<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wc="https://wilhelm.codes/ns/feed/1.0"><channel><title>Wilhelm Codes · Api</title><link>https://wilhelm.codes/tags/api/</link><description>Slinger of nibbles, bits and bytes. Over 25 years of professional experience as a software engineer. Love making glowing rectangles go "beep-boop".</description><generator>Hugo</generator><language>en-US</language><managingEditor>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</managingEditor><webMaster>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</webMaster><lastBuildDate>Thu, 10 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://wilhelm.codes/tags/api/index.xml" rel="self" type="application/rss+xml"/><item><title>What Good is a Number You Can't Call?</title><link>https://wilhelm.codes/blog/what-good-is-a-number-you-cant-call/</link><pubDate>Thu, 10 Sep 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/what-good-is-a-number-you-cant-call/</guid><category>plantsm.art</category><category>api</category><category>updates</category><category>hugo</category><wc:kind>post</wc:kind><description>This one&amp;rsquo;s relatively short. I feel like I&amp;rsquo;ve been posting about Plant🌱Smart a bit too much lately. But I&amp;rsquo;ve made a small change to how the site displays emergency information and it matters enough to warrant its own post. So, here we go.</description><content:encoded><![CDATA[<p>This one&rsquo;s relatively short. I feel like I&rsquo;ve been posting about <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span> a bit too much lately. But I&rsquo;ve made a small change to how the site displays emergency information and it matters enough to warrant its own post. So, here we go.</p>
<h2 id="some-background">
  <a class="heading-link" href="#some-background">Some background.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>For three years every plant page on <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span> ended with the same two phone numbers and both of them were for the US. That never really sat right with me. I even flagged it as roadmap work when I <a href="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/">rebuilt the site</a>, then left it sitting there. The site&rsquo;s whole reason for existing is for the moment your cat eats something and you need an answer now. For most of the people who land there from a search engine, I was answering with a number they can&rsquo;t dial. So, I recently sat down to address that gap, assuming I&rsquo;d spend a few minutes filling in a table of hotlines and be done with it.</p>
<h2 id="every-single-country-had-a-speed-bump">
  <a class="heading-link" href="#every-single-country-had-a-speed-bump">Every single country had a speed bump.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<ul>
<li>Sweden&rsquo;s poison centre stopped answering questions about animals in July 2018 because the human call volume got too high.</li>
<li>The Dutch line only takes calls from vets, so if you&rsquo;re a pet owner in Rotterdam, the correct advice is &ldquo;ring your vet and let them ring the line&rdquo;, which is not a phone number at all.</li>
<li>Belgium answers animal calls only during the day and says outright on its own site that it isn&rsquo;t funded for animal work.</li>
<li>The Australian helpline I&rsquo;d have sworn was free actually starts at <code>$75</code>. This is a bit embarrassing because I live here and <em>should</em> know this.</li>
<li>The UK&rsquo;s fee changes depending on the time of day you call.</li>
<li>The ASPCA doesn&rsquo;t publish its consultation fee anywhere on its own page, so the figure everyone quotes online comes from third parties.</li>
</ul>
<p>The list goes on.</p>
<p>I&rsquo;d have got at least half of these wrong from memory, which is a genuinely unsettling thing to realise about emergency information.</p>
<p>Once again, I had to collate a bunch of disparate data and create a shape to support it before munging it all together. Every entry in the hotlines list now cites the operator&rsquo;s page it came from and the date I read it which is the <a href="https://wilhelm.codes/blog/every-record-now-has-a-source/">same rule</a> I put on the plant records in the last post. A Python script holds the whole list and nags me when an entry goes over a year old and it runs as part of the automated deployment. So, the build gives me a big&rsquo;ole bonk on the nose whenever the list goes stale.</p>
<h2 id="the-boring-technical-stuff">
  <a class="heading-link" href="#the-boring-technical-stuff">The boring technical stuff.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The engineering ended up being the easy half and mostly a matter of deciding what fails safely. Hugo writes every country into the HTML as a partial. A small script then lifts the relevant one out and folds the rest away. It guesses in order: a country you&rsquo;ve pinned, then the location filter if it names exactly one country, then your browser locale.</p>
<p>It never asks permission for anything, because a permission prompt at 2am is the last thing you need while you&rsquo;re panicking about your pet. And, personally, I <em>hate</em> browser prompts asking for location tracking. Hard pass. If the script doesn&rsquo;t run, or if the service worker hands you a stale page, you get all countries instead of the wrong one.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>Too many numbers costs you a few seconds. The wrong number costs you the call.</p>
  </blockquote>
  <figcaption>
    <cite>It me.</cite>
  </figcaption>
</figure>

<p>There&rsquo;s also a new <a href="https://plantsm.art/emergency/">emergency</a> page for the first five minutes before you get through: what to do, what not to do and what they&rsquo;ll ask you. The guidance follows the ASPCA and the Pet Poison Helpline which is also cited and linked. After all, the one thing I&rsquo;m definitely not qualified to write is clinical advice.</p>
<h2 id="another-free-gift-the-hotlines-api">
  <a class="heading-link" href="#another-free-gift-the-hotlines-api">Another free gift: the hotlines API.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>There&rsquo;s really no obvious centralised place online that collates all this information into a single data set. I&rsquo;ve at least done the work of bootstrapping the handful of most common countries that have helplines and the list will definitely grow over time. So, I&rsquo;ve exposed a new &ldquo;hotlines&rdquo; <a href="https://plantsm.art/api/hotlines.json">API endpoint</a> with its own documented shape.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">JSON</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;countries&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// ... more countries ...
</span></span></span><span class="line"><span class="cl">        <span class="nt">&#34;US&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;checked&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-09-10&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;United States&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;services&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">                <span class="p">{</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;audience&#34;</span><span class="p">:</span> <span class="s2">&#34;owner&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;display&#34;</span><span class="p">:</span> <span class="s2">&#34;(888) 426-4435&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;fee&#34;</span><span class="p">:</span> <span class="s2">&#34;A consultation fee applies&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;hours&#34;</span><span class="p">:</span> <span class="s2">&#34;24 hours, every day&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;org&#34;</span><span class="p">:</span> <span class="s2">&#34;ASPCA Animal Poison Control Center&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="s2">&#34;https://www.aspca.org/pet-care/animal-poison-control&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;tel&#34;</span><span class="p">:</span> <span class="s2">&#34;+18884264435&#34;</span>
</span></span><span class="line"><span class="cl">                <span class="p">},</span>
</span></span><span class="line"><span class="cl">                <span class="p">{</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;audience&#34;</span><span class="p">:</span> <span class="s2">&#34;owner&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;display&#34;</span><span class="p">:</span> <span class="s2">&#34;(855) 764-7661&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;fee&#34;</span><span class="p">:</span> <span class="s2">&#34;$89 for each incident&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;hours&#34;</span><span class="p">:</span> <span class="s2">&#34;24 hours, every day&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;org&#34;</span><span class="p">:</span> <span class="s2">&#34;Pet Poison Helpline&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="s2">&#34;https://www.petpoisonhelpline.com/about/&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;tel&#34;</span><span class="p">:</span> <span class="s2">&#34;+18557647661&#34;</span>
</span></span><span class="line"><span class="cl">                <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;notice&#34;</span><span class="p">:</span> <span class="s2">&#34;Emergency telephone numbers for animal poisoning. Each country cites the operator page the entry came from and the date a person read it. Services close and numbers change, so check an entry before you rely on it. Coverage is not global. A country in this file with an empty services list has been checked and has no service an owner can call. A country that is absent has not been checked.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;updated&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-09-10&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>I felt wrapping it in an <a href="https://www.progress.com/blogs/envelope-design-pattern">envelope</a> was the correct approach here. It leads with the notice and the date I last changed anything. A <code>.countries</code> object follows, keyed by ISO 3166-1 alpha-2 code. At the very least, it gives me room to change a country&rsquo;s metadata later without breaking the top-level shape.</p>
<p>The <code>audience</code> field on each service is what carries the Dutch problem into the data. It reads either <code>owner</code> or <code>vet</code>. A <code>vet</code> entry means the number exists but you can&rsquo;t ring it yourself, so the entry tells you to phone your vet and let them make the call.</p>
<p>Anyway, along with some stylistic changes and code cleanup, that&rsquo;s pretty much all there is to it for this update. I&rsquo;ll be giving <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span> a rest for a bit while all the changes settle in. This gives me some time to work on other fun things!</p>
<p>Enjoy, nerds!</p>]]></content:encoded></item><item><title>Every Record Now Has a Source</title><link>https://wilhelm.codes/blog/every-record-now-has-a-source/</link><pubDate>Sat, 05 Sep 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/every-record-now-has-a-source/</guid><category>plantsm.art</category><category>api</category><wc:kind>post</wc:kind><description>As an experiment, last week I introduced r/InternetIsBeautiful to Plant🌱Smart. To my surprise, it was overall very well received! Obviously, there were a few inevitable bad-faith comments from your standard-issue dickheads, but along with a lot of positive vibes I also got heaps of great suggestions and feature requests. So, this post covers not only a database scrub, but everything I&amp;rsquo;ve added as a result of the thread.</description><content:encoded><![CDATA[<p>As an experiment, last week I introduced <a href="https://www.reddit.com/r/InternetIsBeautiful/">r/InternetIsBeautiful</a> to <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span>. To my surprise, it was overall very <a href="https://www.reddit.com/r/InternetIsBeautiful/comments/1w5utet/i_got_tired_of_frantically_googling_is_this_plant/">well received</a>! Obviously, there were a few inevitable bad-faith comments from your standard-issue dickheads, but along with a lot of positive vibes I also got heaps of great suggestions and feature requests. So, this post covers not only a database scrub, but everything I&rsquo;ve added as a result of the thread.</p>
<h2 id="the-database-lost-some-weight">
  <a class="heading-link" href="#the-database-lost-some-weight">The database lost some weight.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Originally, the plant database held around <code>480</code> records. It now holds around <code>280</code>, so roughly <code>200</code> records are gone. About <code>170</code> of them came from a single symptom list and all of them included &ldquo;cardiac failure&rdquo;. That one symptom pushed every associated record to the top severity level. They read as &ldquo;critical&rdquo; because of the shared symptom list, not because of the plant itself. The list came from older sourced data that I never properly scrubbed and vetted, so I nixed those records entirely from the primary data set.</p>
<p>Sucks, but a smaller database you can trust beats a bigger one you cannot.</p>
<h3 id="how-i-source-vetted-data">
  <a class="heading-link" href="#how-i-source-vetted-data">How I source vetted data.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>At the moment, I check each record against the following authoritative data sources:</p>
<ul>
<li>The <a href="https://www.aspca.org/">ASPCA</a> toxic plant list. It gives the clinical signs for dogs, cats and horses. This is the original reference that started the project way back in December of 2022.</li>
<li>The <a href="https://poisonousplants.cvmbs.colostate.edu/">Guide to Poisonous Plants</a> from the College of Veterinary Medicine and Biomedical Sciences at Colorado State University. It gives the clinical signs for each body system and it names the animals that the plant affects. It is stronger than the ASPCA list on horses and other farm animals.</li>
</ul>
<p>Every one of the <code>280</code> or so records that remain traces back to at least one of these two references. This is a <em>hard</em> requirement. If a plant can&rsquo;t clear it, it doesn&rsquo;t get a record.</p>
<p>Both references are copyrighted material. I cite them and link to them directly without publishing any of their text. Each record holds my own symptom names only. If you&rsquo;re curious, the <a href="https://plantsm.art/attributions/">attributions page</a> lists every licensed source used to build the site.</p>
<p>If you know of another authoritative data set that covers animals, the plants that are toxic to them and the associated symptoms, please let me know in the comments below or on the <a href="https://plantsm.art/contribute/">contribute page</a>.</p>
<h3 id="what-i-removed-specifically">
  <a class="heading-link" href="#what-i-removed-specifically">What I removed, specifically.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>I removed the inaccurate records and enriched and corrected the rest. These changes break down into three groups.</p>
<ol>
<li>The old data set covered seven animal types. The update reduces coverage to cats, dogs and horses. That doesn&rsquo;t mean I&rsquo;ve given up on birds, fish, reptiles and small mammals. If I find good data for any of those groups, they go straight back in. Until then, they&rsquo;re parked.</li>
<li>I also changed the rule that sets the severity level of each record. The old rule just took the worst symptom on the record. Now there&rsquo;s an extra condition: a record with a single &ldquo;critical&rdquo; symptom and no &ldquo;severe&rdquo; symptoms gets marked &ldquo;severe&rdquo; instead. Nothing huge, but I felt it was worth mentioning.</li>
<li>A handful of records turned out to be duplicates, so I merged them.</li>
</ol>
<div class="callout callout-warning">
  A plant that is not in this database is a plant I cannot verify. If I can&rsquo;t verify it, it&rsquo;s not something I would personally call safe. Always consult your veterinarian when in doubt!
</div>

<h2 id="the-plant-index-is-now-a-field-guide">
  <a class="heading-link" href="#the-plant-index-is-now-a-field-guide">The plant index is now a &ldquo;Field Guide&rdquo;.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Reddit user <a href="https://www.reddit.com/user/Abbot_of_Cucany/">u/Abbot_of_Cucany</a> mentioned that their bird guide indexes each species twice: the common name in plain type and the scientific name in italic type. I thought this was a great idea, so I built it. Of course, I <a href="https://plantsm.art/plants/">got a little carried away</a>, so the page now also holds a filter box. Type part of a name and the page shows every matching plant on record. A letter row sticks to the top of the screen while you scroll and it marks the letter you&rsquo;re currently reading through.</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: The field guide showing a partial filter match for &#34;pea&#34;.">
      <img
  src="https://wilhelm.codes/blog/every-record-now-has-a-source/field-guide_hu_565905eb6e9ebe77.webp"
  srcset="/blog/every-record-now-has-a-source/field-guide_hu_565905eb6e9ebe77.webp 736w, /blog/every-record-now-has-a-source/field-guide_hu_22f9451b693f70db.webp 1104w, /blog/every-record-now-has-a-source/field-guide_hu_e79f76b4507b8f5a.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="455"
  alt="The field guide showing a partial filter match for &#34;pea&#34;."
  loading="lazy"
  decoding="async"
  data-full="/blog/every-record-now-has-a-source/field-guide_hu_95762cf08491285a.webp"
  data-credit="The field guide, filtered on &#34;pea&#34;."
  
/>

    </button>
    
      <figcaption>
        
          The field guide, filtered on &#34;pea&#34;.
        
      </figcaption>
    
  </figure>
<h2 id="the-front-page-got-some-filter-updates">
  <a class="heading-link" href="#the-front-page-got-some-filter-updates">The front page got some filter updates!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Some people requested more filters, so I delivered. The additions cover severity, plant shape and the geographical regions where you might come across a plant in the wild. Those last two data points were already sourced, but not really in use. At least not in any obvious way. I also added a newer, tighter, photo-centric grid view for smaller screens. Personally, I think it looks good, but I&rsquo;m not sure if I&rsquo;m going to keep it. Let me know what you think!</p>
<p>The extra dropdowns squished everything together in the filter bar, so I had to rethink how to display them. I replaced all the individual dropdowns with a single multi-level filter menu. You can now click around and filter as you go. I also shrank the icons a bit to save more space, but they expand when you hover over them with a mouse or tap them on a small screen.</p>
<p>Also, worth noting: your filter selections stay in the browser between visits.</p>
<div class="masonry">
  
  <button type="button" class="zoom" aria-label="Zoom image: Expanding the animal filter on hover.">
      <img
    src="https://wilhelm.codes/blog/every-record-now-has-a-source/filter-one_hu_59139fcdbc92cbdb.webp"
    srcset="/blog/every-record-now-has-a-source/filter-one_hu_59139fcdbc92cbdb.webp 232w, /blog/every-record-now-has-a-source/filter-one_hu_7ee35f28aaa54166.webp 464w, /blog/every-record-now-has-a-source/filter-one_hu_a6061e0eb2569431.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="57"
    alt="Expanding the animal filter on hover."
    loading="lazy"
    decoding="async"
    data-full="/blog/every-record-now-has-a-source/filter-one_hu_727147be8822309.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The top-level filter dropdown.">
      <img
    src="https://wilhelm.codes/blog/every-record-now-has-a-source/filter-two_hu_3e391d4292ae8b58.webp"
    srcset="/blog/every-record-now-has-a-source/filter-two_hu_3e391d4292ae8b58.webp 232w, /blog/every-record-now-has-a-source/filter-two_hu_9f6e4f737a9412f3.webp 464w, /blog/every-record-now-has-a-source/filter-two_hu_10b6a6629641ca96.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="241"
    alt="The top-level filter dropdown."
    loading="lazy"
    decoding="async"
    data-full="/blog/every-record-now-has-a-source/filter-two_hu_c0e71fd6803796db.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The new shape filter.">
      <img
    src="https://wilhelm.codes/blog/every-record-now-has-a-source/filter-three_hu_996831283bb9b3cb.webp"
    srcset="/blog/every-record-now-has-a-source/filter-three_hu_996831283bb9b3cb.webp 232w, /blog/every-record-now-has-a-source/filter-three_hu_90a3df2b9559054e.webp 464w, /blog/every-record-now-has-a-source/filter-three_hu_a22fb765af80dc6.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="237"
    alt="The new shape filter."
    loading="lazy"
    decoding="async"
    data-full="/blog/every-record-now-has-a-source/filter-three_hu_72ee94969247d390.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: Default filter bar view.">
      <img
    src="https://wilhelm.codes/blog/every-record-now-has-a-source/filter-four_hu_577e7bef322884da.webp"
    srcset="/blog/every-record-now-has-a-source/filter-four_hu_577e7bef322884da.webp 232w, /blog/every-record-now-has-a-source/filter-four_hu_90a8fa52c83043ac.webp 464w, /blog/every-record-now-has-a-source/filter-four_hu_7271fc9ef5ce4738.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="70"
    alt="Default filter bar view."
    loading="lazy"
    decoding="async"
    data-full="/blog/every-record-now-has-a-source/filter-four_hu_55074a478d89b991.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The new severity filter.">
      <img
    src="https://wilhelm.codes/blog/every-record-now-has-a-source/filter-five_hu_f60bb397f0112d7b.webp"
    srcset="/blog/every-record-now-has-a-source/filter-five_hu_f60bb397f0112d7b.webp 232w, /blog/every-record-now-has-a-source/filter-five_hu_53bb61d19f9ba214.webp 464w, /blog/every-record-now-has-a-source/filter-five_hu_11ae826fade33dfb.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="181"
    alt="The new severity filter."
    loading="lazy"
    decoding="async"
    data-full="/blog/every-record-now-has-a-source/filter-five_hu_b4cb749954053bad.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The new card view option.">
      <img
    src="https://wilhelm.codes/blog/every-record-now-has-a-source/card-view_hu_4695a5952fd7838e.webp"
    srcset="/blog/every-record-now-has-a-source/card-view_hu_4695a5952fd7838e.webp 232w, /blog/every-record-now-has-a-source/card-view_hu_69eb238bc3ca1fa7.webp 464w, /blog/every-record-now-has-a-source/card-view_hu_4d215804aba7fd2a.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="158"
    alt="The new card view option."
    loading="lazy"
    decoding="async"
    data-full="/blog/every-record-now-has-a-source/card-view_hu_ba40ad5ce671bb.webp"
    
    
  />
  
    </button>

</div>

<p>The data for the &ldquo;Found in&rdquo; filter comes from <a href="https://www.gbif.org/">GBIF</a>, the Global Biodiversity Information Facility. GBIF counts the wild sightings of each plant in each country. A country counts as a match when it holds one percent or more of the sightings for that plant. It&rsquo;s a totally arbitrary, non-scientific threshold that I imposed to keep the country list as tight as possible.</p>
<h2 id="safe-plants-get-a-facelift">
  <a class="heading-link" href="#safe-plants-get-a-facelift">Safe plants get a facelift!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The <a href="https://plantsm.art/safe/">safe plants list</a> now holds over <code>30</code> common household plants. The new entries include the spider plant, the bamboo palm, sage and baby&rsquo;s breath, among others. A new entry joins this list only when the ASPCA marks the plant as non-toxic and only when the plant is absent from my own database. Each entry also carries a note that names the toxic plant people confuse it with. For instance, bamboo palm is safe while sago palm is deadly.</p>
<p>Based on a couple of user suggestions, each safe plant also includes a small gallery of three related photos sourced directly from the naturalist community at <a href="https://www.inaturalist.org/">iNaturalist</a>. Clicking a photo opens a lightbox with a zoomed-in view along with attribution and licensing information.</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: A preview of some safe plant entries.">
      <img
  src="https://wilhelm.codes/blog/every-record-now-has-a-source/safe-plants_hu_518e75c0605083eb.webp"
  srcset="/blog/every-record-now-has-a-source/safe-plants_hu_518e75c0605083eb.webp 736w, /blog/every-record-now-has-a-source/safe-plants_hu_15c13933de798fc2.webp 1104w, /blog/every-record-now-has-a-source/safe-plants_hu_a53afb27ae8153fe.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="196"
  alt="A preview of some safe plant entries."
  loading="lazy"
  decoding="async"
  data-full="/blog/every-record-now-has-a-source/safe-plants_hu_9063c7d8087527b8.webp"
  data-credit="A few entries from the new safe plants list."
  
/>

    </button>
    
      <figcaption>
        
          A few entries from the new safe plants list.
        
      </figcaption>
    
  </figure>
<h2 id="it-now-has-a-blog-sort-of">
  <a class="heading-link" href="#it-now-has-a-blog-sort-of">It now has a blog&hellip; sort of.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>This part wasn&rsquo;t a suggestion. In addition to the <a href="https://plantsm.art/notes/">updates</a> page, I thought some kind of small blog would make sense. The articles I write about the site live <em>here</em>, where visitors to <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span> will most likely never read them. So, instead of building actual blogging functionality, I decided to syndicate the articles from here. I updated my Hugo templates to generate an RSS feed for each tag. Every article about <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span> carries the <code>plantsm.art</code> tag, so the site can ingest all the relevant content on its own.</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: A syndicated feed that&#39;s in desperate need of more content.">
      <img
  src="https://wilhelm.codes/blog/every-record-now-has-a-source/field-notes_hu_7010e8441282d499.webp"
  srcset="/blog/every-record-now-has-a-source/field-notes_hu_7010e8441282d499.webp 736w, /blog/every-record-now-has-a-source/field-notes_hu_69ea7ea798302772.webp 1104w, /blog/every-record-now-has-a-source/field-notes_hu_90054e81197cd882.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="522"
  alt="A syndicated feed that&#39;s in desperate need of more content."
  loading="lazy"
  decoding="async"
  data-full="/blog/every-record-now-has-a-source/field-notes_hu_9daffcd17af182c0.webp"
  data-credit="A syndicated feed that&#39;s in desperate need of more content."
  
/>

    </button>
    
      <figcaption>
        
          A syndicated feed that&#39;s in desperate need of more content.
        
      </figcaption>
    
  </figure>
<p>The page shows one featured note first, just the latest entry in the list. Five recent notes follow as cards. Finally, an archive section gives one row per note, grouped by year. There&rsquo;s just not enough content at the moment to fill that section out, so it stays hidden for now. Also, something you can&rsquo;t see just yet: a text filter appears after <code>15</code> or so entries and works just like the one in the field guide.</p>
<h2 id="finally-some-changes-to-the-api">
  <a class="heading-link" href="#finally-some-changes-to-the-api">Finally, some changes to the API.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The API gives one file for each animal in the database. Since the data scrub, four of those files no longer exist: <code>birds.json</code>, <code>small-mammals.json</code>, <code>reptiles.json</code> and <code>fish.json</code>. The database itself no longer holds plant records for those animals. The <a href="https://plantsm.art/api/">API page</a> documents the files that remain. Every other endpoint and every other shape stays the same.</p>
<h2 id="whats-next">
  <a class="heading-link" href="#whats-next">What&rsquo;s next?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I want the other four animal types back. A rabbit owner needs this data as much as a dog owner does. I&rsquo;m on the lookout for a reference that gives the symptoms per plant for birds, fish, reptiles and small mammals, but there&rsquo;s just not a lot of authoritative data out there. If you know of one, tell me in the comments below or on the <a href="https://plantsm.art/contribute/">contribute page</a>.</p>
<p>I also want those <code>170</code> records back, but each one needs a reference that names the plant and gives the clinical signs. Until then, they stay on the back burner.</p>
<p>That pretty much covers it. It&rsquo;s been a fairly hectic week of coding and rolling out updates, but it&rsquo;s been a lot of fun. At the time of writing, the <a href="https://www.reddit.com/r/InternetIsBeautiful/comments/1w5utet/i_got_tired_of_frantically_googling_is_this_plant/">Reddit post</a> sits at over <code>530</code> upvotes! It&rsquo;s always risky sharing something you&rsquo;ve made with the world, but more often than not, people appreciate it. It really put a smile on my face.</p>
<p>Till next time!</p>]]></content:encoded></item><item><title>A Changelog That Builds Itself</title><link>https://wilhelm.codes/blog/a-changelog-that-builds-itself/</link><pubDate>Sun, 14 Jun 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/a-changelog-that-builds-itself/</guid><category>hugo</category><category>github</category><category>api</category><wc:kind>post</wc:kind><description>This site has a changelog page. It&amp;rsquo;s not one I write by hand as it builds itself from my activity on GitHub every time the site deploys. I think it&amp;rsquo;s a neat little trick. It also spent a solid afternoon teaching me that &amp;ldquo;works on my machine&amp;rdquo; and &amp;ldquo;works in production&amp;rdquo; are, once again, two very different things.</description><content:encoded><![CDATA[<p>This site has a <a href="https://wilhelm.codes/changelog/">changelog</a> page. It&rsquo;s not one I write by hand as it builds itself from my activity on GitHub every time the site deploys. I think it&rsquo;s a neat little trick. It also spent a solid afternoon teaching me that &ldquo;works on my machine&rdquo; and &ldquo;works in production&rdquo; are, once again, two very different things.</p>
<p>If you use Github, you know it quietly records nearly everything you do as a stream of <a href="https://docs.github.com/en/rest/activity/events">events</a>. Better still, for a public repository, that stream is available over a public, unauthenticated API endpoint through a simple <code>GET</code> request.</p>
<p>So the plan more or less writes itself. We fetch the stream at build time, group the events by day, and let Hugo render them. Hugo even has a tidy <a href="https://gohugo.io/functions/resources/getremote/">little function</a> for pulling in remote resources that works like so:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">url</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="s">&#34;https://api.github.com/repos/wilhelm-murdoch/wilhelm.codes/events&#34;</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="nf">try</span><span class="w"> </span><span class="p">(</span><span class="nx">resources</span><span class="p">.</span><span class="nx">GetRemote</span><span class="w"> </span><span class="err">$</span><span class="nx">url</span><span class="p">)</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">events</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="p">.</span><span class="nx">Value</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">transform</span><span class="p">.</span><span class="nx">Unmarshal</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="cm">/* ...range over them and render... */</span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>Because this all happens at <em>build</em> time, the visitor never waits on GitHub. By the time the page reaches a browser it&rsquo;s just static HTML like everything else. The changelog is always current as of the last deploy, and I never have to think about it.</p>
<p>That&rsquo;s the part that worked. Now for the parts that didn&rsquo;t.</p>
<h2 id="-then-production-happened">
  <a class="heading-link" href="#-then-production-happened">&hellip; Then, production happened.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I&rsquo;ve been more or less absent from this site for close to a year, but this past week I&rsquo;ve decided to breathe a bit more life into it. I made a few changes to <a href="https://wilhelm.codes/blog/some-long-overdue-housekeeping/">modernise</a> the stack, pushed my changes up and waited on Cloudflare to do its thing and&hellip;</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">ERROR error building site: ... error calling GetRemote:
</span></span><span class="line"><span class="cl">failed to fetch remote resource from &#39;https://api.github.com/...&#39;: Forbidden</span></span></code></pre></div>
</div>
<p>I was greeted with a <code>403</code>. Except&hellip; I could paste that exact URL into my browser and get a perfectly happy wall of JSON back. So what gives?</p>
<p>Rate limiting, that&rsquo;s what. GitHub&rsquo;s unauthenticated API is capped at <strong>60 requests per hour, per IP address</strong>. My build doesn&rsquo;t run on <em>my</em> IP. It runs on a shared build machine alongside who-knows-how-many other people&rsquo;s deploys, all of them hammering GitHub from the same handful of addresses. By the time my build rolled around, that bucket was bone dry.</p>
<p>Honestly, a <code>429</code> instead here would have saved me a bit of investigating, but I digress.</p>
<p>The solution was to created a properly scoped Github PAT. Using an authenticated request gets <strong>5000 requests per hour</strong>, so I went about implementing support and handed it to the build as an environment variable, and taught the template to send it along:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">opts</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">dict</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="nx">os</span><span class="p">.</span><span class="nx">Getenv</span><span class="w"> </span><span class="s">&#34;HUGO_GITHUB_TOKEN&#34;</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">opts</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="nx">dict</span><span class="w"> </span><span class="s">&#34;headers&#34;</span><span class="w"> </span><span class="p">(</span><span class="nx">dict</span><span class="w"> </span><span class="s">&#34;Authorization&#34;</span><span class="w"> </span><span class="p">(</span><span class="nx">printf</span><span class="w"> </span><span class="s">&#34;Bearer %s&#34;</span><span class="w"> </span><span class="p">.))</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">remote</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nf">try</span><span class="w"> </span><span class="p">(</span><span class="nx">resources</span><span class="p">.</span><span class="nx">GetRemote</span><span class="w"> </span><span class="err">$</span><span class="nx">url</span><span class="w"> </span><span class="err">$</span><span class="nx">opts</span><span class="p">)</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>It&rsquo;s worth noting that Hugo won&rsquo;t read just <em>any</em> environment variable. Its security policy only lets <code>os.Getenv</code> see variables prefixed with <code>HUGO_</code>. Name your token <code>GITHUB_TOKEN</code> and you&rsquo;ll get a confusing fistful of nothing; name it <code>HUGO_GITHUB_TOKEN</code> and it works. Ask me how I know.</p>
<h2 id="hark-another-footgun">
  <a class="heading-link" href="#hark-another-footgun">Hark! Another Footgun!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>With the token in place, the fetch succeeded and the build promptly fell over somewhere new:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">error calling len: reflect: call of reflect.Value.Type on zero Value</span></span></code></pre></div>
</div>
<p>This never showed up locally, which tells me I should do myself a favour and do a quick local <em>production</em> deployment as a preflight <em>before</em> I push to Github and trigger an automated build. Originally, in development I rendered the changelog from a single large fixture file stored locally on disk. This was before I implemented the PAT so I wouldn&rsquo;t rate limit myself locally when testing. The live stream, however, contains event shapes my fixture simply didn&rsquo;t. One of them was a <code>push</code> event carrying no <code>commits</code> at all, and my template did this without a second thought:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="nf">eq</span><span class="w"> </span><span class="p">(</span><span class="nx">len</span><span class="w"> </span><span class="p">.</span><span class="nx">payload</span><span class="p">.</span><span class="nx">commits</span><span class="p">)</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>Call <code>len</code> on something that exists and isn&rsquo;t there and Go has a small panic about it. The fix is boring; stop assuming the field is there. Hugo&rsquo;s <code>with</code> only enters the block when its argument is actually&hellip; something:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="p">.</span><span class="nx">payload</span><span class="p">.</span><span class="nx">commits</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="nf">eq</span><span class="w"> </span><span class="p">(</span><span class="nx">len</span><span class="w"> </span><span class="p">.)</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">{{</span><span class="cm">/* the one-commit case */</span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">{{</span><span class="cm">/* the many-commits case */</span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>Since I&rsquo;ve implemented PAT support, I&rsquo;m not so concerned about being rate-limited during local development, though I now use the fixtures as a fallback if any request fails. This allows me to keep working locally without disruption.</p>
<h2 id="dont-let-someone-elses-server-break-your-build">
  <a class="heading-link" href="#dont-let-someone-elses-server-break-your-build">Don&rsquo;t let someone else&rsquo;s server break your build.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The real personal lesson is the moment your build depends on a third party at build time, you&rsquo;ve effectively ceded control of your deployment to them. GitHub rate-limits you, or has a wobble, or changes a payload shape, and suddenly your perfectly good site won&rsquo;t deploy and you&rsquo;ll have a hard time.</p>
<p>So the changelog no longer treats GitHub as load-bearing. If the fetch fails for <em>any</em> reason it shrugs, logs a warning, and falls back to that saved fixture instead of taking the whole deploy down with it:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">remote</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nf">try</span><span class="w"> </span><span class="p">(</span><span class="nx">resources</span><span class="p">.</span><span class="nx">GetRemote</span><span class="w"> </span><span class="err">$</span><span class="nx">url</span><span class="w"> </span><span class="err">$</span><span class="nx">opts</span><span class="p">)</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="err">$</span><span class="nx">remote</span><span class="p">.</span><span class="nx">Err</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="nx">warnf</span><span class="w"> </span><span class="s">&#34;changelog: GitHub fetch failed (%s); using the fixture&#34;</span><span class="w"> </span><span class="p">.</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">response</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="nx">os</span><span class="p">.</span><span class="nx">ReadFile</span><span class="w"> </span><span class="s">&#34;static/github.json&#34;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">transform</span><span class="p">.</span><span class="nx">Unmarshal</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="err">$</span><span class="nx">remote</span><span class="p">.</span><span class="nx">Value</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">response</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="p">.</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">transform</span><span class="p">.</span><span class="nx">Unmarshal</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>The <code>try</code> keyword is the hero here as it catches the error that <code>GetRemote</code> would otherwise throw and hands it back to me as a value I can actually <em>do</em> something with, rather than a smoking crater where my build used to be. Worst case, the page shows slightly stale history. That&rsquo;s a trade I&rsquo;ll take every single time over a red deploy or disruption to my local flow.</p>
<h2 id="in-conclusion">
  <a class="heading-link" href="#in-conclusion">In conclusion&hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Ironically, this is something I&rsquo;ve dealt with quite frequently at work. Github &amp; AWS API rate limits ( yes, we frequently got rate limited by the VPC API of all things ), <a href="https://yarnpkg.com/">Yarn</a> will throw a <code>5xx</code> and even Sentry will chimp out during source map uploads. All of these things can disrupt deployments.</p>
<p>The point is just assume these things will happen and then build defensively around them. I, ehhhh, just need to apply this wisdom to my personal stuff. 😅</p>
<p>Anyway, the changelog still builds itself. It just doesn&rsquo;t get to take the rest of the site down with it anymore.</p>]]></content:encoded></item><item><title>How to Mass-Unfollow Instagram Accounts</title><link>https://wilhelm.codes/blog/how-to-mass-unfollow-instagram-accounts/</link><pubDate>Tue, 07 Jan 2025 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/how-to-mass-unfollow-instagram-accounts/</guid><category>bash</category><category>api</category><category>tutorials</category><category>snippets</category><wc:kind>post</wc:kind><description>I&amp;rsquo;m not super-active on Instagram these days and Threads was a real let down. I have a dormant Mastodon account, but find myself being quite happy on Bluesky at the moment. Who knows how long that will last. Having recently read about Meta creating loads of fake AI-based accounts, I thought it was time to do some spring cleaning.</description><content:encoded><![CDATA[<p>I&rsquo;m not super-active on Instagram these days and Threads was a real let down. I have a dormant Mastodon account, but find myself being quite happy on <a href="https://bsky.app/profile/wilhelm.codes">Bluesky</a> at the moment. Who knows how long that will last. Having recently read about Meta creating loads of <a href="https://www.404media.co/metas-ai-profiles-are-indistinguishable-from-terrible-spam-that-took-over-facebook/">fake AI-based accounts</a>, I thought it was time to do some spring cleaning.</p>
<h2 id="before-we-begin">
  <a class="heading-link" href="#before-we-begin">Before we begin<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Today, I went ahead and made my Instagram profile private. I have no desire to delete my posts just yet, but decided to go ahead and unfollow everyone as a start. There are several ways to do this:</p>
<ul>
<li>API calls using an SDK, which requires a developer account.</li>
<li>Manually deleting &ldquo;handraulically&rdquo; via the native app or browser, but that can quickly become tedious if you have more than a hundred or so follows.</li>
<li>Random &ldquo;GreaseMonkey&rdquo; scripts off the web that may, or may not, work properly if at all.</li>
<li>Reverse-engineering XHR calls from your browser and writing a simple Bash script to automate the process.</li>
</ul>
<p>If you&rsquo;ve read some of my <a href="https://wilhelm.codes/blog/why-cant-i-hold-all-these-slack-emojis/">previous</a> <a href="https://wilhelm.codes/blog/liberating-custom-slack-emojis/">blog</a> <a href="https://wilhelm.codes/blog/falsifying-github-participation-graphs-for-fun-and-profit/">posts</a>, you probably can guess which one I&rsquo;ll be going with.</p>
<h3 id="requirements">
  <a class="heading-link" href="#requirements">Requirements<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>This guide assumes you have <em>some</em> programming or scripting knowledge.</p>
<p>You will need the following on your machine:</p>
<ul>
<li>A shell terminal with Bash, or ZSH, support.</li>
<li><code>curl</code> to make API calls from the command line.</li>
<li><code>jq</code> to easily parse JSON-based responses from said API calls. This can easily be installed using <a href="https://brew.sh/">Homebrew</a> or some other supported package manager.</li>
<li>A modern browser that supports a developer tools console to intercept XHR calls.</li>
</ul>
<p>It&rsquo;s worth mentioning that I am using a MacBook with the Chrome browser, but other webkit-based browsers should support similar functionality.</p>
<h2 id="fire-up-the-browser">
  <a class="heading-link" href="#fire-up-the-browser">Fire up the browser!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Open up your browser, or new tab, and navigate to your Instagram profile. Open your browser&rsquo;s developer console and navigate to the &ldquo;Network&rdquo; tab. Clear out whatever requests are currently listed and then filter by &ldquo;Fetch/XHR&rdquo;.</p>
<h3 id="your-following-list">
  <a class="heading-link" href="#your-following-list">Your following list<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>We need to intercept an XHR request that returns a JSON body containing some of your followers. Click the &ldquo;following&rdquo; link and monitor the resulting requests that pop in the list on the &ldquo;Network&rdquo; tab.</p>
<p><img
  src="https://wilhelm.codes/blog/how-to-mass-unfollow-instagram-accounts/image-1_hu_6f1c3624c92cee84.webp"
  srcset="/blog/how-to-mass-unfollow-instagram-accounts/image-1_hu_6f1c3624c92cee84.webp 736w, /blog/how-to-mass-unfollow-instagram-accounts/image-1_hu_d061d7b50a984152.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="219"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>You <em>should</em> see something like <code>following/count=12...</code> in the list. Right-click that item and select &ldquo;Copy as cURL&rdquo;. Paste the new value in your clipboard into a scratch file for reference later.</p>
<p><img
  src="https://wilhelm.codes/blog/how-to-mass-unfollow-instagram-accounts/image-2_hu_eac316580918723e.webp"
  srcset="/blog/how-to-mass-unfollow-instagram-accounts/image-2_hu_eac316580918723e.webp 736w, /blog/how-to-mass-unfollow-instagram-accounts/image-2_hu_5cf36f632fab2260.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="331"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Be aware this command will contain all the headers required to make requests from your terminal on your behalf. Do NOT share these details with anyone. I haven&rsquo;t checked how long until the tokens within the auth headers expire, so assume they are long(ish)-lived and treat them accordingly.</p>
<h3 id="an-unfollow-request">
  <a class="heading-link" href="#an-unfollow-request">An unfollow request<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Next, we need to intercept an XHR request to <em>unfollow</em> someone. Going back to your following list, select a random user and unfollow them while once again keeping an eye on the &ldquo;Network&rdquo; tab.</p>
<p><img
  src="https://wilhelm.codes/blog/how-to-mass-unfollow-instagram-accounts/image-3_hu_3c8e9df334aa9ff5.webp"
  srcset="/blog/how-to-mass-unfollow-instagram-accounts/image-3_hu_3c8e9df334aa9ff5.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="508"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>You&rsquo;re going to see something like the following; an XHR event making a <code>POST</code> request to &ldquo;destroy&rdquo; your chosen follower. Once again, you&rsquo;ll right-click and &ldquo;Copy and cURL&rdquo;. Paste this into your scratch file as well for future reference.</p>
<p><img
  src="https://wilhelm.codes/blog/how-to-mass-unfollow-instagram-accounts/image-4_hu_b009422977c7ada3.webp"
  srcset="/blog/how-to-mass-unfollow-instagram-accounts/image-4_hu_b009422977c7ada3.webp 736w, /blog/how-to-mass-unfollow-instagram-accounts/image-4_hu_fbde267ec81cb211.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="285"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h2 id="putting-it-all-together">
  <a class="heading-link" href="#putting-it-all-together">Putting it all together<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Now that you have your two types of requests, we&rsquo;ll need to script the following steps:</p>
<ul>
<li>Execute a <code>curl</code> request to fetch a JSON body containing the accounts you follow, one page of results at a time.</li>
<li>Use <code>jq</code> to extract their Instagram IDs.</li>
<li>Store the results in a variable named&hellip; drumroll, pls&hellip; <code>$ids</code>.</li>
<li>Iterate through each <code>$id</code>.</li>
<li>Execute a <code>curl</code> request to unfollow each <code>$id</code>.</li>
</ul>
<p>Here is the meat of the script. I have included only the necessary headers required to successfully make these requests on your Instagram account&rsquo;s behalf. You&rsquo;ll need to swap out the <code>...</code> with your own values.</p>
<p>You may also notice that I&rsquo;ve change the <code>count=...</code> parameter to <code>100</code>. It may be able to go higher, but I haven&rsquo;t tested it.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">ids</span><span class="o">=</span><span class="k">$(</span>
</span></span><span class="line"><span class="cl">  curl <span class="s1">&#39;https://www.instagram.com/api/v1/friendships/.../following/?count=100&amp;hl=en&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;cookie: ...&#39;</span>           <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-asbd-id: ...&#39;</span>        <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-csrftoken: ...&#39;</span>      <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-ig-app-id: ...&#39;</span>      <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-ig-www-claim: ...&#39;</span>   <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-requested-with: ...&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-web-session-id: ...&#39;</span> <span class="p">|</span> jq -r <span class="s1">&#39;.users[].id&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span> <span class="nb">read</span> -r id<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> -n <span class="s2">&#34;Unfollowing user &#39;</span><span class="si">${</span><span class="nv">id</span><span class="si">}</span><span class="s2">&#39;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  curl -s -o /dev/null <span class="s2">&#34;https://www.instagram.com/api/v1/friendships/destroy/</span><span class="si">${</span><span class="nv">id</span><span class="si">}</span><span class="s2">/?hl=en&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;cookie: ...&#39;</span>           <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-asbd-id: ...&#39;</span>        <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-csrftoken: ...&#39;</span>      <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-ig-app-id: ...&#39;</span>      <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-ig-www-claim: ...&#39;</span>   <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-requested-with: ...&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-web-session-id: ...&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --data-raw <span class="s2">&#34;container_module=profile&amp;nav_chain=PolarisProfilePostsTabRoot%3AprofilePage%3A1%3Avia_cold_start%2CPolarisProfilePostsTabRoot%3AprofilePage%3A2%3Aunexpected&amp;user_id=</span><span class="si">${</span><span class="nv">id</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s1">&#39; ... done!&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span> <span class="o">&lt;&lt;&lt;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">ids</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Finished&#34;</span></span></span></code></pre></div>
</div>
<p>You can save this as a file and make it executable, but I just paste this bad boy into your terminal. The above script will remove <code>100</code> accounts at a time. Just keep executing the script until you no longer see results like the following:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Unfollowing user &#39;111&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;222&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;333&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;444&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;555&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;666&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;777&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;888&#39;... done
</span></span><span class="line"><span class="cl">Finished</span></span></code></pre></div>
</div>
<h3 id="caveats-gotchas--other-things">
  <a class="heading-link" href="#caveats-gotchas--other-things">Caveats, gotchas &amp; other things<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>I&rsquo;ve only had a few hundred accounts that I followed, so I didn&rsquo;t feel the need to make this a bullet-proof solution. There&rsquo;s quite a bit missing and things can go wrong. Here are some things to consider off the top of my head :</p>
<ul>
<li>The session tokens will expire eventually, so you will have to refer back to your &ldquo;Network&rdquo; tab in your developer tools console to get new ones.</li>
<li>As this example only unfollows a static amount at a time, re-executing the script may become tedious for very large follow numbers. Perhaps, you can modify this script to add some basic pagination.</li>
<li>There is no error handling here, so keep an eye out for potential rate limiting or expired token issues with the Instagram API.</li>
<li>Constantly updating session header values can be made a bit simpler by resorting to variables instead.</li>
</ul>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Cleaning out old, unused or unwanted social media accounts can be liberating. I&rsquo;ve recently gutted my Twitter presence and I haven&rsquo;t been on Facebook in years, though I still do use Messenger to keep in touch with a handful of people. I&rsquo;d prefer using Signal for this, but trying to convince other people to install yet another messaging app is like pulling teeth.</p>
<p>Anyway, I actually enjoy writing these kinds of posts. What other social networks should I cover? LinkedIn? Threads? What are some ways you can improve and build on the above? Lemme know in the comment section below.</p>]]></content:encoded></item><item><title>Why Can't I Hold All These Slack Emojis?</title><link>https://wilhelm.codes/blog/why-cant-i-hold-all-these-slack-emojis/</link><pubDate>Wed, 28 Sep 2022 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/why-cant-i-hold-all-these-slack-emojis/</guid><category>slack</category><category>emoji</category><category>bash</category><category>api</category><wc:kind>post</wc:kind><description>Previously, last blog post I wrote covered how to make a quick escape with your precious hoard of custom Slack emojis. It was fairly well-received, but didn&amp;rsquo;t quite cover the next step in the migration process; how do you upload your millions of little images to your new Slack workspace?</description><content:encoded><![CDATA[<p>Previously, <a href="https://wilhelm.codes/liberating-custom-slack-emojis">last blog post</a> I wrote covered how to make a quick escape with your precious hoard of custom Slack emojis. It was fairly well-received, but didn&rsquo;t quite cover the next step in the migration process; how do you upload your millions of little images to your <em>new</em> Slack workspace?</p>
<h2 id="gathering-requirements">
  <a class="heading-link" href="#gathering-requirements">Gathering Requirements<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Since we&rsquo;re doing some destructive operations, we&rsquo;ll need to make sure our Slack user has the appropriate permissions to manage emoji; adding &amp; deleting. This means using a different section of the Slack interface which uses an entirely seperate set of API endpoints.</p>
<p>You&rsquo;ll still be using the following, which can be found using instructions from <a href="https://wilhelm.codes/liberating-custom-slack-emojis#heading-some-investigative-work">the previous article</a>:</p>
<ul>
<li>An API request token.</li>
<li>A session cookie.</li>
<li>A workspace, or team, id.</li>
</ul>
<p>In addition, you will need your workspaces&rsquo;s subdomain, or URL. This can easily be found within the Slack app itself:</p>
<p><img
  src="https://wilhelm.codes/blog/why-cant-i-hold-all-these-slack-emojis/image-1_hu_dacba861f5d90b40.webp"
  srcset="/blog/why-cant-i-hold-all-these-slack-emojis/image-1_hu_dacba861f5d90b40.webp 607w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="607"
  height="490"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h2 id="testing-your-findings">
  <a class="heading-link" href="#testing-your-findings">Testing Your Findings<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>This is an incredibly straight-forward process as it&rsquo;s quite similar to what we already know. The primary difference is this is a <code>multipart/form-data</code> upload. So, instead of shipping of a JSON payload, it&rsquo;s a form with associated fields.</p>
<p>With the information gathered above, you can upload directly to Slack using a simple cURL command like so:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s --compressed <span class="s2">&#34;https://&lt;domain&gt;.slack.com/api/emoji.add&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s1">&#39;content-type: multipart/form-data&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;cookie: d=&lt;cookie&gt;;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -F <span class="s2">&#34;token=&lt;token&gt;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -F <span class="s2">&#34;name=&lt;name&gt;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -F <span class="nv">mode</span><span class="o">=</span>data <span class="se">\
</span></span></span><span class="line"><span class="cl">  -F <span class="s2">&#34;image=@&lt;local-emoji-path&gt;&#34;</span></span></span></code></pre></div>
</div>
<p>Replace the following:</p>
<ul>
<li><code>&lt;domain&gt;</code> is your workspace&rsquo;s, or team&rsquo;s, private URL.</li>
<li><code>&lt;cookie&gt;</code> is your session cookie value.</li>
<li><code>&lt;token&gt;</code> is your request token.</li>
<li><code>&lt;name&gt;</code> will be the named reference of your new emoji.</li>
<li><code>&lt;path&gt;</code> is the relative, or absolute, local path of your emoji file to upload.</li>
</ul>
<p>The JSON response to this request will have the following structure if all goes well:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">JSON</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span> <span class="nt">&#34;ok&#34;</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span></span></span></code></pre></div>
</div>
<p>And, if something went wrong:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">JSON</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span> 
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;ok&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;err_code&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>That&rsquo;s it! Now that we know how to upload from the command line.</p>
<h2 id="the-implementation">
  <a class="heading-link" href="#the-implementation">The Implementation<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>If you&rsquo;re only uploading a handful of emojis, it might make sense to just do it via Slack&rsquo;s UI. However, this can get a bit tedious if you have dozens, or even hundreds, to upload.  We can  automate things even further by:</p>
<ul>
<li>Gathering all supported images from a specific source directory.</li>
<li>Iterating through our findings and bulk-upload them all in one go.</li>
</ul>
<p>So, let&rsquo;s do just that. But, first, let&rsquo;s set up some environmental variables so we can easily configure out script:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_DOMAIN</span><span class="p">:=</span><span class="si">}</span></span></span></code></pre></div>
</div>
<p>Slack only supports <code>png</code>, <code>gif</code> and <code>jpg</code> image formats, so lets see what we can find from our present working directory:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">files</span><span class="o">=</span><span class="k">$(</span>find . -iname <span class="se">\*</span>.gif -o -iname <span class="se">\*</span>.png -o -iname <span class="se">\*</span>.png -maxdepth 1<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">files</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span></span></span></code></pre></div>
</div>
<p>Exit if we can&rsquo;t find any results. No need to continue if we haven&rsquo;t a thing to upload.</p>
<p>Next, we just iterate through our findings using <code>read</code> and some more <a href="https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html">Bash parameter expansion</a> to determine what will be the new emoji&rsquo;s reference name:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">files</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> -r path<span class="p">;</span> <span class="k">do</span> 
</span></span><span class="line"><span class="cl">  <span class="nv">name</span><span class="o">=</span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="si">${</span><span class="nv">path</span><span class="p">%.*</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>We can easily parse the response bodies with <code>jq</code> for some error checking and we&rsquo;re good to go:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.ok&#34;</span><span class="k">)</span> <span class="o">==</span> <span class="nb">false</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.error&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div>
</div>
<p>We now have all the information we need to upload each file in bulk. Put it all together and you&rsquo;ve got a working bulk emoji uploader:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -eo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_DOMAIN</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">files</span><span class="o">=</span><span class="k">$(</span>find . -iname <span class="se">\*</span>.gif -o -iname <span class="se">\*</span>.png -o -iname <span class="se">\*</span>.png -maxdepth 1<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">files</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">files</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> -r path<span class="p">;</span> <span class="k">do</span> 
</span></span><span class="line"><span class="cl">  <span class="nv">name</span><span class="o">=</span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="si">${</span><span class="nv">path</span><span class="p">%.*</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nv">result</span><span class="o">=</span><span class="k">$(</span>
</span></span><span class="line"><span class="cl">    curl -s --compressed <span class="s2">&#34;https://</span><span class="si">${</span><span class="nv">SLACK_DOMAIN</span><span class="si">}</span><span class="s2">.slack.com/api/emoji.add&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -H <span class="s1">&#39;content-type: multipart/form-data&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -H <span class="s2">&#34;cookie: d=</span><span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="si">}</span><span class="s2">;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -F <span class="s2">&#34;token=</span><span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="si">}</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -F <span class="s2">&#34;name=</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -F <span class="nv">mode</span><span class="o">=</span>data <span class="se">\
</span></span></span><span class="line"><span class="cl">      -F <span class="s2">&#34;image=@</span><span class="si">${</span><span class="nv">path</span><span class="si">}</span><span class="s2">&#34;</span> 
</span></span><span class="line"><span class="cl">  <span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.ok&#34;</span><span class="k">)</span> <span class="o">==</span> <span class="nb">false</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.error&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;uploaded :</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">:!&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>There you go! To test it out yourself, save this as an executable script and make sure your present working directory has some supported images in it. Pass through the your environmental variables when executing and you&rsquo;re off:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ <span class="nv">SLACK_DOMAIN</span><span class="o">=</span>*** <span class="nv">SLACK_COOKIE</span><span class="o">=</span>*** <span class="nv">SLACK_TOKEN</span><span class="o">=</span>*** ./upload.sh
</span></span><span class="line"><span class="cl">uploaded :stonks:!
</span></span><span class="line"><span class="cl">uploaded :boop:!
</span></span><span class="line"><span class="cl">uploaded :derp:!
</span></span><span class="line"><span class="cl">uploaded :booyah:!
</span></span><span class="line"><span class="cl">uploaded :merp-flakes:!</span></span></code></pre></div>
</div>
<p>Nice! 😊</p>
<h2 id="once-again-something-better">
  <a class="heading-link" href="#once-again-something-better">Once Again, Something Better!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>While perfectly functional, there&rsquo;s not a lot of flexibility. No error checking, filtering or confirmation checks. If you&rsquo;re looking for something a bit more fleshed out, <a href="https://github.com/wilhelm-murdoch/slack-emoji-toolkit">look no further</a>!</p>
<h2 id="the-final-piece-">
  <a class="heading-link" href="#the-final-piece-">The Final Piece &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>So far, we&rsquo;ve gone over how to download and upload large sets of emoji, but what if you want to nuke them from orbit? The final article in this series will cover how to bulk delete while using advanced filters to pin-point specific sets of emoji you wish to remove.</p>
<p>Hope you&rsquo;ve learned something useful!</p>]]></content:encoded></item><item><title>Liberating Custom Slack Emojis</title><link>https://wilhelm.codes/blog/liberating-custom-slack-emojis/</link><pubDate>Tue, 20 Sep 2022 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/liberating-custom-slack-emojis/</guid><category>emoji</category><category>api</category><category>bash</category><wc:kind>post</wc:kind><description>As is stupid tradition, whenever I start at a new company, one of the first things I like to do while getting settled in is upload my favourite emojis to whatever real-time messaging platform is in use. I know it&amp;rsquo;s childish, but silly memes and emojis are great ways for you to break the ice with your new coworkers.</description><content:encoded><![CDATA[<p>As is stupid tradition, whenever I start at a new company, one of the first things I like to do while getting settled in is upload my favourite emojis to whatever real-time messaging platform is in use. I know it&rsquo;s childish, but silly memes and emojis are great ways for you to break the ice with your new coworkers.</p>
<p>But, when it comes time to part ways it&rsquo;s only understandable to want to gather your stuff for the next place to start the process again. Over the years one can collect and curate quite a nice hoard of stupid images.</p>
<p>I&rsquo;ve been using Slack prolifically for the past few years. Unfortunately, there is no simple way of exporting your precious collection outside of the old “right-click and save” method. This is effective for smaller collections of a couple dozen or so, but becomes impractical when there are hundreds or even thousands.</p>
<p>And, of course, I&rsquo;m not alone. There have been a non-zero number of times when someone left the company only to hit me up later asking for an emoji archive.</p>
<p>Yes, this really happens and something <em>must</em> be done about it!</p>
<p>What follows is an unnecessarily long dive into a little bit of reverse engineering one of Slack&rsquo;s API endpoints, subverting it for our own use and writing a stupid little tool to help ourselves ( and others ) automate this process for the future.</p>
<h2 id="some-investigative-work">
  <a class="heading-link" href="#some-investigative-work">Some Investigative Work<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>There just has to be a straightforward way we can <em>mostly</em> automate this. So, I did some cursory digging around the Chrome Developer Tools console and found this endpoint in the “Network” tab:</p>
<div class="code-block">
  <pre tabindex="0"><code>https://edgeapi.slack.com/cache/T0XXXX/emojis/list?fp=97</code></pre>
</div>
<p>The <code>T0XXXX</code> will be your workspace, or team, id. Make note of it, you&rsquo;ll need it later.</p>
<p>If you intercept one of these requests and take a peak at the “Response” tab in the “Network” panel, you&rsquo;ll see a JSON response with a structure similar to:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">JSON</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;ok&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;next_marker&#34;</span><span class="p">:</span> <span class="s2">&#34;a-custom-emoji&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;results&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">		<span class="p">{</span>
</span></span><span class="line"><span class="cl">			<span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;another-custom-emoji&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">			<span class="nt">&#34;value&#34;</span><span class="p">:</span> <span class="s2">&#34;https://emoji.slack-edge.com/T0XXXX/another-custom-emoji/xxxxxxx.png&#34;</span>
</span></span><span class="line"><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="cl">		<span class="err">...</span> <span class="err">more</span> <span class="err">emojis</span> <span class="err">...</span>
</span></span><span class="line"><span class="cl">	<span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<ul>
<li><code>ok</code> tells you whether the request was successful or not. If it returns <code>false</code> there will be an additional field named <code>error</code> that should clue you into what went wrong.</li>
<li><code>next_marker</code> tells you where in the list of custom emoji the <em>next</em> page of results should start. We use this as the value of <code>marker</code> in subsequent request payloads ( see below ). This is effectively how you page through large lists of emoji.</li>
<li><code>results</code> should be obvious, but it contains an array of objects with <code>name</code> and <code>value</code> fields.
<ul>
<li><code>name</code> is the alias of the emoji.</li>
<li><code>value</code> is the direct CDN URL to the emoji.</li>
</ul>
</li>
</ul>
<p>You may see other fields, but <code>next_marker</code> and <code>value</code> are the only fields we really care about.</p>
<p>Next, If you take a look at the “Payload” tab in the “Network” panel for the same request, you&rsquo;ll see something similar to:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">JSON</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;token&#34;</span><span class="p">:</span> <span class="s2">&#34;xoxc-xxxx-xxxx-xxxx&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;count&#34;</span><span class="p">:</span> <span class="mi">100</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;marker&#34;</span><span class="p">:</span> <span class="s2">&#34;some-other-emoji&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<ul>
<li><code>token</code> is the API request token the Slack client uses to make the emoji list request on your behalf.</li>
<li><code>count</code> is the amount of emojis to return as a page.</li>
<li><code>marker</code> tells the API where in the emoji list to start paging.</li>
</ul>
<p>The final piece of the puzzle is getting the value of your session cookie. You can grab this by clicking the “Request Headers” section under the “Headers” tab. You should see a header labeled <code>cookie</code>.</p>
<p>There&rsquo;s going to be a fairly large block of text to parse through, but you&rsquo;re looking for the cookie named <code>d</code>. Save everything between the <code>d=</code> and the closing <code>;</code>. You&rsquo;re going to need this to authenticate with the Slack API itself.</p>
<h2 id="testing-your-findings">
  <a class="heading-link" href="#testing-your-findings">Testing Your Findings<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>We now understand the structure of our payload. We also have the endpoint to hit as well as our tokens and session cookie to authenticate. Piecing it all together, you can now use cURL to hit the API directly from your terminal:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ curl --silent https://edgeapi.slack.com/cache/&lt;team&gt;/emojis/list?fp<span class="o">=</span><span class="m">97</span>
</span></span><span class="line"><span class="cl">  -H <span class="s1">&#39;cookie: d=&lt;cookie&gt;;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --data-raw <span class="s1">&#39;{&#34;token&#34;:&#34;&lt;token&gt;&#34;,&#34;count&#34;:10}&#39;</span></span></span></code></pre></div>
</div>
<p>Replace the following:</p>
<ul>
<li><code>&lt;team&gt;</code> is your workspace, or team, id.</li>
<li><code>&lt;cookie&gt;</code> is your session cookie value.</li>
<li><code>&lt;token&gt;</code> is your request token.</li>
</ul>
<p>If everything worked out, you should have successful API response like the one above. If the response has root field named <code>marker_next</code>, make note of the associated value to help page through subsequent results. Keep doing this in a loop until your response no longer returns a <code>marker_next</code> field.</p>
<p>Once that happens, you&rsquo;re at the end of the list.</p>
<p>With a little bit of help from <code>jq</code> to parse and filter the JSON responses, we can grab a simple list of URLs.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ &lt;previous_curl_command&gt; <span class="p">|</span> jq -r <span class="s1">&#39;.results[].value&#39;</span>
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/no/060fca9aa2581a93.png
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/nods/c7fe54342ab5b8b6.gif
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/nods-back/fbd1b5384cdd0905.gif
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/nomnomnom/612cfc74c785010d.gif
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/octocat/627964d7c9.png
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/ohyou/6a352df984d9c076.gif
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/one-sec-cooking/e749627cb088859d.png
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/oof/db94710445cbb206.png
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/petrol/35ed3db238f795fc.png
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/piggy/b7762ee8cd.png</span></span></code></pre></div>
</div>
<p>We now understand how to collect everything we need, so let&rsquo;s automate this a bit more.</p>
<h2 id="the-implementation">
  <a class="heading-link" href="#the-implementation">The Implementation<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>We&rsquo;re going to write a little script that&rsquo;ll download all the custom emojis from the target Slack workspace. It&rsquo;s going to perform the following steps:</p>
<ol>
<li>Define a starting JSON payload.</li>
<li>Create a run loop.</li>
<li>With each loop we make a request using the API endpoint.</li>
<li>Do some error checking.</li>
<li>Iterate through the results and save them to disk.</li>
<li>If the results contain a <code>next_marker</code> update the JSON payload with a <code>marker</code> field and continue the run loop.</li>
<li>Keep going until the last request no longer provides a <code>next_marker</code>.</li>
</ol>
<p>First, we want to define some environmental variables so we can easily configure the script:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_WORKSPACE_ID</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="p">:=</span><span class="si">}</span></span></span></code></pre></div>
</div>
<p>Now we define our JSON payload object. We use <code>jq</code> here to keep things easy. Not only is it great for parsing and filtering JSON in Bash, it also makes building and manipulating objects simple:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">payload</span><span class="o">=</span><span class="k">$(</span>
</span></span><span class="line"><span class="cl">  jq -nc <span class="se">\
</span></span></span><span class="line"><span class="cl">    --arg     token <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="si">}</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --argjson count <span class="s2">&#34;100&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s1">&#39;{
</span></span></span><span class="line"><span class="cl"><span class="s1">      token: $token, 
</span></span></span><span class="line"><span class="cl"><span class="s1">      count: $count
</span></span></span><span class="line"><span class="cl"><span class="s1">    }&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">)</span></span></span></code></pre></div>
</div>
<p>Next, is the run loop where we make our API requests. This passes through the payload object we just defined while also targeting our workspace:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">while</span> true<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">result</span><span class="o">=</span><span class="k">$(</span>
</span></span><span class="line"><span class="cl">    curl -s --compressed <span class="s2">&#34;https://edgeapi.slack.com/cache/</span><span class="si">${</span><span class="nv">SLACK_WORKSPACE_ID</span><span class="si">}</span><span class="s2">/emojis/list?fp=97&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -H <span class="s2">&#34;cookie: d=</span><span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="si">}</span><span class="s2">;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      --data-raw <span class="s2">&#34;</span><span class="si">${</span><span class="nv">payload</span><span class="si">}</span><span class="s2">&#34;</span> 
</span></span><span class="line"><span class="cl">  <span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>We obviously want to do some simple error checking so we know what we did wrong if the requests fail. Grab the error, spit it out and terminate the script.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.ok&#34;</span><span class="k">)</span> <span class="o">==</span> <span class="nb">false</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.error&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div>
</div>
<p>We now start iterating through any of the results we got from the initial request. We really only care about the <code>value</code> field as the URL gives us all the pieces we need to give our emojis a human-readable filename.</p>
<p>Here we treat the resulting URL as a standard file path and use <code>dirname</code> paired with <code>basename</code> to get the name of the parent directory of the file. The parent directory holds the actual name of the emoji, while the file itself is only a random hash. We use <a href="https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html">Bash&rsquo;s parameter expansion</a> feature to construct our filename.</p>
<p>Finally, we download the remote file to the current directory:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span>  <span class="p">|</span> jq -r <span class="s1">&#39;.results[].value&#39;</span> <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> -r url<span class="p">;</span> <span class="k">do</span> 
</span></span><span class="line"><span class="cl"><span class="nv">name</span><span class="o">=</span><span class="k">$(</span>basename <span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="si">${</span><span class="nv">url</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">))</span>
</span></span><span class="line"><span class="cl">curl -s -o <span class="s2">&#34;</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">.</span><span class="si">${</span><span class="nv">url</span><span class="p">##*.</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">url</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>We need to check if there are more results to page through, so let&rsquo;s look for the <code>marker_next</code> field. If we can&rsquo;t find one, then mission accomplished; all done. However, if we do get a value, we take the previous payload we constructed and <em>add</em> the <code>marker</code> field.</p>
<p>The next loop will pick this change up and pass it on through to the API request telling Slack to give us the next page of precious emojis.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">marker</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.next_marker&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">marker</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;null&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">payload</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">payload</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq --arg marker <span class="s2">&#34;</span><span class="si">${</span><span class="nv">marker</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s1">&#39;. + { marker: $marker }&#39;</span><span class="k">)</span></span></span></code></pre></div>
</div>
<h2 id="the-finished-product">
  <a class="heading-link" href="#the-finished-product">The Finished Product<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -eo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_WORKSPACE_ID</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">payload</span><span class="o">=</span><span class="k">$(</span>
</span></span><span class="line"><span class="cl">  jq -nc <span class="se">\
</span></span></span><span class="line"><span class="cl">    --arg     token <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="si">}</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --argjson count <span class="s2">&#34;100&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s1">&#39;{
</span></span></span><span class="line"><span class="cl"><span class="s1">      token: $token, 
</span></span></span><span class="line"><span class="cl"><span class="s1">      count: $count
</span></span></span><span class="line"><span class="cl"><span class="s1">    }&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">while</span> true<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">result</span><span class="o">=</span><span class="k">$(</span>
</span></span><span class="line"><span class="cl">    curl -s --compressed <span class="s2">&#34;https://edgeapi.slack.com/cache/</span><span class="si">${</span><span class="nv">SLACK_WORKSPACE_ID</span><span class="si">}</span><span class="s2">/emojis/list?fp=97&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -H <span class="s2">&#34;cookie: d=</span><span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="si">}</span><span class="s2">;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      --data-raw <span class="s2">&#34;</span><span class="si">${</span><span class="nv">payload</span><span class="si">}</span><span class="s2">&#34;</span> 
</span></span><span class="line"><span class="cl">  <span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.ok&#34;</span><span class="k">)</span> <span class="o">==</span> <span class="nb">false</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.error&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span>  <span class="p">|</span> jq -r <span class="s1">&#39;.results[].value&#39;</span> <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> -r url<span class="p">;</span> <span class="k">do</span> 
</span></span><span class="line"><span class="cl">    <span class="nv">name</span><span class="o">=</span><span class="k">$(</span>basename <span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="si">${</span><span class="nv">url</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">))</span>
</span></span><span class="line"><span class="cl">    curl -s -o <span class="s2">&#34;</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">.</span><span class="si">${</span><span class="nv">url</span><span class="p">##*.</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">url</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">done</span> 
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nv">marker</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.next_marker&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">marker</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;null&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nv">payload</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">payload</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq --arg marker <span class="s2">&#34;</span><span class="si">${</span><span class="nv">marker</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s1">&#39;. + { marker: $marker }&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>This is pretty much it. A barebones script that downloads all findings to its present working directory. To test it out yourself, make the script executable, define the environmental variables and fire it off like so:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ <span class="nv">SLACK_WORKSPACE_ID</span><span class="o">=</span>*** <span class="nv">SLACK_COOKIE</span><span class="o">=</span>*** <span class="nv">SLACK_TOKEN</span><span class="o">=</span>*** ./fetch.sh</span></span></code></pre></div>
</div>
<h2 id="an-even-better-version">
  <a class="heading-link" href="#an-even-better-version">An Even Better Version!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I know I just made you read through all this, but you also could&rsquo;ve just <a href="https://github.com/wilhelm-murdoch/slack-emoji-toolkit">downloaded the tool from here</a>. It&rsquo;s got heaps more bells and whistles ( if you&rsquo;re into that sort of thing):</p>
<h2 id="the-bitter-irony">
  <a class="heading-link" href="#the-bitter-irony">The Bitter Irony<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>In typical bored engineer fashion, I&rsquo;ve spent more time writing this article, documenting and publishing the code than I ever would have personally just “handraulically” doing this from time to time.</p>
<p>Hopefully, you&rsquo;ve learned something new! 🤞</p>
<h2 id="bonus-relevant-xkcd-image">
  <a class="heading-link" href="#bonus-relevant-xkcd-image">Bonus Relevant XKCD Image<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p><img
  src="https://wilhelm.codes/blog/liberating-custom-slack-emojis/image-1_hu_190150482d40845e.webp"
  srcset="/blog/liberating-custom-slack-emojis/image-1_hu_190150482d40845e.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="740"
  alt=""
  
  loading="lazy"
  decoding="async"
/>

source: <a href="https://xkcd.com/1319/">xkcd: automation</a></p>]]></content:encoded></item></channel></rss>