<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: observable</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/observable.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2025-11-19T22:00:34+00:00</updated><author><name>Simon Willison</name></author><entry><title>How I automate my Substack newsletter with content from my blog</title><link href="https://simonwillison.net/2025/Nov/19/how-i-automate-my-substack-newsletter/#atom-tag" rel="alternate"/><published>2025-11-19T22:00:34+00:00</published><updated>2025-11-19T22:00:34+00:00</updated><id>https://simonwillison.net/2025/Nov/19/how-i-automate-my-substack-newsletter/#atom-tag</id><summary type="html">
    &lt;p&gt;I sent out &lt;a href="https://simonw.substack.com/p/trying-out-gemini-3-pro-with-audio"&gt;my weekly-ish Substack newsletter&lt;/a&gt; this morning and took the opportunity to record &lt;a href="https://www.youtube.com/watch?v=BoPZltKDM-s"&gt;a YouTube video&lt;/a&gt; demonstrating my process and describing the different components that make it work. There's a &lt;em&gt;lot&lt;/em&gt; of digital duct tape involved, taking the content from Django+Heroku+PostgreSQL to GitHub Actions to SQLite+Datasette+Fly.io to JavaScript+Observable and finally to Substack.&lt;/p&gt;

&lt;p&gt;&lt;lite-youtube videoid="BoPZltKDM-s" js-api="js-api"
  title="How I automate my Substack newsletter with content from my blog"
  playlabel="Play: How I automate my Substack newsletter with content from my blog"
&gt; &lt;/lite-youtube&gt;&lt;/p&gt;

&lt;p&gt;The core process is the same as I described &lt;a href="https://simonwillison.net/2023/Apr/4/substack-observable/"&gt;back in 2023&lt;/a&gt;. I have an Observable notebook called &lt;a href="https://observablehq.com/@simonw/blog-to-newsletter"&gt;blog-to-newsletter&lt;/a&gt; which fetches content from my blog's database, filters out anything that has been in the newsletter before, formats what's left as HTML and offers a big "Copy rich text newsletter to clipboard" button.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/copy-to-newsletter.jpg" alt="Screenshot of the interface. An item in a list says 9080: Trying out Gemini 3 Pro with audio transcription and a new pelican benchmark. A huge button reads Copy rich text newsletter to clipboard - below is a smaller button that says Copy just the links/quotes/TILs. A Last X days slider is set to 2. There are checkboxes for SKip content sent in prior newsletters and only include post content prior to the cutoff comment." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I click that button, paste the result into the Substack editor, tweak a few things and hit send. The whole process usually takes just a few minutes.&lt;/p&gt;
&lt;p&gt;I make very minor edits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I set the title and the subheading for the newsletter. This is often a direct copy of the title of the featured blog post.&lt;/li&gt;
&lt;li&gt;Substack turns YouTube URLs into embeds, which often isn't what I want - especially if I have a YouTube URL inside a code example.&lt;/li&gt;
&lt;li&gt;Blocks of preformatted text often have an extra blank line at the end, which I remove.&lt;/li&gt;
&lt;li&gt;Occasionally I'll make a content edit - removing a piece of content that doesn't fit the newsletter, or fixing a time reference like "yesterday" that doesn't make sense any more.&lt;/li&gt;
&lt;li&gt;I pick the featured image for the newsletter and add some tags.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That's the whole process!&lt;/p&gt;
&lt;h4 id="the-observable-notebook"&gt;The Observable notebook&lt;/h4&gt;
&lt;p&gt;The most important cell in the Observable notebook is this one:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-s1"&gt;raw_content&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c1"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;await&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;
    &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-en"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
      &lt;span class="pl-s"&gt;`https://datasette.simonwillison.net/simonwillisonblog.json?sql=&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-en"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-s1"&gt;        &lt;span class="pl-s1"&gt;sql&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-s1"&gt;      &lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;amp;_shape=array&amp;amp;numdays=&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-s1"&gt;numDays&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;`&lt;/span&gt;
    &lt;span class="pl-kos"&gt;)&lt;/span&gt;
  &lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;json&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This uses the JavaScript &lt;code&gt;fetch()&lt;/code&gt; function to pull data from my blog's Datasette instance, using a very complex SQL query that is composed elsewhere in the notebook.&lt;/p&gt;
&lt;p&gt;Here's a link to &lt;a href="https://datasette.simonwillison.net/simonwillisonblog?sql=with+content+as+%28%0D%0A++select%0D%0A++++id%2C%0D%0A++++%27entry%27+as+type%2C%0D%0A++++title%2C%0D%0A++++created%2C%0D%0A++++slug%2C%0D%0A++++%27%3Ch3%3E%3Ca+href%3D%22%27+%7C%7C+%27https%3A%2F%2Fsimonwillison.net%2F%27+%7C%7C+strftime%28%27%25Y%2F%27%2C+created%29%0D%0A++++++%7C%7C+substr%28%27JanFebMarAprMayJunJulAugSepOctNovDec%27%2C+%28strftime%28%27%25m%27%2C+created%29+-+1%29+*+3+%2B+1%2C+3%29+%0D%0A++++++%7C%7C+%27%2F%27+%7C%7C+cast%28strftime%28%27%25d%27%2C+created%29+as+integer%29+%7C%7C+%27%2F%27+%7C%7C+slug+%7C%7C+%27%2F%27+%7C%7C+%27%22%3E%27+%0D%0A++++++%7C%7C+title+%7C%7C+%27%3C%2Fa%3E+-+%27+%7C%7C+date%28created%29+%7C%7C+%27%3C%2Fh3%3E%27+%7C%7C+body%0D%0A++++++as+html%2C%0D%0A++++%27null%27+as+json%2C%0D%0A++++%27%27+as+external_url%0D%0A++from+blog_entry%0D%0A++union+all%0D%0A++select%0D%0A++++id%2C%0D%0A++++%27blogmark%27+as+type%2C%0D%0A++++link_title%2C%0D%0A++++created%2C%0D%0A++++slug%2C%0D%0A++++%27%3Cp%3E%3Cstrong%3ELink%3C%2Fstrong%3E+%27+%7C%7C+date%28created%29+%7C%7C+%27+%3Ca+href%3D%22%27%7C%7C+link_url+%7C%7C+%27%22%3E%27%0D%0A++++++%7C%7C+link_title+%7C%7C+%27%3C%2Fa%3E%3A%3C%2Fp%3E%3Cp%3E%27+%7C%7C+%27+%27+%7C%7C+replace%28commentary%2C+%27%0D%0A%27%2C+%27%3Cbr%3E%27%29+%7C%7C+%27%3C%2Fp%3E%27%0D%0A++++++as+html%2C%0D%0A++++json_object%28%0D%0A++++++%27created%27%2C+date%28created%29%2C%0D%0A++++++%27link_url%27%2C+link_url%2C%0D%0A++++++%27link_title%27%2C+link_title%2C%0D%0A++++++%27commentary%27%2C+commentary%2C%0D%0A++++++%27use_markdown%27%2C+use_markdown%0D%0A++++%29+as+json%2C%0D%0A++link_url+as+external_url%0D%0A++from+blog_blogmark%0D%0A++union+all%0D%0A++select%0D%0A++++id%2C%0D%0A++++%27quotation%27+as+type%2C%0D%0A++++source%2C%0D%0A++++created%2C%0D%0A++++slug%2C%0D%0A++++%27%3Cstrong%3Equote%3C%2Fstrong%3E+%27+%7C%7C+date%28created%29+%7C%7C%0D%0A++++%27%3Cblockquote%3E%3Cp%3E%3Cem%3E%27+%7C%7C%0D%0A++++replace%28quotation%2C+%27%0D%0A%27%2C+%27%3Cbr%3E%27%29+%7C%7C+%0D%0A++++%27%3C%2Fem%3E%3C%2Fp%3E%3C%2Fblockquote%3E%3Cp%3E%3Ca+href%3D%22%27+%7C%7C%0D%0A++++coalesce%28source_url%2C+%27%23%27%29+%7C%7C+%27%22%3E%27+%7C%7C+source+%7C%7C+%27%3C%2Fa%3E%27+%7C%7C%0D%0A++++case+%0D%0A++++++++when+nullif%28trim%28context%29%2C+%27%27%29+is+not+null+%0D%0A++++++++then+%27%2C+%27+%7C%7C+context+%0D%0A++++++++else+%27%27+%0D%0A++++end+%7C%7C%0D%0A++++%27%3C%2Fp%3E%27+as+html%2C%0D%0A++++%27null%27+as+json%2C%0D%0A++++source_url+as+external_url%0D%0A++from+blog_quotation%0D%0A++union+all%0D%0A++select%0D%0A++++id%2C%0D%0A++++%27note%27+as+type%2C%0D%0A++++case%0D%0A++++++when+title+is+not+null+and+title+%3C%3E+%27%27+then+title%0D%0A++++++else+%27Note+on+%27+%7C%7C+date%28created%29%0D%0A++++end%2C%0D%0A++++created%2C%0D%0A++++slug%2C%0D%0A++++%27No+HTML%27%2C%0D%0A++++json_object%28%0D%0A++++++%27created%27%2C+date%28created%29%2C%0D%0A++++++%27link_url%27%2C+%27https%3A%2F%2Fsimonwillison.net%2F%27+%7C%7C+strftime%28%27%25Y%2F%27%2C+created%29%0D%0A++++++%7C%7C+substr%28%27JanFebMarAprMayJunJulAugSepOctNovDec%27%2C+%28strftime%28%27%25m%27%2C+created%29+-+1%29+*+3+%2B+1%2C+3%29+%0D%0A++++++%7C%7C+%27%2F%27+%7C%7C+cast%28strftime%28%27%25d%27%2C+created%29+as+integer%29+%7C%7C+%27%2F%27+%7C%7C+slug+%7C%7C+%27%2F%27%2C%0D%0A++++++%27link_title%27%2C+%27%27%2C%0D%0A++++++%27commentary%27%2C+body%2C%0D%0A++++++%27use_markdown%27%2C+1%0D%0A++++%29%2C%0D%0A++++%27%27+as+external_url%0D%0A++from+blog_note%0D%0A++union+all%0D%0A++select%0D%0A++++rowid%2C%0D%0A++++%27til%27+as+type%2C%0D%0A++++title%2C%0D%0A++++created%2C%0D%0A++++%27null%27+as+slug%2C%0D%0A++++%27%3Cp%3E%3Cstrong%3ETIL%3C%2Fstrong%3E+%27+%7C%7C+date%28created%29+%7C%7C+%27+%3Ca+href%3D%22%27%7C%7C+%27https%3A%2F%2Ftil.simonwillison.net%2F%27+%7C%7C+topic+%7C%7C+%27%2F%27+%7C%7C+slug+%7C%7C+%27%22%3E%27+%7C%7C+title+%7C%7C+%27%3C%2Fa%3E%3A%27+%7C%7C+%27+%27+%7C%7C+substr%28html%2C+1%2C+instr%28html%2C+%27%3C%2Fp%3E%27%29+-+1%29+%7C%7C+%27+%26%238230%3B%3C%2Fp%3E%27+as+html%2C%0D%0A++++%27null%27+as+json%2C%0D%0A++++%27https%3A%2F%2Ftil.simonwillison.net%2F%27+%7C%7C+topic+%7C%7C+%27%2F%27+%7C%7C+slug+as+external_url%0D%0A++from+til%0D%0A%29%2C%0D%0Acollected+as+%28%0D%0A++select%0D%0A++++id%2C%0D%0A++++type%2C%0D%0A++++title%2C%0D%0A++++case%0D%0A++++++when+type+%3D+%27til%27%0D%0A++++++then+external_url%0D%0A++++++else+%27https%3A%2F%2Fsimonwillison.net%2F%27+%7C%7C+strftime%28%27%25Y%2F%27%2C+created%29%0D%0A++++++%7C%7C+substr%28%27JanFebMarAprMayJunJulAugSepOctNovDec%27%2C+%28strftime%28%27%25m%27%2C+created%29+-+1%29+*+3+%2B+1%2C+3%29+%7C%7C+%0D%0A++++++%27%2F%27+%7C%7C+cast%28strftime%28%27%25d%27%2C+created%29+as+integer%29+%7C%7C+%27%2F%27+%7C%7C+slug+%7C%7C+%27%2F%27%0D%0A++++++end+as+url%2C%0D%0A++++created%2C%0D%0A++++html%2C%0D%0A++++json%2C%0D%0A++++external_url%2C%0D%0A++++case%0D%0A++++++when+type+%3D+%27entry%27+then+%28%0D%0A++++++++select+json_group_array%28tag%29%0D%0A++++++++from+blog_tag%0D%0A++++++++join+blog_entry_tags+on+blog_tag.id+%3D+blog_entry_tags.tag_id%0D%0A++++++++where+blog_entry_tags.entry_id+%3D+content.id%0D%0A++++++%29%0D%0A++++++when+type+%3D+%27blogmark%27+then+%28%0D%0A++++++++select+json_group_array%28tag%29%0D%0A++++++++from+blog_tag%0D%0A++++++++join+blog_blogmark_tags+on+blog_tag.id+%3D+blog_blogmark_tags.tag_id%0D%0A++++++++where+blog_blogmark_tags.blogmark_id+%3D+content.id%0D%0A++++++%29%0D%0A++++++when+type+%3D+%27quotation%27+then+%28%0D%0A++++++++select+json_group_array%28tag%29%0D%0A++++++++from+blog_tag%0D%0A++++++++join+blog_quotation_tags+on+blog_tag.id+%3D+blog_quotation_tags.tag_id%0D%0A++++++++where+blog_quotation_tags.quotation_id+%3D+content.id%0D%0A++++++%29%0D%0A++++++else+%27%5B%5D%27%0D%0A++++end+as+tags%0D%0A++from+content%0D%0A++where+created+%3E%3D+date%28%27now%27%2C+%27-%27+%7C%7C+%3Anumdays+%7C%7C+%27+days%27%29+++%0D%0A++order+by+created+desc%0D%0A%29%0D%0Aselect+id%2C+type%2C+title%2C+url%2C+created%2C+html%2C+json%2C+external_url%2C+tags%0D%0Afrom+collected+%0D%0Aorder+by+%0D%0A++case+type+%0D%0A++++when+%27entry%27+then+0+%0D%0A++++else+1+%0D%0A++end%2C%0D%0A++case+type+%0D%0A++++when+%27entry%27+then+created+%0D%0A++++else+-strftime%28%27%25s%27%2C+created%29+%0D%0A++end+desc%3B&amp;amp;numdays=7"&gt;see and execute that query&lt;/a&gt; directly in Datasette. It's 143 lines of convoluted SQL that assembles most of the HTML for the newsletter using SQLite string concatenation! An illustrative snippet:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;with content &lt;span class="pl-k"&gt;as&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt;
    id,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;entry&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; type,
    title,
    created,
    slug,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;h3&amp;gt;&amp;lt;a href="&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;https://simonwillison.net/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%Y/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created)
      &lt;span class="pl-k"&gt;||&lt;/span&gt; substr(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;JanFebMarAprMayJunJulAugSepOctNovDec&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, (strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%m&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created) &lt;span class="pl-k"&gt;-&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;) &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-c1"&gt;3&lt;/span&gt; &lt;span class="pl-k"&gt;+&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;, &lt;span class="pl-c1"&gt;3&lt;/span&gt;) 
      &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; cast(strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%d&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created) &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-k"&gt;integer&lt;/span&gt;) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; slug &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;"&amp;gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; 
      &lt;span class="pl-k"&gt;||&lt;/span&gt; title &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;/a&amp;gt; - &lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-k"&gt;date&lt;/span&gt;(created) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;/h3&amp;gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; body
      &lt;span class="pl-k"&gt;as&lt;/span&gt; html,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;null&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; json,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; external_url
  &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_entry
  &lt;span class="pl-k"&gt;union all&lt;/span&gt;
  &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; ...&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;My blog's URLs look like &lt;code&gt;/2025/Nov/18/gemini-3/&lt;/code&gt; - this SQL constructs that three letter month abbreviation from the month number using a substring operation.&lt;/p&gt;
&lt;p&gt;This is a &lt;em&gt;terrible&lt;/em&gt; way to assemble HTML, but I've stuck with it because it amuses me.&lt;/p&gt;
&lt;p&gt;The rest of the Observable notebook takes that data, filters out anything that links to content mentioned in the previous newsletters and composes it into a block of HTML that can be copied using that big button.&lt;/p&gt;
&lt;p&gt;Here's the recipe it uses to turn HTML into rich text content on a clipboard suitable for Substack. I can't remember how I figured this out but it's very effective:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-v"&gt;Object&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;assign&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-en"&gt;html&lt;/span&gt;&lt;span class="pl-s"&gt;`&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;button&lt;/span&gt; &lt;span class="pl-c1"&gt;style&lt;/span&gt;="&lt;span class="pl-s"&gt;font-size: 1.4em; padding: 0.3em 1em; font-weight: bold;&lt;/span&gt;"&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;Copy rich text newsletter to clipboard`&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-en"&gt;onclick&lt;/span&gt;: &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;htmlContent&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;newsletterHTML&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-c"&gt;// Create a temporary element to hold the HTML content&lt;/span&gt;
      &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;tempElement&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;createElement&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"div"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-s1"&gt;tempElement&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;innerHTML&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;htmlContent&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;body&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;appendChild&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;tempElement&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-c"&gt;// Select the HTML content&lt;/span&gt;
      &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;range&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;createRange&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-s1"&gt;range&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;selectNode&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;tempElement&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-c"&gt;// Copy the selected HTML content to the clipboard&lt;/span&gt;
      &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;selection&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getSelection&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-s1"&gt;selection&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;removeAllRanges&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-s1"&gt;selection&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;addRange&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;range&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;execCommand&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"copy"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-s1"&gt;selection&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;removeAllRanges&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;body&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;removeChild&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;tempElement&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h4 id="from-django-postgresql-to-datasette-sqlite"&gt;From Django+Postgresql to Datasette+SQLite&lt;/h4&gt;
&lt;p&gt;My blog itself is a Django application hosted on Heroku, with data stored in Heroku PostgreSQL. Here's &lt;a href="https://github.com/simonw/simonwillisonblog"&gt;the source code for that Django application&lt;/a&gt;. I use the Django admin as my CMS.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; provides a JSON API over a SQLite database... which means something needs to convert that PostgreSQL database into a SQLite database that Datasette can use.&lt;/p&gt;
&lt;p&gt;My system for doing that lives in the &lt;a href="https://github.com/simonw/simonwillisonblog-backup"&gt;simonw/simonwillisonblog-backup&lt;/a&gt; GitHub repository. It uses GitHub Actions on a schedule that executes every two hours, fetching the latest data from PostgreSQL and converting that to SQLite.&lt;/p&gt;
&lt;p&gt;My &lt;a href="https://github.com/simonw/db-to-sqlite"&gt;db-to-sqlite&lt;/a&gt; tool is responsible for that conversion. I call it &lt;a href="https://github.com/simonw/simonwillisonblog-backup/blob/dc5b9df272134ce051a5280b4de6d4daa9b2a9fc/.github/workflows/backup.yml#L44-L62"&gt;like this&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;db-to-sqlite \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;$(&lt;/span&gt;heroku config:get DATABASE_URL -a simonwillisonblog &lt;span class="pl-k"&gt;|&lt;/span&gt; sed s/postgres:/postgresql+psycopg2:/&lt;span class="pl-pds"&gt;)&lt;/span&gt;&lt;/span&gt; \
  simonwillisonblog.db \
  --table auth_permission \
  --table auth_user \
  --table blog_blogmark \
  --table blog_blogmark_tags \
  --table blog_entry \
  --table blog_entry_tags \
  --table blog_quotation \
  --table blog_quotation_tags \
  --table blog_note \
  --table blog_note_tags \
  --table blog_tag \
  --table blog_previoustagname \
  --table blog_series \
  --table django_content_type \
  --table redirects_redirect&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That &lt;code&gt;heroku config:get DATABASE_URL&lt;/code&gt; command uses Heroku credentials in an environment variable to fetch the database connection URL for my blog's PostgreSQL database (and fixes a small difference in the URL scheme).&lt;/p&gt;
&lt;p&gt;&lt;code&gt;db-to-sqlite&lt;/code&gt; can then export that data and write it to a SQLite database file called &lt;code&gt;simonwillisonblog.db&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;--table&lt;/code&gt; options specify the tables that should be included in the export.&lt;/p&gt;
&lt;p&gt;The repository does more than just that conversion: it also exports the resulting data to JSON files that live in the repository, which gives me a &lt;a href="https://github.com/simonw/simonwillisonblog-backup/commits/main/simonwillisonblog"&gt;commit history&lt;/a&gt; of changes I make to my content. This is a cheap way to get a revision history of my blog content without having to mess around with detailed history tracking inside the Django application itself.&lt;/p&gt;
&lt;p&gt;At the &lt;a href="https://github.com/simonw/simonwillisonblog-backup/blob/dc5b9df272134ce051a5280b4de6d4daa9b2a9fc/.github/workflows/backup.yml#L200-L204"&gt;end of my GitHub Actions workflow&lt;/a&gt; is this code that publishes the resulting database to Datasette running on &lt;a href="https://fly.io/"&gt;Fly.io&lt;/a&gt; using the &lt;a href="https://datasette.io/plugins/datasette-publish-fly"&gt;datasette publish fly&lt;/a&gt; plugin:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;datasette publish fly simonwillisonblog.db \
  -m metadata.yml \
  --app simonwillisonblog-backup \
  --branch 1.0a2 \
  --extra-options &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;--setting sql_time_limit_ms 15000 --setting truncate_cells_html 10000 --setting allow_facet off&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  --install datasette-block-robots \
  &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; ... more plugins&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;As you can see, there are a lot of moving parts! Surprisingly it all mostly just works - I rarely have to intervene in the process, and the cost of those different components is pleasantly low.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/blogging"&gt;blogging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/youtube"&gt;youtube&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/heroku"&gt;heroku&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/fly"&gt;fly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/newsletter"&gt;newsletter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/substack"&gt;substack&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/site-upgrades"&gt;site-upgrades&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="blogging"/><category term="django"/><category term="javascript"/><category term="postgresql"/><category term="sql"/><category term="sqlite"/><category term="youtube"/><category term="heroku"/><category term="datasette"/><category term="observable"/><category term="github-actions"/><category term="fly"/><category term="newsletter"/><category term="substack"/><category term="site-upgrades"/></entry><entry><title>Tom MacWright: Observable Notebooks 2.0</title><link href="https://simonwillison.net/2025/Aug/6/observable-notebooks-20/#atom-tag" rel="alternate"/><published>2025-08-06T16:37:13+00:00</published><updated>2025-08-06T16:37:13+00:00</updated><id>https://simonwillison.net/2025/Aug/6/observable-notebooks-20/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://macwright.com/2025/07/31/observable-notebooks-2"&gt;Tom MacWright: Observable Notebooks 2.0&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Observable announced &lt;a href="https://observablehq.com/notebook-kit/"&gt;Observable Notebooks 2.0&lt;/a&gt; last week - the latest take on their JavaScript notebook technology, this time with an &lt;a href="https://observablehq.com/notebook-kit/kit"&gt;open file format&lt;/a&gt; and a brand new &lt;a href="https://observablehq.com/notebook-kit/desktop"&gt;macOS desktop app&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Tom MacWright worked at Observable during their first iteration and here provides thoughtful commentary from an insider-to-outsider perspective on how their platform has evolved over time.&lt;/p&gt;
&lt;p&gt;I particularly appreciated this aside on the downsides of evolving your own not-quite-standard language syntax:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Notebook Kit and Desktop &lt;a href="https://observablehq.com/notebook-kit/#vanilla-java-script"&gt;support vanilla JavaScript&lt;/a&gt;, which is excellent and cool. The Observable changes to JavaScript were always tricky and meant that we struggled to use off-the-shelf parsers, and users couldn't use standard JavaScript tooling like eslint. This is stuff like the &lt;code&gt;viewof&lt;/code&gt; operator which meant that &lt;a href="https://observablehq.com/@observablehq/observable-javascript"&gt;Observable was not JavaScript&lt;/a&gt;. [...] &lt;em&gt;Sidenote&lt;/em&gt;: I now work on &lt;a href="https://www.val.town/"&gt;Val Town&lt;/a&gt;, which is also a platform based on writing JavaScript, and when I joined it &lt;em&gt;also&lt;/em&gt; had a tweaked version of JavaScript. We used the &lt;code&gt;@&lt;/code&gt; character to let you 'mention' other vals and implicitly import them. This was, like it was in Observable, not worth it and we switched to standard syntax: don't mess with language standards folks!&lt;/p&gt;
&lt;/blockquote&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tom-macwright"&gt;tom-macwright&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/val-town"&gt;val-town&lt;/a&gt;&lt;/p&gt;



</summary><category term="javascript"/><category term="observable"/><category term="tom-macwright"/><category term="val-town"/></entry><entry><title>Share Claude conversations by converting their JSON to Markdown</title><link href="https://simonwillison.net/2024/Aug/8/convert-claude-json-to-markdown/#atom-tag" rel="alternate"/><published>2024-08-08T20:40:20+00:00</published><updated>2024-08-08T20:40:20+00:00</updated><id>https://simonwillison.net/2024/Aug/8/convert-claude-json-to-markdown/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@simonw/convert-claude-json-to-markdown"&gt;Share Claude conversations by converting their JSON to Markdown&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Anthropic's &lt;a href="https://claude.ai/"&gt;Claude&lt;/a&gt; is missing one key feature that I really appreciate in ChatGPT: the ability to create a public link to a full conversation transcript. You can publish individual artifacts from Claude, but I often find myself wanting to publish the whole conversation.&lt;/p&gt;
&lt;p&gt;Before ChatGPT added that feature I solved it myself with &lt;a href="https://observablehq.com/@simonw/chatgpt-json-transcript-to-markdown"&gt;this ChatGPT JSON transcript to Markdown Observable notebook&lt;/a&gt;. Today I built the same thing for Claude.&lt;/p&gt;
&lt;p&gt;Here's how to use it:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Animated demo - starting on the Claude homepage, opening a conversation with the DevTools network panel open, searching for chat_ and then using Copy -&amp;gt; Response to get the JSON, then switching tabs to the Observable notebook and pasting that JSON in to get Markdown." src="https://static.simonwillison.net/static/2024/claude-json-markdown.gif" /&gt;&lt;/p&gt;
&lt;p&gt;The key is to load a Claude conversation on their website with your browser DevTools network panel open and then filter URLs for &lt;code&gt;chat_&lt;/code&gt;.  You can use the Copy -&amp;gt; Response right click menu option to get the JSON for that conversation, then paste it into that &lt;a href="https://observablehq.com/@simonw/convert-claude-json-to-markdown"&gt;new Observable notebook&lt;/a&gt; to get a Markdown transcript.&lt;/p&gt;
&lt;p&gt;I like sharing these by pasting them into a "secret" &lt;a href="https://gist.github.com/"&gt;Gist&lt;/a&gt; - that way they won't be indexed by search engines (adding more AI generated slop to the world) but can still be shared with people who have the link.&lt;/p&gt;
&lt;p&gt;Here's an &lt;a href="https://gist.github.com/simonw/95abdfa3cdf755dbe6feb5ec4e3029f4"&gt;example transcript&lt;/a&gt; from this morning. I started by asking Claude:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I want to breed spiders in my house to get rid of all of the flies. What spider would you recommend?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When it suggested that this was a bad idea because it might attract pests, I asked:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;What are the pests might they attract? I really like possums&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It told me that possums are attracted by food waste, but "deliberately attracting them to your home isn't recommended" - so I said:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Thank you for the tips on attracting possums to my house. I will get right on that! [...] Once I have attracted all of those possums, what other animals might be attracted as a result? Do you think I might get a mountain lion?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It emphasized how bad an idea that would be and said "This would be extremely dangerous and is a serious public safety risk.", so I said:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;OK. I took your advice and everything has gone wrong: I am now hiding inside my house from the several mountain lions stalking my backyard, which is full of possums&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Claude has quite a preachy tone when you ask it for advice on things that are clearly a bad idea, which makes winding it up with increasingly ludicrous questions a lot of fun.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/json"&gt;json&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/markdown"&gt;markdown&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;&lt;/p&gt;



</summary><category term="json"/><category term="projects"/><category term="tools"/><category term="markdown"/><category term="ai"/><category term="observable"/><category term="generative-ai"/><category term="llms"/><category term="anthropic"/><category term="claude"/></entry><entry><title>Observable Plot: Waffle mark</title><link href="https://simonwillison.net/2024/Aug/6/observable-plot-waffle-mark/#atom-tag" rel="alternate"/><published>2024-08-06T21:40:48+00:00</published><updated>2024-08-06T21:40:48+00:00</updated><id>https://simonwillison.net/2024/Aug/6/observable-plot-waffle-mark/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/plot/marks/waffle"&gt;Observable Plot: Waffle mark&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New feature in &lt;a href="https://github.com/observablehq/plot/blob/v0.6.16/CHANGELOG.md#0616"&gt;Observable Plot 0.6.16&lt;/a&gt;: the waffle mark! I really like this one. Here's an example showing the gender and weight of athletes in this year's Olympics:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Displays weight distribution data for males and females using small colored squares stacked to form columns, with blue representing females and orange representing males. The x-axis shows weight from 30 to 170, while the y-axis indicates frequency up to 2,800. The distribution forms a bell curve, with females peaking at lower weights than males, and clear differences visible between the genders, especially at weight extremes." src="https://static.simonwillison.net/static/2024/waffle.png" /&gt;

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/mbostock/status/1820867956366065819"&gt;@mbostock&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/visualization"&gt;visualization&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable-plot"&gt;observable-plot&lt;/a&gt;&lt;/p&gt;



</summary><category term="javascript"/><category term="visualization"/><category term="observable"/><category term="observable-plot"/></entry><entry><title>Hacker News homepage with links to comments ordered by most recent first</title><link href="https://simonwillison.net/2024/Jul/15/hacker-news-homepage-with-links/#atom-tag" rel="alternate"/><published>2024-07-15T17:48:07+00:00</published><updated>2024-07-15T17:48:07+00:00</updated><id>https://simonwillison.net/2024/Jul/15/hacker-news-homepage-with-links/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@simonw/hacker-news-homepage"&gt;Hacker News homepage with links to comments ordered by most recent first&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Conversations on Hacker News are displayed as a tree, which can make it difficult to spot new comments added since the last time you viewed the thread.&lt;/p&gt;
&lt;p&gt;There's a workaround for this using the &lt;a href="https://hn.algolia.com/"&gt;Hacker News Algolia Search&lt;/a&gt; interface: search for &lt;code&gt;story:STORYID&lt;/code&gt;, select "comments" and the result will be a list of comments sorted by most recent first.&lt;/p&gt;
&lt;p&gt;I got fed up of doing this manually so I built a quick tool in an Observable Notebook that documents the hack, provides a UI for pasting in a Hacker News URL to get back that search interface link and also shows the most recent items on the homepage with links to their most recently added comments.&lt;/p&gt;
&lt;p&gt;See also my &lt;a href="https://til.simonwillison.net/hacker-news/recent-comments"&gt;How to read Hacker News threads with most recent comments first&lt;/a&gt; TIL from last year.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=40969925"&gt;Show HN&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/hacker-news"&gt;hacker-news&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;&lt;/p&gt;



</summary><category term="hacker-news"/><category term="projects"/><category term="observable"/></entry><entry><title>marimo.app</title><link href="https://simonwillison.net/2024/Jun/29/marimo-app/#atom-tag" rel="alternate"/><published>2024-06-29T23:07:42+00:00</published><updated>2024-06-29T23:07:42+00:00</updated><id>https://simonwillison.net/2024/Jun/29/marimo-app/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://marimo.app/"&gt;marimo.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
The Marimo reactive notebook (&lt;a href="https://simonwillison.net/2024/Jan/12/marimo/"&gt;previously&lt;/a&gt;) - a Python notebook that's effectively a cross between Jupyter and Observable - now also has a version that runs entirely in your browser using WebAssembly and Pyodide. Here's &lt;a href="https://docs.marimo.io/guides/wasm.html"&gt;the documentation&lt;/a&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jupyter"&gt;jupyter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/marimo"&gt;marimo&lt;/a&gt;&lt;/p&gt;



</summary><category term="python"/><category term="jupyter"/><category term="observable"/><category term="webassembly"/><category term="pyodide"/><category term="marimo"/></entry><entry><title>Ham radio general exam question pool as JSON</title><link href="https://simonwillison.net/2024/May/11/ham-radio-general/#atom-tag" rel="alternate"/><published>2024-05-11T19:16:49+00:00</published><updated>2024-05-11T19:16:49+00:00</updated><id>https://simonwillison.net/2024/May/11/ham-radio-general/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/ham-general-question-pool"&gt;Ham radio general exam question pool as JSON&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I scraped a pass of my Ham radio general exam this morning. One of the tools I used to help me pass was a Datasette instance with all 429 questions from the official question pool. I've published that raw data as JSON on GitHub, which I converted from the official question pool document using &lt;a href="https://observablehq.com/@simonw/ham-general-2024"&gt;an Observable notebook&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Relevant TIL: &lt;a href="https://til.simonwillison.net/ham-radio/general"&gt;How I studied for my Ham radio general exam&lt;/a&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/json"&gt;json&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/radio"&gt;radio&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ham-radio"&gt;ham-radio&lt;/a&gt;&lt;/p&gt;



</summary><category term="json"/><category term="projects"/><category term="radio"/><category term="datasette"/><category term="observable"/><category term="ham-radio"/></entry><entry><title>Wrap text at specified width</title><link href="https://simonwillison.net/2024/Mar/28/wrap-text-at-specified-width/#atom-tag" rel="alternate"/><published>2024-03-28T03:36:01+00:00</published><updated>2024-03-28T03:36:01+00:00</updated><id>https://simonwillison.net/2024/Mar/28/wrap-text-at-specified-width/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@simonw/wrap-text-at-specified-width"&gt;Wrap text at specified width&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New Observable notebook. I built this with the help of Claude 3 Opus—it’s a text wrapping tool which lets you set the width and also lets you optionally add a four space indent.&lt;/p&gt;

&lt;p&gt;The four space indent is handy for posting on forums such as Hacker News that treat a four space indent as a code block.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="tools"/><category term="observable"/><category term="ai-assisted-programming"/><category term="claude"/></entry><entry><title>GitHub Public repo history tool</title><link href="https://simonwillison.net/2024/Mar/20/github-public-repo-history/#atom-tag" rel="alternate"/><published>2024-03-20T21:56:12+00:00</published><updated>2024-03-20T21:56:12+00:00</updated><id>https://simonwillison.net/2024/Mar/20/github-public-repo-history/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@simonw/github-public-repo-history"&gt;GitHub Public repo history tool&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I built this Observable Notebook to run queries against the GH Archive (via ClickHouse) to try to answer questions about repository history—in particular, were they ever made public as opposed to private in the past.&lt;/p&gt;

&lt;p&gt;It works by combining together PublicEvent event (moments when a private repo was made public) with the most recent PushEvent event for each of a user’s repositories.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://til.simonwillison.net/clickhouse/github-public-history"&gt;TIL: Reviewing your history of public GitHub repositories using ClickHouse&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/clickhouse"&gt;clickhouse&lt;/a&gt;&lt;/p&gt;



</summary><category term="github"/><category term="projects"/><category term="observable"/><category term="clickhouse"/></entry><entry><title>Coroutines and web components</title><link href="https://simonwillison.net/2024/Mar/9/coroutines-and-web-components/#atom-tag" rel="alternate"/><published>2024-03-09T03:38:53+00:00</published><updated>2024-03-09T03:38:53+00:00</updated><id>https://simonwillison.net/2024/Mar/9/coroutines-and-web-components/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://lorenzofox.dev/posts/component-as-infinite-loop/"&gt;Coroutines and web components&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I like using generators in Python but I rarely knowingly use them in JavaScript—I’m probably most exposed to them by Observable, which uses then extensively under the hood as a mostly hidden implementation detail.&lt;/p&gt;

&lt;p&gt;Laurent Renard here shows some absolutely ingenious tricks with them as a way of building stateful Web Components.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=39646749"&gt;Hacker News&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/web-components"&gt;web-components&lt;/a&gt;&lt;/p&gt;



</summary><category term="javascript"/><category term="observable"/><category term="web-components"/></entry><entry><title>Observable Framework 1.1</title><link href="https://simonwillison.net/2024/Mar/5/observable-framework-1-1/#atom-tag" rel="alternate"/><published>2024-03-05T21:12:48+00:00</published><updated>2024-03-05T21:12:48+00:00</updated><id>https://simonwillison.net/2024/Mar/5/observable-framework-1-1/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/observablehq/framework/releases/tag/v1.1.0"&gt;Observable Framework 1.1&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Less than three weeks after 1.0, the 1.1 release adds a whole lot of interesting new stuff. The signature feature is self-hosted npm imports: Framework 1.0 linked out to CDN hosted copies of libraries, but 1.1 fetches copies locally and then bundles that code with the deployed static site.&lt;/p&gt;

&lt;p&gt;This works by using the acorn JavaScript parsing library to statically analyze the code and find all of the relevant imports.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/mbostock/status/1765076085249921413"&gt;@mbostock&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/npm"&gt;npm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/mike-bostock"&gt;mike-bostock&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable-framework"&gt;observable-framework&lt;/a&gt;&lt;/p&gt;



</summary><category term="javascript"/><category term="npm"/><category term="observable"/><category term="mike-bostock"/><category term="observable-framework"/></entry><entry><title>Interesting ideas in Observable Framework</title><link href="https://simonwillison.net/2024/Mar/3/interesting-ideas-in-observable-framework/#atom-tag" rel="alternate"/><published>2024-03-03T17:54:21+00:00</published><updated>2024-03-03T17:54:21+00:00</updated><id>https://simonwillison.net/2024/Mar/3/interesting-ideas-in-observable-framework/#atom-tag</id><summary type="html">
    &lt;p&gt;Mike Bostock, &lt;a href="https://observablehq.com/blog/observable-2-0"&gt;Announcing: Observable Framework&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Today we’re launching &lt;a href="https://observablehq.com/product"&gt;Observable 2.0&lt;/a&gt; with a bold new vision: an open-source static site generator for building fast, beautiful data apps, dashboards, and reports.&lt;/p&gt;
&lt;p&gt;Our mission is to help teams communicate more effectively with data. Effective presentation of data is critical for deep insight, nuanced understanding, and informed decisions. Observable notebooks are great for ephemeral, &lt;em&gt;ad hoc&lt;/em&gt; data exploration. But notebooks aren't well-suited for polished dashboards and apps.&lt;/p&gt;
&lt;p&gt;Enter &lt;a href="https://observablehq.com/framework/"&gt;Observable Framework&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;There are a lot of &lt;em&gt;really&lt;/em&gt; interesting ideas in Observable Framework.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Mar/3/interesting-ideas-in-observable-framework/#static-site-dashboards"&gt;A static site generator for data projects and dashboards&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Mar/3/interesting-ideas-in-observable-framework/#javascript-in-markdown"&gt;JavaScript in Markdown&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Mar/3/interesting-ideas-in-observable-framework/#everything-reactive"&gt;Everything is still reactive&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Mar/3/interesting-ideas-in-observable-framework/#only-code-you-use"&gt;Only include the code that you use&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Mar/3/interesting-ideas-in-observable-framework/#cache-data-at-build"&gt;Cache your data at build time&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Mar/3/interesting-ideas-in-observable-framework/#comparison-to-observable-notebooks"&gt;Comparison to Observable Notebooks&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Mar/3/interesting-ideas-in-observable-framework/#change-in-strategy"&gt;A change in strategy&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id="static-site-dashboards"&gt;A static site generator for data projects and dashboards&lt;/h4&gt;
&lt;p&gt;At its heart, Observable Framework is a static site generator. You give it a mixture of Markdown and JavaScript (and potentially other languages too) and it compiles them all together into fast loading interactive pages.&lt;/p&gt;
&lt;p&gt;It ships with a full featured hot-reloading server, so you can edit those files in your editor, hit save and see the changes reflected instantly in your browser.&lt;/p&gt;
&lt;p&gt;Once you're happy with your work you can run a build command to turn it into a set of static files ready to deploy to a server - or you can use the &lt;code&gt;npm run deploy&lt;/code&gt; command to deploy it directly to Observable's own authenticated sharing platform.&lt;/p&gt;
&lt;h4 id="javascript-in-markdown"&gt;JavaScript in Markdown&lt;/h4&gt;
&lt;p&gt;The key to the design of Observable Framework is the way it uses JavaScript in Markdown to create interactive documents.&lt;/p&gt;
&lt;p&gt;Here's what that looks like:&lt;/p&gt;
&lt;div class="highlight highlight-text-md"&gt;&lt;pre&gt;&lt;span class="pl-mh"&gt;# &lt;span class="pl-en"&gt;This is a document&lt;/span&gt;&lt;/span&gt;

Markdown content goes here.

This will output 1870:

&lt;span class="pl-s"&gt;```&lt;/span&gt;&lt;span class="pl-en"&gt;js&lt;/span&gt;
&lt;span class="pl-c1"&gt;34&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-c1"&gt;55&lt;/span&gt;
&lt;span class="pl-s"&gt;```&lt;/span&gt;

And here's the current date and time, updating constantly:

&lt;span class="pl-s"&gt;```&lt;/span&gt;&lt;span class="pl-en"&gt;js&lt;/span&gt;
&lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-en"&gt;Date&lt;/span&gt;(now)
&lt;span class="pl-s"&gt;```&lt;/span&gt;

The same thing as an inline string: ${new Date(now)}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Any Markdown code block tagged &lt;code&gt;js&lt;/code&gt; will be executed as JavaScript in the user's browser. This is an &lt;em&gt;incredibly&lt;/em&gt; powerful abstraction - anything you can do in JavaScript (which these days is effectively anything at all) can now be seamlessly integrated into your document.&lt;/p&gt;
&lt;p&gt;In the above example the &lt;code&gt;now&lt;/code&gt; value is interesting - it's a special variable that provides the current time in milliseconds since the epoch, updating constantly. Because &lt;code&gt;now&lt;/code&gt; updates constantly, the display value of the cell and that inline expression will update constantly as well.&lt;/p&gt;
&lt;p&gt;If you've used Observable Notebooks before this will feel familiar - but notebooks involve code and markdown authored in separate cells. With Framework they are all now part of a single text document.&lt;/p&gt;
&lt;p&gt;Aside: when I tried the above example I found that the &lt;code&gt;${new Date(now)}&lt;/code&gt; inline expression displayed as &lt;code&gt;Mon Feb 19 2024 20:46:02 GMT-0800 (Pacific Standard Time)&lt;/code&gt; while the &lt;code&gt;js&lt;/code&gt; block displayed as &lt;code&gt;2024-02-20T04:46:02.641Z&lt;/code&gt;. That's because inline expressions use the JavaScript default string representation of the object, while the &lt;code&gt;js&lt;/code&gt; block uses the Observable &lt;code&gt;display()&lt;/code&gt; function which has its own rules for how to display different types of objects, &lt;a href="https://github.com/observablehq/inspector/blob/main/src/inspect.js"&gt;visible in inspect/src/inspect.js&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="everything-reactive"&gt;Everything is still reactive&lt;/h4&gt;
&lt;p&gt;The best feature of Observable Notebooks is their &lt;em&gt;reactivity&lt;/em&gt; - the way cells automatically refresh when other cells they depend on change. This is a big difference to Python's popular Jupyter notebooks, and is the signature feature of &lt;a href="https://marimo.io/"&gt;marimo&lt;/a&gt;, a new Python notebook tool.&lt;/p&gt;
&lt;p&gt;Observable Framework retains this feature in its new JavaScript Markdown documents.&lt;/p&gt;
&lt;p&gt;This is particularly useful when working with form inputs. You can drop an input onto a page and refer its value throughout the rest of the document, adding realtime interactivity to documents incredibly easily.&lt;/p&gt;
&lt;p&gt;Here's an example. I ported one of my &lt;a href="https://observablehq.com/@simonw/datasette-downloads-per-day-with-observable-plot"&gt;favourite notebooks&lt;/a&gt; to Framework, which provides a tool for viewing download statistics for my various Python packages.&lt;/p&gt;
&lt;p&gt;The Observable Framework version can be found at &lt;a href="https://simonw.github.io/observable-framework-experiments/package-downloads"&gt;https://simonw.github.io/observable-framework-experiments/package-downloads&lt;/a&gt; - source code &lt;a href="https://github.com/simonw/observable-framework-experiments/blob/main/docs/package-downloads.md?plain=1"&gt;here on GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/pypi-dashboard.gif" alt="Animated demo showing PyPI download stats for Datasette projects - as I switch a select menu between sqlite-utils and csv-diff and shot-scraper the displayed chart updates to match." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;This entire thing is just 57 lines of Markdown. Here's the code with additional comments (and presented in a slightly different order - the order of code blocks doesn't matter in Observable thanks to reactivity).&lt;/p&gt;
&lt;div class="highlight highlight-text-md"&gt;&lt;pre&gt;&lt;span class="pl-mh"&gt;# &lt;span class="pl-en"&gt;PyPI download stats for Datasette projects&lt;/span&gt;&lt;/span&gt;

Showing downloads for &lt;span class="pl-s"&gt;**&lt;/span&gt;${packageName}&lt;span class="pl-s"&gt;**&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It starts with a Markdown &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; heading and text that shows the name of the selected package.&lt;/p&gt;
&lt;div class="highlight highlight-text-md"&gt;&lt;pre&gt;&lt;span class="pl-s"&gt;```&lt;/span&gt;&lt;span class="pl-en"&gt;js&lt;/span&gt; echo
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-c1"&gt;packageName&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;view&lt;/span&gt;(&lt;span class="pl-smi"&gt;Inputs&lt;/span&gt;.&lt;span class="pl-c1"&gt;select&lt;/span&gt;(packages, {
  value&lt;span class="pl-k"&gt;:&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;sqlite-utils&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
  label&lt;span class="pl-k"&gt;:&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Package&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
}));
&lt;span class="pl-s"&gt;```&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This block displays the select widget allowing the user to pick one of the items from the &lt;code&gt;packages&lt;/code&gt; array (defined later on).&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Inputs.select()&lt;/code&gt; is a built-in method provided by Framework, described in the &lt;a href="https://observablehq.com/framework/lib/inputs"&gt;Observable Inputs&lt;/a&gt; documentation.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;view()&lt;/code&gt; function is new in Observable Framework - it's the thing that enables the reactivity, ensuring that updates to the input selection are acted on by other code blocks in the document.&lt;/p&gt;
&lt;p&gt;Because &lt;code&gt;packageName&lt;/code&gt; is defined with &lt;code&gt;const&lt;/code&gt; it becomes a variable that is visible to other &lt;code&gt;js&lt;/code&gt; blocks on the page. It's used by this next block:&lt;/p&gt;
&lt;div class="highlight highlight-text-md"&gt;&lt;pre&gt;&lt;span class="pl-s"&gt;```&lt;/span&gt;&lt;span class="pl-en"&gt;js&lt;/span&gt; echo
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-c1"&gt;data&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;d3&lt;/span&gt;.&lt;span class="pl-en"&gt;json&lt;/span&gt;(
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;`&lt;/span&gt;https://datasette.io/content/stats.json?_size=max&amp;amp;package=&lt;span class="pl-s1"&gt;&lt;span class="pl-pse"&gt;${&lt;/span&gt;packageName&lt;span class="pl-pse"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;amp;_sort_desc=date&amp;amp;_shape=array&lt;span class="pl-pds"&gt;`&lt;/span&gt;&lt;/span&gt;
);&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here we are fetching the data that we need for the chart. I'm using &lt;code&gt;d3.json()&lt;/code&gt; (all of D3 is available in Framework) to fetch the data from a URL that includes the selected package name.&lt;/p&gt;
&lt;p&gt;The data is coming from &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt;, using the Datasette JSON API. I have a SQLite table at &lt;a href="https://datasette.io/content/stats"&gt;datasette.io/content/stats&lt;/a&gt; that's updated once a day with the latest PyPI package statistics via a convoluted series of GitHub Actions workflows, &lt;a href="https://simonwillison.net/2021/Jul/28/baked-data/#baked-data-datasette-io"&gt;described previously&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Adding &lt;code&gt;.json&lt;/code&gt; to that URL returns the JSON, then I ask for rows for that particular package, sorted descending by date and returning the maximum number of rows (1,000) as a JSON array of objects.&lt;/p&gt;
&lt;p&gt;Now that we have &lt;code&gt;data&lt;/code&gt; as a variable we can manipulate it slightly for use with Observable Plot - parsing the SQLite string dates into JavaScript &lt;code&gt;Date&lt;/code&gt; objects:&lt;/p&gt;
&lt;div class="highlight highlight-text-md"&gt;&lt;pre&gt;&lt;span class="pl-s"&gt;```&lt;/span&gt;&lt;span class="pl-en"&gt;js&lt;/span&gt; echo
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-c1"&gt;data_with_dates&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;data&lt;/span&gt;.&lt;span class="pl-en"&gt;map&lt;/span&gt;(&lt;span class="pl-k"&gt;function&lt;/span&gt;(&lt;span class="pl-smi"&gt;d&lt;/span&gt;) {
  d.date = d3.&lt;span class="pl-en"&gt;timeParse&lt;/span&gt;(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;%Y-%m-%d&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;)(d.date);
  return d;
})
```&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This code is ready to render as a chart. I'm using &lt;a href="https://observablehq.com/plot"&gt;Observable Plot&lt;/a&gt; - also packaged with Framework:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;```js echo
Plot.plot({
  y: {
    grid: true,
    label: `${packageName} PyPI downloads per day`
  },
  width: width,
  marginLeft: 60,
  marks: [
    Plot.line(data_with_dates, {
      x: "date",
      y: "downloads",
      title: "downloads",
      tip: true
    })
  ]
})
```
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So we have one cell that lets the user pick the package they want, a cell that fetches that data, a cell that processes it and a cell that renders it as a chart.&lt;/p&gt;
&lt;p&gt;There's one more piece of the puzzle: where does that list of packages come from? I fetch that with another API call to Datasette. Here I'm using a SQL query executed against the &lt;a href="https://datasette.io/content"&gt;/content&lt;/a&gt; database directly:&lt;/p&gt;
&lt;div class="highlight highlight-text-md"&gt;&lt;pre&gt;&lt;span class="pl-s"&gt;```&lt;/span&gt;&lt;span class="pl-en"&gt;js&lt;/span&gt; echo
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-c1"&gt;packages_sql&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;select package from stats group by package order by max(downloads) desc&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;```&lt;/span&gt;
&lt;span class="pl-s"&gt;```&lt;/span&gt;&lt;span class="pl-en"&gt;js&lt;/span&gt; echo
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-c1"&gt;packages&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;fetch&lt;/span&gt;(
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;`&lt;/span&gt;https://datasette.io/content.json?sql=&lt;span class="pl-s1"&gt;&lt;span class="pl-pse"&gt;${&lt;/span&gt;&lt;span class="pl-c1"&gt;encodeURIComponent&lt;/span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-s1"&gt;    packages_sql&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-s1"&gt;  )&lt;span class="pl-pse"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;amp;_size=max&amp;amp;_shape=arrayfirst&lt;span class="pl-pds"&gt;`&lt;/span&gt;&lt;/span&gt;
).&lt;span class="pl-c1"&gt;then&lt;/span&gt;((&lt;span class="pl-smi"&gt;r&lt;/span&gt;) &lt;span class="pl-k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-smi"&gt;r&lt;/span&gt;.&lt;span class="pl-en"&gt;json&lt;/span&gt;());
&lt;span class="pl-s"&gt;```&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;_shape=arrayfirst&lt;/code&gt; is a shortcut for getting back a JSON array of the first column of the resulting rows.&lt;/p&gt;
&lt;p&gt;That's all there is to it! It's a pretty tiny amount of code for a full interactive dashboard.&lt;/p&gt;
&lt;h4 id="only-code-you-use"&gt;Only include the code that you use&lt;/h4&gt;
&lt;p&gt;You may have noticed that my dashboard example uses several additional libraries - &lt;code&gt;Inputs&lt;/code&gt; for the form element, &lt;code&gt;d3&lt;/code&gt; for the data fetching and &lt;code&gt;Plot&lt;/code&gt; for the chart rendering.&lt;/p&gt;
&lt;p&gt;Observable Framework is smart about these. It implements lazy loading in development mode, so code is only loaded the first time you attempt to use it in a cell.&lt;/p&gt;
&lt;p&gt;When you build and deploy your application, Framework automatically loads just the referenced library code from the &lt;a href="https://www.jsdelivr.com/"&gt;jsdelivr CDN&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="cache-data-at-build"&gt;Cache your data at build time&lt;/h4&gt;
&lt;p&gt;One of the most interesting features of Framework is its &lt;a href="https://observablehq.com/framework/loaders"&gt;Data loader&lt;/a&gt; mechanism.&lt;/p&gt;
&lt;p&gt;Dashboards built using Framework can load data at runtime from anywhere using &lt;code&gt;fetch()&lt;/code&gt; requests (or wrappers around them). This is how Observable Notebooks work too, but it leaves the performance of your dashboard at the mercy of whatever backends you are talking to.&lt;/p&gt;
&lt;p&gt;Dashboards benefit from fast loading times. Framework encourages a pattern where you build the data for the dashboard at deploy time, bundling it together into static files containing just the subset of the data needed for the dashboard. These can be served lightning fast from the same static hosting as the dashboard code itself.&lt;/p&gt;
&lt;p&gt;The design of the data loaders is beautifully simple and powerful. A data loader is a script that can be written in &lt;em&gt;any&lt;/em&gt; programming language. At build time, Framework executes that script and saves whatever is outputs to a file.&lt;/p&gt;
&lt;p&gt;A data loader can be as simple as the following, saved as &lt;code&gt;quakes.json.sh&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;When the application is built, that filename tells Framework the destination file (&lt;code&gt;quakes.json&lt;/code&gt;) and the loader to execute (&lt;code&gt;.sh&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;This means you can load data from any source using any technology you like, provided it has the ability to output JSON or CSV or some other useful format to standard output.&lt;/p&gt;
&lt;h4 id="comparison-to-observable-notebooks"&gt;Comparison to Observable Notebooks&lt;/h4&gt;
&lt;p&gt;Mike introduced Observable Framework as &lt;em&gt;Observable 2.0&lt;/em&gt;. It's worth reviewing how the this system compares to the original Observable Notebook platform.&lt;/p&gt;
&lt;p&gt;I've been a huge fan of Observable Notebooks for years - &lt;a href="https://simonwillison.net/tags/observable/"&gt;38 blog posts and counting&lt;/a&gt;! The most obvious comparison is to Jupyter Notebooks, where they have some key differences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Observable notebooks use JavaScript, not Python.&lt;/li&gt;
&lt;li&gt;The notebook editor itself isn't open source - it's a hosted product provided on &lt;a href="https://observablehq.com/"&gt;observablehq.com&lt;/a&gt;. You can export the notebooks as static files and run them anywhere you like, but the editor itself is a proprietary product.&lt;/li&gt;
&lt;li&gt;Observable cells are &lt;em&gt;reactive&lt;/em&gt;. This is the key difference with Jupyter: any time you change a cell all other cells that depend on that cell are automatically re-evaluated, similar to Excel.&lt;/li&gt;
&lt;li&gt;The JavaScript syntax they use isn't quite standard JavaScript - they had to invent a new &lt;code&gt;viewof&lt;/code&gt; keyword to support their reactivity model.&lt;/li&gt;
&lt;li&gt;Editable notebooks are a pretty complex proprietary file format. They don't play well with tools like Git, to the point that Observable ended up implementing their own custom version control and collaboration systems.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Observable Framework reuses many of the ideas (and code) from Observable Notebooks, but with some crucial differences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Notebooks (really documents) are now &lt;strong&gt;single text files&lt;/strong&gt; - Markdown files with embedded JavaScript blocks. It's all still reactive, but the file format is much simpler and can be edited using any text editor, and checked into Git.&lt;/li&gt;
&lt;li&gt;It's &lt;strong&gt;all open source&lt;/strong&gt;. Everything is under an ISC license (OSI approved) and you can run the full editing stack on your own machine.&lt;/li&gt;
&lt;li&gt;It's all just standard JavaScript now - &lt;strong&gt;no custom syntax&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="change-in-strategy"&gt;A change in strategy&lt;/h4&gt;
&lt;p&gt;Reading the tea leaves a bit, this also looks to me like a strategic change of direction for Observable as a company. Their previous focus was on building great collaboration tools for data science and analytics teams, based around the proprietary Observable Notebook editor.&lt;/p&gt;
&lt;p&gt;With Framework they appear to be leaning more into the developer tools space.&lt;/p&gt;
&lt;p&gt;On Twitter &lt;a href="http://twitter.com/observablehq"&gt;@observablehq&lt;/a&gt; describes itself as "The end-to-end solution for developers who want to build and host dashboards that don’t suck" - the Internet Archive copy &lt;a href="https://web.archive.org/web/20231003212202/https://twitter.com/observablehq"&gt;from October 3rd 2023&lt;/a&gt; showed "Build data visualizations, dashboards, and data apps that impact your business — faster."&lt;/p&gt;
&lt;p&gt;I'm excited to see where this goes. I've limited my usage of Observable Notebooks a little in the past purely due to the proprietary nature of their platform and the limitations placed on free accounts (mainly the lack of free private notebooks), while still having enormous respect for the technology and enthusiastically adopting their open source libraries such as &lt;a href="https://observablehq.com/plot/"&gt;Observable Plot&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Observable Framework addresses basically all of my reservations. It's a fantastic new expression of the ideas that made Observable Notebooks so compelling, and I expect to use it for all sorts of interesting projects in the future.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/open-source"&gt;open-source&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pypi"&gt;pypi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/d3"&gt;d3&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jupyter"&gt;jupyter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/mike-bostock"&gt;mike-bostock&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable-framework"&gt;observable-framework&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable-plot"&gt;observable-plot&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="javascript"/><category term="open-source"/><category term="pypi"/><category term="d3"/><category term="jupyter"/><category term="observable"/><category term="mike-bostock"/><category term="observable-framework"/><category term="observable-plot"/></entry><entry><title>PGlite</title><link href="https://simonwillison.net/2024/Feb/23/pglite/#atom-tag" rel="alternate"/><published>2024-02-23T15:56:37+00:00</published><updated>2024-02-23T15:56:37+00:00</updated><id>https://simonwillison.net/2024/Feb/23/pglite/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/electric-sql/pglite"&gt;PGlite&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
PostgreSQL compiled for WebAssembly and turned into a very neat JavaScript library. Previous attempts at running PostgreSQL in WASM have worked by bundling a full Linux virtual machine - PGlite just bundles a compiled PostgreSQL itself, which brings the size down to an impressive 3.7MB gzipped.&lt;/p&gt;
&lt;p&gt;I built &lt;a href="https://simonw.github.io/observable-framework-experiments/postgresql"&gt;this interactive demo&lt;/a&gt; of PGlite using Observable Framework, &lt;a href="https://github.com/simonw/observable-framework-experiments/blob/main/docs/postgresql.md?plain=1"&gt;source code here&lt;/a&gt;.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://codapi.org/postgres-pglite/"&gt;Anton Zhiyanov&amp;#x27;s new PostgreSQL playground&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable-framework"&gt;observable-framework&lt;/a&gt;&lt;/p&gt;



</summary><category term="postgresql"/><category term="observable"/><category term="webassembly"/><category term="observable-framework"/></entry><entry><title>Observable notebook: URL to download a GitHub repository as a zip file</title><link href="https://simonwillison.net/2024/Jan/29/observable-notebook-url-to-download-a-github-repository-as-a-zip/#atom-tag" rel="alternate"/><published>2024-01-29T21:17:27+00:00</published><updated>2024-01-29T21:17:27+00:00</updated><id>https://simonwillison.net/2024/Jan/29/observable-notebook-url-to-download-a-github-repository-as-a-zip/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@simonw/download-github-repo"&gt;Observable notebook: URL to download a GitHub repository as a zip file&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
GitHub broke the “right click -&amp;gt; copy URL” feature on their Download ZIP button a few weeks ago. I’m still hoping they fix that, but in the meantime I built this Observable Notebook to generate ZIP URLs for any GitHub repo and any branch or commit hash.&lt;/p&gt;

&lt;p&gt;Update 30th January 2024: GitHub have fixed the bug now, so right click -&amp;gt; Copy URL works again on that button.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://github.com/orgs/community/discussions/84699#discussioncomment-8291021"&gt;GitHub discussion forum where I reported the bug&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;&lt;/p&gt;



</summary><category term="github"/><category term="observable"/></entry><entry><title>Marimo</title><link href="https://simonwillison.net/2024/Jan/12/marimo/#atom-tag" rel="alternate"/><published>2024-01-12T21:17:57+00:00</published><updated>2024-01-12T21:17:57+00:00</updated><id>https://simonwillison.net/2024/Jan/12/marimo/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://marimo.io/"&gt;Marimo&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
This is a really interesting new twist on Python notebooks.&lt;/p&gt;

&lt;p&gt;The most powerful feature is that these notebooks are reactive: if you change the value or code in a cell (or change the value in an input widget) every other cell that depends on that value will update automatically. It’s the same pattern implemented by Observable JavaScript notebooks, but now it works for Python.&lt;/p&gt;

&lt;p&gt;There are a bunch of other nice touches too. The notebook file format is a regular Python file, and those files can be run as “applications” in addition to being edited in the notebook interface. The interface is very nicely built, especially for such a young project—they even have GitHub Copilot integration for their CodeMirror cell editors.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=38971966"&gt;Hacker News&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/open-source"&gt;open-source&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jupyter"&gt;jupyter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-copilot"&gt;github-copilot&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/marimo"&gt;marimo&lt;/a&gt;&lt;/p&gt;



</summary><category term="open-source"/><category term="python"/><category term="jupyter"/><category term="observable"/><category term="github-copilot"/><category term="marimo"/></entry><entry><title>datasette-plot - a new Datasette Plugin for building data visualizations</title><link href="https://simonwillison.net/2023/Dec/31/datasette-plot/#atom-tag" rel="alternate"/><published>2023-12-31T05:04:19+00:00</published><updated>2023-12-31T05:04:19+00:00</updated><id>https://simonwillison.net/2023/Dec/31/datasette-plot/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.datasette.cloud/blog/2023/datasette-plot/"&gt;datasette-plot - a new Datasette Plugin for building data visualizations&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I forgot to link to this here last week: Alex Garcia released the first version of datasette-plot, a brand new Datasette visualization plugin built on top of the Observable Plot charting library. We plan to use this as the new, updated alternative to my older datasette-vega plugin.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/visualization"&gt;visualization&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/alex-garcia"&gt;alex-garcia&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable-plot"&gt;observable-plot&lt;/a&gt;&lt;/p&gt;



</summary><category term="plugins"/><category term="visualization"/><category term="datasette"/><category term="observable"/><category term="alex-garcia"/><category term="observable-plot"/></entry><entry><title>Observable notebook: Detect objects in images</title><link href="https://simonwillison.net/2023/Oct/1/detect-objects-in-images/#atom-tag" rel="alternate"/><published>2023-10-01T15:46:14+00:00</published><updated>2023-10-01T15:46:14+00:00</updated><id>https://simonwillison.net/2023/Oct/1/detect-objects-in-images/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@simonw/detect-objects-in-images"&gt;Observable notebook: Detect objects in images&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I built an Observable notebook that uses &lt;a href="https://github.com/huggingface/transformers.js"&gt;Transformers.js&lt;/a&gt; and the &lt;a href="https://huggingface.co/Xenova/detr-resnet-50"&gt;Xenova/detra-resnet-50&lt;/a&gt; model to detect objects in images, entirely running within your browser. You can select an image using a file picker and it will show you that image with bounding boxes and labels drawn around items within it. I have a demo image showing some pelicans flying ahead, but it works with any image you give it - all without uploading that image to a server.&lt;/p&gt;
&lt;p&gt;&lt;img alt="A blue image with four birds flying, each is enclosed in a bounding box labelled bird with a %99.something accuracy label." src="https://static.simonwillison.net/static/2023/bird-detection.jpg" /&gt;

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/simonw/status/1708508183189410288"&gt;@simonw&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/machine-learning"&gt;machine-learning&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/transformers"&gt;transformers&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/transformers-js"&gt;transformers-js&lt;/a&gt;&lt;/p&gt;



</summary><category term="javascript"/><category term="machine-learning"/><category term="transformers"/><category term="ai"/><category term="observable"/><category term="transformers-js"/></entry><entry><title>Llama encoder and decoder</title><link href="https://simonwillison.net/2023/Jun/13/llama-encoder-and-decoder/#atom-tag" rel="alternate"/><published>2023-06-13T22:37:29+00:00</published><updated>2023-06-13T22:37:29+00:00</updated><id>https://simonwillison.net/2023/Jun/13/llama-encoder-and-decoder/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@simonw/llama-encoder-and-decoder"&gt;Llama encoder and decoder&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I forked my GPT tokenizer Observable notebook to create a similar tool for exploring the tokenization scheme used by the Llama family of LLMs, using the new llama-tokenizer-js JavaScript library.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llama"&gt;llama&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tokenization"&gt;tokenization&lt;/a&gt;&lt;/p&gt;



</summary><category term="ai"/><category term="observable"/><category term="generative-ai"/><category term="llama"/><category term="llms"/><category term="tokenization"/></entry><entry><title>GPT-3 token encoder and decoder</title><link href="https://simonwillison.net/2023/Apr/27/gpt-3-token-encoder-and-decoder/#atom-tag" rel="alternate"/><published>2023-04-27T23:48:34+00:00</published><updated>2023-04-27T23:48:34+00:00</updated><id>https://simonwillison.net/2023/Apr/27/gpt-3-token-encoder-and-decoder/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@simonw/gpt-3-token-encoder-decoder"&gt;GPT-3 token encoder and decoder&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I built an Observable notebook with an interface to encode, decode and search through GPT-3 tokens, building on top of a notebook by EJ Fox and Ian Johnson.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gpt-3"&gt;gpt-3&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openai"&gt;openai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="ai"/><category term="observable"/><category term="gpt-3"/><category term="openai"/><category term="llms"/></entry><entry><title>Weeknotes: A new llm CLI tool, plus automating my weeknotes and newsletter</title><link href="https://simonwillison.net/2023/Apr/4/llm/#atom-tag" rel="alternate"/><published>2023-04-04T23:28:29+00:00</published><updated>2023-04-04T23:28:29+00:00</updated><id>https://simonwillison.net/2023/Apr/4/llm/#atom-tag</id><summary type="html">
    &lt;p&gt;I started publishing weeknotes &lt;a href="https://simonwillison.net/2019/Sep/13/weeknotestwitter-sqlite-datasette-rure/"&gt;in 2019&lt;/a&gt; partly as a way to hold myself accountable but mainly as a way to encourage myself to write more.&lt;/p&gt;
&lt;p&gt;Now that I'm writing multiple posts a week (mainly about AI) - and sending them out &lt;a href="https://simonw.substack.com/"&gt;as a newsletter&lt;/a&gt; - my weeknotes are feeling a little less necessary. Here's everything I've written here since my last weeknotes on 22nd March:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2023/Mar/24/datasette-chatgpt-plugin/"&gt;I built a ChatGPT plugin to answer questions about data hosted in Datasette&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2023/Mar/27/ai-enhanced-development/"&gt;AI-enhanced development makes me more ambitious with my projects&lt;/a&gt; - and for another illustrative example of that effect, see my TIL &lt;a href="https://til.simonwillison.net/googlecloud/video-frame-ocr"&gt;Reading thermometer temperatures over time from a video&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2023/Apr/2/what-ai-can-do-for-you/"&gt;What AI can do for you on the Theory of Change podcast&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2023/Apr/2/calculator-for-words/"&gt;Think of language models like ChatGPT as a "calculator for words"&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2023/Apr/4/substack-observable/"&gt;Semi-automating a Substack newsletter with an Observable notebook&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;(That list created &lt;a href="https://datasette.simonwillison.net/simonwillisonblog?sql=with+last_weeknotes+as+(%0D%0A++select+id+from+blog_entry+where+id+in+(select+entry_id+from+blog_entry_tags+where+tag_id+%3D+(select+id+from+blog_tag+where+tag+%3D+%27weeknotes%27))%0D%0A++order+by+created+desc+limit+1%0D%0A)%2C%0D%0Aentries+as+(%0D%0A++select%0D%0A++++title%2C%0D%0A++++%27https%3A%2F%2Fsimonwillison.net%2F%27+%7C%7C+strftime(%27%25Y%2F%27%2C+created)%0D%0A++++%7C%7C+substr(%0D%0A++++++%27JanFebMarAprMayJunJulAugSepOctNovDec%27%2C+(strftime(%27%25m%27%2C+created)+-+1)+*+3+%2B+1%2C+3%0D%0A++++)+%7C%7C+%27%2F%27+%7C%7C+cast(strftime(%27%25d%27%2C+created)+as+integer)+%7C%7C+%27%2F%27+%7C%7C+slug+%7C%7C+%27%2F%27+as+url%0D%0A++from+blog_entry+where+id+%3E+(select+id+from+last_weeknotes)%0D%0A)%0D%0Aselect+%27*+%5B%27+%7C%7C+title+%7C%7C+%27%5D(%27+%7C%7C+url+%7C%7C+%27)%27+from+entries"&gt;using this SQL query&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;I'm going to keep them going though: I've had so much value out of the habit that I don't feel it's time to stop.&lt;/p&gt;
&lt;h4&gt;The llm CLI tool&lt;/h4&gt;
&lt;p&gt;This is one new piece of software I've released in the past few weeks that I haven't written about yet.&lt;/p&gt;
&lt;p&gt;I built the first version of &lt;a href=""&gt;llm&lt;/a&gt;, a command-line tool for running prompts against large language model (currently just ChatGPT and GPT-4), getting the results back on the command-line and also storing the prompt and response in a SQLite database.&lt;/p&gt;
&lt;p&gt;It's still pretty experimental, but it's already looking like it will be a fun playground for trying out new things.&lt;/p&gt;
&lt;p&gt;Here's the 30s version of how to start using it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Install the tool
pipx install llm
# Put an OpenAI API key somewhere it can find it
echo 'your-OpenAI-API-key' &amp;gt; ~/.openai-api-key.txt
# Or you can set it as an environment variable:
# export OPENAI_API_KEY='...'
# Run a prompt
llm 'Ten names for cheesecakes'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will output the response to that prompt directly to the terminal.&lt;/p&gt;
&lt;p&gt;Add the &lt;code&gt;-s&lt;/code&gt; or &lt;code&gt;--stream&lt;/code&gt; option to stream results instead:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2023/cheesecakes-names.gif" alt="Animated GIF of a terminal window. Command is: llm 'Ten names for cheesecakes' -s - the output is 1. Classic New York Style Cheesecake 2. Raspberry Swirl Cheesecake 3. Lemon Meringue Cheesecake 4. Chocolate Chip Cookie Dough Cheesecake 5. Caramel Turtle Cheesecake 6. White Chocolate Raspberry Cheesecake 7. Blueberry Cheesecake 8. Peanut Butter Cup Cheesecake 9. Pumpkin Spice Cheesecake 10. Key Lime Cheesecake" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Prompts are run against ChatGPT's inexpensive &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; model by default.  You can use &lt;code&gt;-4&lt;/code&gt; to run against the GPT-4 model instead (if you have access to it), or &lt;code&gt;--model X&lt;/code&gt; to run against another named OpenAI model.&lt;/p&gt;
&lt;p&gt;If a SQLite database file exists in &lt;code&gt;~/.llm/log.db&lt;/code&gt; any prompts you run will be automatically recorded to that database, which you can then explore using &lt;code&gt;datasette ~/.llm/log.db&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The following command will create that database if it does not yet exist:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; llm init-db
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There's &lt;a href="https://github.com/simonw/llm"&gt;more in the README&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;There are plenty of other options for tools for running LLM prompts on your own machines, including some that work on the command-line and some that record your results. &lt;code&gt;llm&lt;/code&gt; is probably less useful than those alternatives, but it's a fun space for me to try out new ideas.&lt;/p&gt;
&lt;h4&gt;Automating my weeknotes&lt;/h4&gt;
&lt;p&gt;I wrote at length about &lt;a href="https://simonwillison.net/2023/Apr/4/substack-observable/"&gt;how I automated most of my newsletter&lt;/a&gt; using an Observable notebook and some Datasette tricks.&lt;/p&gt;
&lt;p&gt;I realized the same trick could work for my weeknotes as well. The "releases this week" and "TILs this week" sections have previously been generated by hand, so I applied the same technique from the newsletter notebook to automate them as well.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://observablehq.com/@simonw/weeknotes"&gt;observablehq.com/@simonw/weeknotes&lt;/a&gt; is the notebook. It fetches TILs &lt;a href="https://til.simonwillison.net/tils?sql=select%0D%0A++title%2C%0D%0A++%27https%3A%2F%2Ftil.simonwillison.net%2F%27+%7C%7C+topic+%7C%7C+%27%2F%27+%7C%7C+slug+as+url%2C%0D%0A++date%28created_utc%29+as+date%0D%0Afrom+til%0D%0A++where+created_utc+%3E%3D+date%28%27now%27%2C+%27-28+days%27%29%0D%0Aorder+by+created_utc+desc"&gt;from my TILs Datasette&lt;/a&gt;, then grabs releases from &lt;a href="https://raw.githubusercontent.com/simonw/simonw/main/releases.md"&gt;this page on GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It also fetches the full text of my most recent weeknotes post &lt;a href="https://datasette.simonwillison.net/simonwillisonblog?sql=select%0D%0A++created%2C+body%0D%0Afrom%0D%0A++blog_entry%0D%0Awhere%0D%0A++id+in+%28select+entry_id+from+blog_entry_tags+where+tag_id+%3D+%28%0D%0A++++select+id+from+blog_tag+where+tag+%3D+%27weeknotes%27%0D%0A++++%29%0D%0A++%29%0D%0Aorder+by+created+desc+limit+1"&gt;from my blog's Datasette backup&lt;/a&gt; so it can calculate which releases and TILs are new since last time.&lt;/p&gt;
&lt;p&gt;It uses various regular expression and array tricks to filter that content to just the new stuff, then assembles me a markdown string which I can use as the basis of my new post.&lt;/p&gt;
&lt;p&gt;Here's what that generated for me this week:&lt;/p&gt;
&lt;h4&gt;Releases since last time&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-explain/releases/tag/0.1a1"&gt;datasette-explain 0.1a1&lt;/a&gt;&lt;/strong&gt; - 2023-04-04&lt;br /&gt;Explain and validate SQL queries as you type them into Datasette&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/llm/releases/tag/0.2"&gt;llm 0.2&lt;/a&gt;&lt;/strong&gt; - 2023-04-01&lt;br /&gt;Access large language models from the command-line&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-graphql/releases/tag/2.2"&gt;datasette-graphql 2.2&lt;/a&gt;&lt;/strong&gt; - 2023-03-23&lt;br /&gt;Datasette plugin providing an automatic GraphQL API for your SQLite databases&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;TIL since last time&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://til.simonwillison.net/sqlite/copy-tables-between-databases"&gt;Copy tables between SQLite databases&lt;/a&gt; - 2023-04-03&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://til.simonwillison.net/googlecloud/video-frame-ocr"&gt;Reading thermometer temperatures over time from a video&lt;/a&gt; - 2023-04-02&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://til.simonwillison.net/gpt3/python-chatgpt-streaming-api"&gt;Using the ChatGPT streaming API from Python&lt;/a&gt; - 2023-04-01&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://til.simonwillison.net/datasette/row-selection-prototype"&gt;Interactive row selection prototype with Datasette&lt;/a&gt; - 2023-03-30&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://til.simonwillison.net/observable/jq-in-observable"&gt;Using jq in an Observable notebook&lt;/a&gt; - 2023-03-26&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://til.simonwillison.net/jq/git-log-json"&gt;Convert git log output to JSON using jq&lt;/a&gt; - 2023-03-25&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/cli"&gt;cli&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="cli"/><category term="projects"/><category term="datasette"/><category term="observable"/><category term="weeknotes"/><category term="llms"/><category term="llm"/></entry><entry><title>Semi-automating a Substack newsletter with an Observable notebook</title><link href="https://simonwillison.net/2023/Apr/4/substack-observable/#atom-tag" rel="alternate"/><published>2023-04-04T17:55:28+00:00</published><updated>2023-04-04T17:55:28+00:00</updated><id>https://simonwillison.net/2023/Apr/4/substack-observable/#atom-tag</id><summary type="html">
    &lt;p&gt;I recently started sending out &lt;a href="https://simonw.substack.com/"&gt;a weekly-ish email newsletter&lt;/a&gt; consisting of content from my blog. I've mostly automated that, using &lt;a href="https://observablehq.com/@simonw/blog-to-newsletter"&gt;an Observable Notebook&lt;/a&gt; to generate the HTML. Here's how that system works.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2023/substack-index.jpg" alt="Screenshot of Substack: Simon Willison' Newsletter, with a big podcast promo image next to Think of language models like GhatGPT as a calculator for words, followed by two other recent newsletter headlines." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4&gt;What goes in my newsletter&lt;/h4&gt;
&lt;p&gt;My blog has three types of content: &lt;a href="https://simonwillison.net/search/?type=entry"&gt;entries&lt;/a&gt;, &lt;a href="https://simonwillison.net/search/?type=blogmark"&gt;blogmarks&lt;/a&gt; and &lt;a href="https://simonwillison.net/search/?type=quotation"&gt;quotations&lt;/a&gt;. "Blogmarks" is a name I came up with for bookmarks &lt;a href="https://simonwillison.net/2003/Nov/24/blogmarks/"&gt;in 2003&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Blogmarks and quotations show up in my blog's sidebar, entries get the main column - but on mobile the three are combined into a single flow.&lt;/p&gt;
&lt;p&gt;These live in a PostgreSQL database managed by Django. You can see them defined &lt;a href="https://github.com/simonw/simonwillisonblog/blob/main/blog/models.py"&gt;in models.py&lt;/a&gt; in my blog's open source repo.&lt;/p&gt;
&lt;p&gt;My newsletter consists of all of the new entries, blogmarks and quotations since I last sent it out. I include the entries first in reverse chronological order, since usually the entry I've just written is the one I want to use for the email subject. The blogmarks and quotations come in chronological order afterwards.&lt;/p&gt;
&lt;p&gt;I'm including the full HTML for everything: people don't need to click through back to my blog to read it, all of the content should be right there in their email client.&lt;/p&gt;
&lt;h4&gt;The Substack API: RSS and copy-and-paste&lt;/h4&gt;
&lt;p&gt;Substack doesn't yet offer an API, and &lt;a href="https://support.substack.com/hc/en-us/articles/360038433912-Does-Substack-have-an-API-"&gt;have no public plans&lt;/a&gt; to do so.&lt;/p&gt;
&lt;p&gt;They do offer an RSS feed of each newsletter though - add &lt;code&gt;/feed&lt;/code&gt; to the newsletter subdomain to get it. Mine is at &lt;a href="https://simonw.substack.com/feed"&gt;https://simonw.substack.com/feed&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;So we can get data back out again... but what about getting data in? I don't want to manually assemble a newsletter from all of these different sources of data.&lt;/p&gt;
&lt;p&gt;That's where copy-and-paste comes in.&lt;/p&gt;
&lt;p&gt;The Substack compose editor incorporates a well built rich-text editor. You can paste content into it and it will clean it up to fit the subset of HTML that Substack supports... but that's a pretty decent subset. Headings, paragraphs, lists, links, code blocks and images are all supported.&lt;/p&gt;
&lt;p&gt;The vast majority of content on my blog fits that subset neatly.&lt;/p&gt;
&lt;p&gt;Crucially, pasting in images as part of that rich text content Just Works: Substack automatically copies any images to their &lt;code&gt;substack-post-media&lt;/code&gt; S3 bucket and embeds links to their CDN in the body of the newsletter.&lt;/p&gt;
&lt;p&gt;So... if I can generate the intended rich-text HTML for my whole newsletter, I can copy and paste it directly into the Substack.&lt;/p&gt;
&lt;p&gt;That's exactly what my new Observable notebook does: &lt;a href="https://observablehq.com/@simonw/blog-to-newsletter"&gt;https://observablehq.com/@simonw/blog-to-newsletter&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Generating HTML is a well trodden path, but I also wanted a "copy to clipboard" button that would copy the rich text version of that HTML such that pasting it into Substack would do the right thing.&lt;/p&gt;
&lt;p&gt;With a bit of help from &lt;a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard"&gt;MDN&lt;/a&gt; and &lt;a href="https://til.simonwillison.net/javascript/copy-rich-text-to-clipboard"&gt;ChatGPT (my TIL)&lt;/a&gt; I figured out the following:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;copyRichText&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;html&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;htmlContent&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;html&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Create a temporary element to hold the HTML content&lt;/span&gt;
  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;tempElement&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;createElement&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"div"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-s1"&gt;tempElement&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;innerHTML&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;htmlContent&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;body&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;appendChild&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;tempElement&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Select the HTML content&lt;/span&gt;
  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;range&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;createRange&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-s1"&gt;range&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;selectNode&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;tempElement&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Copy the selected HTML content to the clipboard&lt;/span&gt;
  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;selection&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getSelection&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-s1"&gt;selection&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;removeAllRanges&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-s1"&gt;selection&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;addRange&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;range&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;execCommand&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"copy"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-s1"&gt;selection&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;removeAllRanges&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;body&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;removeChild&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;tempElement&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This works great! Set up a button that triggers that function and clicking that button will copy a rich text version of the HTML to the clipboard, such that pasting it directly into the Substack editor has the desired effect.&lt;/p&gt;
&lt;h4&gt;Assembling the HTML&lt;/h4&gt;
&lt;p&gt;I love using &lt;a href="https://observablehq.com/"&gt;Observable Notebooks&lt;/a&gt; for this kind of project: quick data integration tools that need a UI and will likely be incrementally improved over time.&lt;/p&gt;
&lt;p&gt;Using Observable for these means I don't need to host anything and I can iterate my way to the right solution really quickly.&lt;/p&gt;
&lt;p&gt;First, I needed to retrieve my entries, blogmarks and quotations.&lt;/p&gt;
&lt;p&gt;I never built an API for my Django blog directly, but a while ago I set up a mechanism that &lt;a href="https://github.com/simonw/simonwillisonblog-backup/blob/main/.github/workflows/backup.yml"&gt;exports the contents&lt;/a&gt; of my blog to &lt;a href="https://github.com/simonw/simonwillisonblog-backup"&gt;my simonwillisonblog-backup&lt;/a&gt; GitHub repository for safety, and then deploys a Datasette/SQLite copy of that data to &lt;a href="https://datasette.simonwillison.net/"&gt;https://datasette.simonwillison.net/&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; offers a JSON API for querying that data, and exposes open CORS headers which means JavaScript running in Observable can query it directly.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://datasette.simonwillison.net/simonwillisonblog?sql=select+*+from+blog_entry+order+by+id+desc+limit+5"&gt;Here's an example SQL query&lt;/a&gt; running against that Datasette instance - click the &lt;code&gt;.json&lt;/code&gt; link on that page to get that data back as JSON instead.&lt;/p&gt;
&lt;p&gt;My Observable notebook can then retrieve the exact data it needs to construct the HTML for the newsletter.&lt;/p&gt;
&lt;p&gt;The smart thing to do would have been to retrieve the data from the API and then use JavaScript inside Observable to compose that together into the HTML for the newsletter.&lt;/p&gt;
&lt;p&gt;I decided to challenge myself to doing most of the work in SQL instead, and came up with the following absolute monster of a query:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;with content &lt;span class="pl-k"&gt;as&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt;
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;entry&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; type, title, created, slug,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;h3&amp;gt;&amp;lt;a href="&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;https://simonwillison.net/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%Y/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created)
      &lt;span class="pl-k"&gt;||&lt;/span&gt; substr(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;JanFebMarAprMayJunJulAugSepOctNovDec&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, (strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%m&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created) &lt;span class="pl-k"&gt;-&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;) &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-c1"&gt;3&lt;/span&gt; &lt;span class="pl-k"&gt;+&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;, &lt;span class="pl-c1"&gt;3&lt;/span&gt;) 
      &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; cast(strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%d&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created) &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-k"&gt;integer&lt;/span&gt;) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; slug &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;"&amp;gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; 
      &lt;span class="pl-k"&gt;||&lt;/span&gt; title &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;/a&amp;gt; - &lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-k"&gt;date&lt;/span&gt;(created) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;/h3&amp;gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; body
      &lt;span class="pl-k"&gt;as&lt;/span&gt; html,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; external_url
  &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_entry
  &lt;span class="pl-k"&gt;union all&lt;/span&gt;
  &lt;span class="pl-k"&gt;select&lt;/span&gt;
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;blogmark&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; type,
    link_title, created, slug,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Link&amp;lt;/strong&amp;gt; &lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-k"&gt;date&lt;/span&gt;(created) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt; &amp;lt;a href="&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;||&lt;/span&gt; link_url &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;"&amp;gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
      &lt;span class="pl-k"&gt;||&lt;/span&gt; link_title &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;/a&amp;gt;:&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt; &lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; commentary &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;/p&amp;gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
      &lt;span class="pl-k"&gt;as&lt;/span&gt; html,
  link_url &lt;span class="pl-k"&gt;as&lt;/span&gt; external_url
  &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_blogmark
  &lt;span class="pl-k"&gt;union all&lt;/span&gt;
  &lt;span class="pl-k"&gt;select&lt;/span&gt;
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;quotation&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; type,
    source, created, slug,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;strong&amp;gt;Quote&amp;lt;/strong&amp;gt; &lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-k"&gt;date&lt;/span&gt;(created) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;blockquote&amp;gt;&amp;lt;p&amp;gt;&amp;lt;em&amp;gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-k"&gt;||&lt;/span&gt; replace(quotation, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;br&amp;gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;/em&amp;gt;&amp;lt;/p&amp;gt;&amp;lt;/blockquote&amp;gt;&amp;lt;p&amp;gt;&amp;lt;a href="&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt;
    coalesce(source_url, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;#&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;"&amp;gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; source &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-k"&gt;as&lt;/span&gt; html,
    source_url &lt;span class="pl-k"&gt;as&lt;/span&gt; external_url
  &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_quotation
),
collected &lt;span class="pl-k"&gt;as&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt;
    type,
    title,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;https://simonwillison.net/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%Y/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created)
      &lt;span class="pl-k"&gt;||&lt;/span&gt; substr(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;JanFebMarAprMayJunJulAugSepOctNovDec&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, (strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%m&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created) &lt;span class="pl-k"&gt;-&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;) &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-c1"&gt;3&lt;/span&gt; &lt;span class="pl-k"&gt;+&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;, &lt;span class="pl-c1"&gt;3&lt;/span&gt;) &lt;span class="pl-k"&gt;||&lt;/span&gt; 
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; cast(strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%d&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created) &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-k"&gt;integer&lt;/span&gt;) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; slug &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
      &lt;span class="pl-k"&gt;as&lt;/span&gt; url,
    created,
    html,
    external_url
  &lt;span class="pl-k"&gt;from&lt;/span&gt; content
  &lt;span class="pl-k"&gt;where&lt;/span&gt; created &lt;span class="pl-k"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;date&lt;/span&gt;(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;now&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;-&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; :numdays &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt; days&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;)   
  &lt;span class="pl-k"&gt;order by&lt;/span&gt; created &lt;span class="pl-k"&gt;desc&lt;/span&gt;
)
&lt;span class="pl-k"&gt;select&lt;/span&gt; type, title, url, created, html, external_url
&lt;span class="pl-k"&gt;from&lt;/span&gt; collected 
&lt;span class="pl-k"&gt;order by&lt;/span&gt; 
  case type 
    when &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;entry&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; then &lt;span class="pl-c1"&gt;0&lt;/span&gt; 
    else &lt;span class="pl-c1"&gt;1&lt;/span&gt; 
  end,
  case type 
    when &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;entry&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; then created 
    else &lt;span class="pl-k"&gt;-&lt;/span&gt;strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%s&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created) 
  end &lt;span class="pl-k"&gt;desc&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This logic really should be in the JavaScript instead! You can &lt;a href="https://datasette.simonwillison.net/simonwillisonblog?sql=with+content+as+%28%0D%0A++select%0D%0A++++%27entry%27+as+type%2C+title%2C+created%2C+slug%2C%0D%0A++++%27%3Ch3%3E%3Ca+href%3D%22%27+%7C%7C+%27https%3A%2F%2Fsimonwillison.net%2F%27+%7C%7C+strftime%28%27%25Y%2F%27%2C+created%29%0D%0A++++++%7C%7C+substr%28%27JanFebMarAprMayJunJulAugSepOctNovDec%27%2C+%28strftime%28%27%25m%27%2C+created%29+-+1%29+*+3+%2B+1%2C+3%29+%0D%0A++++++%7C%7C+%27%2F%27+%7C%7C+cast%28strftime%28%27%25d%27%2C+created%29+as+integer%29+%7C%7C+%27%2F%27+%7C%7C+slug+%7C%7C+%27%2F%27+%7C%7C+%27%22%3E%27+%0D%0A++++++%7C%7C+title+%7C%7C+%27%3C%2Fa%3E+-+%27+%7C%7C+date%28created%29+%7C%7C+%27%3C%2Fh3%3E%27+%7C%7C+body%0D%0A++++++as+html%2C%0D%0A++++%27%27+as+external_url%0D%0A++from+blog_entry%0D%0A++union+all%0D%0A++select%0D%0A++++%27blogmark%27+as+type%2C%0D%0A++++link_title%2C+created%2C+slug%2C%0D%0A++++%27%3Cp%3E%3Cstrong%3ELink%3C%2Fstrong%3E+%27+%7C%7C+date%28created%29+%7C%7C+%27+%3Ca+href%3D%22%27%7C%7C+link_url+%7C%7C+%27%22%3E%27%0D%0A++++++%7C%7C+link_title+%7C%7C+%27%3C%2Fa%3E%3A%27+%7C%7C+%27+%27+%7C%7C+commentary+%7C%7C+%27%3C%2Fp%3E%27%0D%0A++++++as+html%2C%0D%0A++link_url+as+external_url%0D%0A++from+blog_blogmark%0D%0A++union+all%0D%0A++select%0D%0A++++%27quotation%27+as+type%2C%0D%0A++++source%2C+created%2C+slug%2C%0D%0A++++%27%3Cstrong%3EQuote%3C%2Fstrong%3E+%27+%7C%7C+date%28created%29+%7C%7C+%27%3Cblockquote%3E%3Cp%3E%3Cem%3E%27%0D%0A++++%7C%7C+replace%28quotation%2C+%27%0D%0A%27%2C+%27%3Cbr%3E%27%29+%7C%7C+%27%3C%2Fem%3E%3C%2Fp%3E%3C%2Fblockquote%3E%3Cp%3E%3Ca+href%3D%22%27+%7C%7C%0D%0A++++coalesce%28source_url%2C+%27%23%27%29+%7C%7C+%27%22%3E%27+%7C%7C+source+%7C%7C+%27%3C%2Fa%3E%3C%2Fp%3E%27%0D%0A++++as+html%2C%0D%0A++++source_url+as+external_url%0D%0A++from+blog_quotation%0D%0A%29%2C%0D%0Acollected+as+%28%0D%0A++select%0D%0A++++type%2C%0D%0A++++title%2C%0D%0A++++%27https%3A%2F%2Fsimonwillison.net%2F%27+%7C%7C+strftime%28%27%25Y%2F%27%2C+created%29%0D%0A++++++%7C%7C+substr%28%27JanFebMarAprMayJunJulAugSepOctNovDec%27%2C+%28strftime%28%27%25m%27%2C+created%29+-+1%29+*+3+%2B+1%2C+3%29+%7C%7C+%0D%0A++++++%27%2F%27+%7C%7C+cast%28strftime%28%27%25d%27%2C+created%29+as+integer%29+%7C%7C+%27%2F%27+%7C%7C+slug+%7C%7C+%27%2F%27%0D%0A++++++as+url%2C%0D%0A++++created%2C%0D%0A++++html%2C%0D%0A++++external_url%0D%0A++from+content%0D%0A++where+created+%3E%3D+date%28%27now%27%2C+%27-%27+%7C%7C+%3Anumdays+%7C%7C+%27+days%27%29+++%0D%0A++order+by+created+desc%0D%0A%29%0D%0Aselect+type%2C+title%2C+url%2C+created%2C+html%2C+external_url%0D%0Afrom+collected+%0D%0Aorder+by+%0D%0A++case+type+%0D%0A++++when+%27entry%27+then+0+%0D%0A++++else+1+%0D%0A++end%2C%0D%0A++case+type+%0D%0A++++when+%27entry%27+then+created+%0D%0A++++else+-strftime%28%27%25s%27%2C+created%29+%0D%0A++end+desc&amp;amp;numdays=7"&gt;try that query in Datasette&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;There are a bunch of tricks in there, but my favourite is this one:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;https://simonwillison.net/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%Y/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created)
  &lt;span class="pl-k"&gt;||&lt;/span&gt; substr(
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;JanFebMarAprMayJunJulAugSepOctNovDec&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;,
    (strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%m&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created) &lt;span class="pl-k"&gt;-&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;) &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-c1"&gt;3&lt;/span&gt; &lt;span class="pl-k"&gt;+&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;, &lt;span class="pl-c1"&gt;3&lt;/span&gt;
  ) &lt;span class="pl-k"&gt;||&lt;/span&gt;  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; cast(strftime(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%d&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created) &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-k"&gt;integer&lt;/span&gt;) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; slug &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-k"&gt;as&lt;/span&gt; url&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This is the trick I'm using to generate the URL for each entry, blogmark and quotation.&lt;/p&gt;
&lt;p&gt;These are stored as datetime values in the database, but the eventual URLs look like this:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://simonwillison.net/2023/Apr/2/calculator-for-words/"&gt;https://simonwillison.net/2023/Apr/2/calculator-for-words/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;So I need to turn that date into a YYYY/Mon/DD URL component.&lt;/p&gt;
&lt;p&gt;One problem: SQLite doesn't have a date format string that produces a three letter month abbreviation. But... with cunning application of the &lt;code&gt;substr()&lt;/code&gt; function and a string of all the month abbreviations I can get what I need.&lt;/p&gt;
&lt;p&gt;The above SQL query plus a little bit of JavaScript provides almost everything I need to generate the HTML for my newsletter.&lt;/p&gt;
&lt;h4&gt;Excluding previously sent content&lt;/h4&gt;
&lt;p&gt;There's one last problem to solve: I want to send a newsletter containing everything that's new since my last edition - I don't want to send out the same content twice.&lt;/p&gt;
&lt;p&gt;I came up with a delightfully gnarly solution to that as well.&lt;/p&gt;
&lt;p&gt;As mentioned earlier, Substack provides an RSS feed of previous editions. I can use that data to avoid including content that's already been sent.&lt;/p&gt;
&lt;p&gt;One problem: the Substack RSS feed does't include CORS headers, which means I can't access it directly from my notebook.&lt;/p&gt;
&lt;p&gt;GitHub offers CORS headers for every file in every repository. I already had a repo that was backing up my blog... so why not set that to backup my RSS feed from Substack as well?&lt;/p&gt;
&lt;p&gt;I &lt;a href="https://github.com/simonw/simonwillisonblog-backup/blob/c42b3afd6bd8cb2a4e8fa928c77426ec71552194/.github/workflows/backup.yml#L70-L74"&gt;added this&lt;/a&gt; to my existing &lt;code&gt;backup.yml&lt;/code&gt; GitHub Actions workflow:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;- &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Backup Substack&lt;/span&gt;
  &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;|-&lt;/span&gt;
&lt;span class="pl-s"&gt;    curl 'https://simonw.substack.com/feed' | \&lt;/span&gt;
&lt;span class="pl-s"&gt;      python -c "import sys, xml.dom.minidom; print(xml.dom.minidom.parseString(sys.stdin.read()).toprettyxml(indent='  '))" \&lt;/span&gt;
&lt;span class="pl-s"&gt;      &amp;gt; simonw-substack-com.xml&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I'm piping it through a tiny Python script here to pretty-print the XML before saving it, because pretty-printed XML is easier to read diffs against later on.&lt;/p&gt;
&lt;p&gt;Now &lt;a href="https://github.com/simonw/simonwillisonblog-backup/blob/c42b3afd6bd8cb2a4e8fa928c77426ec71552194/simonw-substack-com.xml"&gt;simonw-substack-com.xml&lt;/a&gt; is a copy of my RSS feed in a GitHub repo, which means I can access the data directly from JavaScript running on Observable.&lt;/p&gt;
&lt;p&gt;Here's the code I wrote there to fetch that RSS feed, parse it as XML and return a string containing just the HTML of all of the posts:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-s1"&gt;previousNewsletters&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-s1"&gt;const&lt;/span&gt; response &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-en"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
    &lt;span class="pl-s"&gt;"https://raw.githubusercontent.com/simonw/simonwillisonblog-backup/main/simonw-substack-com.xml"&lt;/span&gt;
  &lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;rss&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;text&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;parser&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;DOMParser&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;xmlDoc&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;parser&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;parseFromString&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;rss&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"application/xml"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;xpathExpression&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;"//content:encoded"&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-en"&gt;namespaceResolver&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;prefix&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;ns&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-c1"&gt;content&lt;/span&gt;: &lt;span class="pl-s"&gt;"http://purl.org/rss/1.0/modules/content/"&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;ns&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s1"&gt;prefix&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt; &lt;span class="pl-c1"&gt;||&lt;/span&gt; &lt;span class="pl-c1"&gt;null&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;result&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;xmlDoc&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;evaluate&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
    &lt;span class="pl-s1"&gt;xpathExpression&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-s1"&gt;xmlDoc&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-en"&gt;namespaceResolver&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-v"&gt;XPathResult&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;ANY_TYPE&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;null&lt;/span&gt;
  &lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;node&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;text&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;while&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;node&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;result&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;iterateNext&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-s1"&gt;text&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;push&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;node&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;textContent&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
  &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;text&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;join&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"\n"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then I span up a regular expression to extract all of the URLs from that HTML:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-s1"&gt;previousLinks&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-s1"&gt;const&lt;/span&gt; regex &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;span class="pl-kos"&gt;(?:&lt;/span&gt;"&lt;span class="pl-c1"&gt;|&lt;/span&gt;&amp;amp;quot;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;https?:&lt;span class="pl-cce"&gt;\/&lt;/span&gt;&lt;span class="pl-cce"&gt;\/&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;^&lt;span class="pl-cce"&gt;\s&lt;/span&gt;"&amp;lt;&amp;gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-c1"&gt;+&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;(?:&lt;/span&gt;"&lt;span class="pl-c1"&gt;|&lt;/span&gt;&amp;amp;quot;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;g&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-v"&gt;Array&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;from&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;previousNewsletters&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;matchAll&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;regex&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;match&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;match&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;1&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Added a "skip existing" toggle checkbox to my notebook:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-s1"&gt;viewof&lt;/span&gt; &lt;span class="pl-s1"&gt;skipExisting&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Inputs&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;toggle&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c1"&gt;label&lt;/span&gt;: &lt;span class="pl-s"&gt;"Skip content sent in prior newsletters"&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And added this code to filter the raw content based on whether or not the toggle was selected:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-s1"&gt;content&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;skipExisting&lt;/span&gt;
  ? &lt;span class="pl-s1"&gt;raw_content&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;filter&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
      &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;e&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="pl-c1"&gt;!&lt;/span&gt;&lt;span class="pl-s1"&gt;previousLinks&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;includes&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;e&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;url&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="pl-c1"&gt;!&lt;/span&gt;&lt;span class="pl-s1"&gt;previousLinks&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;includes&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;e&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;external_url&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
    &lt;span class="pl-kos"&gt;)&lt;/span&gt;
  : &lt;span class="pl-s1"&gt;raw_content&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;url&lt;/code&gt; is the URL to the post on my blog. &lt;code&gt;external_url&lt;/code&gt; is the URL to the original source of the blogmark or quotation. A match against ether of those should exclude the content from my next newsletter.&lt;/p&gt;
&lt;h4&gt;My workflow for sending a newsletter&lt;/h4&gt;
&lt;p&gt;Given all of the above, sending a newsletter out is hardly any work at all:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Ensure the most recent backup of my blog has run, such that the Datasette instance contains my latest content. I do that by &lt;a href="https://github.com/simonw/simonwillisonblog-backup/actions/workflows/backup.yml"&gt;triggering this action&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Navigate to &lt;a href="https://observablehq.com/@simonw/blog-to-newsletter"&gt;https://observablehq.com/@simonw/blog-to-newsletter&lt;/a&gt; - select "Skip content sent in prior newsletters" and then click the "Copy rich text newsletter to clipboard" button.&lt;/li&gt;
&lt;li&gt;Navigate to the Substack "publish" interface and paste that content into the rich text editor.&lt;/li&gt;
&lt;li&gt;Pick a title and subheading, and maybe add a bit of introductory text.&lt;/li&gt;
&lt;li&gt;Preview it. If the preview looks good, hit "send".&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2023/newsletter-small.gif" alt="Animated screenshot showing the process of sending the newsletter as described above" style="max-width: 100%;" loading="lazy" /&gt;&lt;/p&gt;
&lt;h4&gt;Copy and paste APIs&lt;/h4&gt;
&lt;p&gt;I think copy and paste is under-rated as an API mechanism.&lt;/p&gt;
&lt;p&gt;There are no rate limits or API keys to worry about.&lt;/p&gt;
&lt;p&gt;It's supported by almost every application, even ones that are resistant to API integrations.&lt;/p&gt;
&lt;p&gt;It even works great on mobile phones, especially if you include a "copy to clipboard" button.&lt;/p&gt;
&lt;p&gt;My &lt;a href="https://datasette.io/plugins/datasette-copyable"&gt;datasette-copyable&lt;/a&gt; plugin for Datasette is one of my earlier explorations of this. It makes it easy to copy data out of Datasette in a variety of useful formats.&lt;/p&gt;
&lt;p&gt;This Observable newsletter project has further convinced me that the clipboard is an under-utilized mechanism for building tools to help integrate data together in creative ways.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/blogging"&gt;blogging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cors"&gt;cors&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/newsletter"&gt;newsletter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/substack"&gt;substack&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/site-upgrades"&gt;site-upgrades&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="blogging"/><category term="projects"/><category term="datasette"/><category term="observable"/><category term="cors"/><category term="newsletter"/><category term="substack"/><category term="site-upgrades"/></entry><entry><title>Introducing sqlite-vss: A SQLite Extension for Vector Search</title><link href="https://simonwillison.net/2023/Feb/10/sqlite-vss/#atom-tag" rel="alternate"/><published>2023-02-10T22:53:14+00:00</published><updated>2023-02-10T22:53:14+00:00</updated><id>https://simonwillison.net/2023/Feb/10/sqlite-vss/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@asg017/introducing-sqlite-vss"&gt;Introducing sqlite-vss: A SQLite Extension for Vector Search&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
This latest SQLite extension from Alex Garcia is possibly his best yet: it adds FAISS-powered vector similarity search directly to SQLite, enabling fast KNN similarity lookups against a virtual table that feels a lot like SQLite’s own built-in full text search feature. This write-up includes interactive demos using Datasette called from an Observable notebook, running similarity searches against an index of 200,000 news headlines and summaries in less than 50ms.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://fedi.simonwillison.net/@simon/109842505896608627"&gt;@simon on Mastodon&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/alex-garcia"&gt;alex-garcia&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vector-search"&gt;vector-search&lt;/a&gt;&lt;/p&gt;



</summary><category term="sqlite"/><category term="datasette"/><category term="observable"/><category term="alex-garcia"/><category term="vector-search"/></entry><entry><title>Tracking Mastodon user numbers over time with a bucket of tricks</title><link href="https://simonwillison.net/2022/Nov/20/tracking-mastodon/#atom-tag" rel="alternate"/><published>2022-11-20T07:00:54+00:00</published><updated>2022-11-20T07:00:54+00:00</updated><id>https://simonwillison.net/2022/Nov/20/tracking-mastodon/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;a href="https://joinmastodon.org/"&gt;Mastodon&lt;/a&gt; is definitely having a moment. User growth is skyrocketing as more and more people migrate over from Twitter.&lt;/p&gt;
&lt;p&gt;I've set up a new &lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;git scraper&lt;/a&gt; to track the number of registered user accounts on known Mastodon instances over time.&lt;/p&gt;
&lt;p&gt;It's only been running for a few hours, but it's already collected enough data to &lt;a href="https://observablehq.com/@simonw/mastodon-users-and-statuses-over-time"&gt;render this chart&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/mastodon-users-few-hours.png" alt="The chart starts at around 1am with 4,694,000 users - it climbs to 4,716,000 users by 6am in a relatively straight line" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I'm looking forward to seeing how this trend continues to develop over the next days and weeks.&lt;/p&gt;
&lt;h4&gt;Scraping the data&lt;/h4&gt;
&lt;p&gt;My scraper works by tracking &lt;a href="https://instances.social/"&gt;https://instances.social/&lt;/a&gt; - a website that lists a large number (but not all) of the Mastodon instances that are out there.&lt;/p&gt;
&lt;p&gt;That site publishes an &lt;a href="https://instances.social/instances.json"&gt;instances.json&lt;/a&gt; array which currently contains 1,830 objects representing Mastodon instances. Each of those objects looks something like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
    &lt;span class="pl-ent"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;pleroma.otter.sh&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"title"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Otterland&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"short_description"&lt;/span&gt;: &lt;span class="pl-c1"&gt;null&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"description"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Otters does squeak squeak&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"uptime"&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.944757&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"up"&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"https_score"&lt;/span&gt;: &lt;span class="pl-c1"&gt;null&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"https_rank"&lt;/span&gt;: &lt;span class="pl-c1"&gt;null&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"ipv6"&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"openRegistrations"&lt;/span&gt;: &lt;span class="pl-c1"&gt;false&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"users"&lt;/span&gt;: &lt;span class="pl-c1"&gt;5&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"statuses"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;54870&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"connections"&lt;/span&gt;: &lt;span class="pl-c1"&gt;9821&lt;/span&gt;,
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I have &lt;a href="https://github.com/simonw/scrape-instances-social/blob/main/.github/workflows/scrape.yml"&gt;a GitHub Actions workflow&lt;/a&gt; running approximately every 20 minutes that fetches a copy of that file and commits it back to this repository:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/scrape-instances-social"&gt;https://github.com/simonw/scrape-instances-social&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Since each instance includes a &lt;code&gt;users&lt;/code&gt; count, the commit history of my &lt;code&gt;instances.json&lt;/code&gt; file tells the story of Mastodon's growth over time.&lt;/p&gt;
&lt;h4&gt;Building a database&lt;/h4&gt;
&lt;p&gt;A commit log of a JSON file is interesting, but the next step is to turn that into actionable information.&lt;/p&gt;
&lt;p&gt;My &lt;a href="https://simonwillison.net/2021/Dec/7/git-history/"&gt;git-history tool&lt;/a&gt; is designed to do exactly that.&lt;/p&gt;
&lt;p&gt;For the chart up above, the only number I care about is the total number of users listed in each snapshot of the file - the sum of that &lt;code&gt;users&lt;/code&gt; field for each instance.&lt;/p&gt;
&lt;p&gt;Here's how to run &lt;code&gt;git-history&lt;/code&gt; against that file's commit history to generate tables showing how that count has changed over time:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;git-history file counts.db instances.json \
  --convert &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;return [&lt;/span&gt;
&lt;span class="pl-s"&gt;    {&lt;/span&gt;
&lt;span class="pl-s"&gt;        'id': 'all',&lt;/span&gt;
&lt;span class="pl-s"&gt;        'users': sum(d['users'] or 0 for d in json.loads(content)),&lt;/span&gt;
&lt;span class="pl-s"&gt;        'statuses': sum(int(d['statuses'] or 0) for d in json.loads(content)),&lt;/span&gt;
&lt;span class="pl-s"&gt;    }&lt;/span&gt;
&lt;span class="pl-s"&gt;  ]&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; --id id&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I'm creating a file called &lt;code&gt;counts.db&lt;/code&gt; that shows the history of the &lt;code&gt;instances.json&lt;/code&gt; file.&lt;/p&gt;
&lt;p&gt;The real trick here though is that &lt;code&gt;--convert&lt;/code&gt; argument. I'm using that to compress each snapshot down to a single row that looks like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
    &lt;span class="pl-ent"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;all&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"users"&lt;/span&gt;: &lt;span class="pl-c1"&gt;4717781&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"statuses"&lt;/span&gt;: &lt;span class="pl-c1"&gt;374217860&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Normally &lt;code&gt;git-history&lt;/code&gt; expects to work against an array of objects, tracking the history of changes to each one based on their &lt;code&gt;id&lt;/code&gt; property.&lt;/p&gt;
&lt;p&gt;Here I'm tricking it a bit - I only return a single object with the ID of &lt;code&gt;all&lt;/code&gt;. This means that &lt;code&gt;git-history&lt;/code&gt; will only track the history of changes to that single object.&lt;/p&gt;
&lt;p&gt;It works though! The result is a &lt;code&gt;counts.db&lt;/code&gt; file which is currently 52KB and has the following schema (truncated to the most interesting bits):&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;CREATE TABLE [commits] (
   [id] &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt; &lt;span class="pl-k"&gt;PRIMARY KEY&lt;/span&gt;,
   [namespace] &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt; &lt;span class="pl-k"&gt;REFERENCES&lt;/span&gt; [namespaces]([id]),
   [hash] &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;,
   [commit_at] &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;
);
CREATE TABLE [item_version] (
   [_id] &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt; &lt;span class="pl-k"&gt;PRIMARY KEY&lt;/span&gt;,
   [_item] &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt; &lt;span class="pl-k"&gt;REFERENCES&lt;/span&gt; [item]([_id]),
   [_version] &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt;,
   [_commit] &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt; &lt;span class="pl-k"&gt;REFERENCES&lt;/span&gt; [commits]([id]),
   [id] &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;,
   [users] &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt;,
   [statuses] &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt;,
   [_item_full_hash] &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;
);&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Each &lt;code&gt;item_version&lt;/code&gt; row will tell us the number of users and statuses at a particular point in time, based on a join against that &lt;code&gt;commits&lt;/code&gt; table to find the &lt;code&gt;commit_at&lt;/code&gt; date.&lt;/p&gt;
&lt;h4&gt;Publishing the database&lt;/h4&gt;
&lt;p&gt;For this project, I decided to publish the SQLite database to an S3 bucket. I considered pushing the binary SQLite file directly to the GitHub repository but this felt rude, since a binary file that changes every 20 minutes would bloat the repository.&lt;/p&gt;
&lt;p&gt;I wanted to serve the file with open CORS headers so I could load it into Datasette Lite and Observable notebooks.&lt;/p&gt;
&lt;p&gt;I used my &lt;a href="https://s3-credentials.readthedocs.io/"&gt;s3-credentials&lt;/a&gt; tool to create a bucket for this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~ % s3-credentials create scrape-instances-social --public --website --create-bucket
Created bucket: scrape-instances-social
Attached bucket policy allowing public access
Configured website: IndexDocument=index.html, ErrorDocument=error.html
Created  user: 's3.read-write.scrape-instances-social' with permissions boundary: 'arn:aws:iam::aws:policy/AmazonS3FullAccess'
Attached policy s3.read-write.scrape-instances-social to user s3.read-write.scrape-instances-social
Created access key for user: s3.read-write.scrape-instances-social
{
    "UserName": "s3.read-write.scrape-instances-social",
    "AccessKeyId": "AKIAWXFXAIOZI5NUS6VU",
    "Status": "Active",
    "SecretAccessKey": "...",
    "CreateDate": "2022-11-20 05:52:22+00:00"
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This created a new bucket called &lt;code&gt;scrape-instances-social&lt;/code&gt; configured to work as a website and allow public access.&lt;/p&gt;
&lt;p&gt;It also generated an access key and a secret access key with access to just that bucket. I saved these in GitHub Actions secrets called &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; and &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I enabled a CORS policy on the bucket like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;s3-credentials set-cors-policy scrape-instances-social
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then I added the following to my GitHub Actions workflow to build and upload the database after each run of the scraper:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;    - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Build and publish database using git-history&lt;/span&gt;
      &lt;span class="pl-ent"&gt;env&lt;/span&gt;:
        &lt;span class="pl-ent"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;: &lt;span class="pl-s"&gt;${{ secrets.AWS_ACCESS_KEY_ID }}&lt;/span&gt;
        &lt;span class="pl-ent"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;: &lt;span class="pl-s"&gt;${{ secrets.AWS_SECRET_ACCESS_KEY }}&lt;/span&gt;
      &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;|-&lt;/span&gt;
&lt;span class="pl-s"&gt;        # First download previous database to save some time&lt;/span&gt;
&lt;span class="pl-s"&gt;        wget https://scrape-instances-social.s3.amazonaws.com/counts.db&lt;/span&gt;
&lt;span class="pl-s"&gt;        # Update with latest commits&lt;/span&gt;
&lt;span class="pl-s"&gt;        ./build-count-history.sh&lt;/span&gt;
&lt;span class="pl-s"&gt;        # Upload to S3&lt;/span&gt;
&lt;span class="pl-s"&gt;        s3-credentials put-object scrape-instances-social counts.db counts.db \&lt;/span&gt;
&lt;span class="pl-s"&gt;          --access-key $AWS_ACCESS_KEY_ID \&lt;/span&gt;
&lt;span class="pl-s"&gt;          --secret-key $AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;git-history&lt;/code&gt; knows how to only process commits since the last time the database was built, so downloading the previous copy saves a lot of time.&lt;/p&gt;
&lt;h4&gt;Exploring the data&lt;/h4&gt;
&lt;p&gt;Now that I have a SQLite database that's being served over CORS-enabled HTTPS I can open it in &lt;a href="https://simonwillison.net/2022/May/4/datasette-lite/"&gt;Datasette Lite&lt;/a&gt; - my implementation of Datasette compiled to WebAssembly that runs entirely in a browser.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/?url=https://scrape-instances-social.s3.amazonaws.com/counts.db"&gt;https://lite.datasette.io/?url=https://scrape-instances-social.s3.amazonaws.com/counts.db&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Any time anyone follows this link their browser will fetch the latest copy of the &lt;code&gt;counts.db&lt;/code&gt; file directly from S3.&lt;/p&gt;
&lt;p&gt;The most interesting page in there is the &lt;code&gt;item_version_detail&lt;/code&gt; SQL view, which joins against the commits table to show the date of each change:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/?url=https://scrape-instances-social.s3.amazonaws.com/counts.db#/counts/item_version_detail"&gt;https://lite.datasette.io/?url=https://scrape-instances-social.s3.amazonaws.com/counts.db#/counts/item_version_detail&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;(Datasette Lite lets you link directly to pages within Datasette itself via a &lt;code&gt;#hash&lt;/code&gt;.)&lt;/p&gt;
&lt;h4&gt;Plotting a chart&lt;/h4&gt;
&lt;p&gt;Datasette Lite doesn't have charting yet, so I decided to turn to my favourite visualization tool, an &lt;a href="https://observablehq.com/"&gt;Observable&lt;/a&gt; notebook.&lt;/p&gt;
&lt;p&gt;Observable has the ability to query SQLite databases (that are served via CORS) directly these days!&lt;/p&gt;
&lt;p&gt;Here's my notebook:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://observablehq.com/@simonw/mastodon-users-and-statuses-over-time"&gt;https://observablehq.com/@simonw/mastodon-users-and-statuses-over-time&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;There are only four cells needed to create the chart shown above.&lt;/p&gt;
&lt;p&gt;First, we need to open the SQLite database from the remote URL:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-s1"&gt;database&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;SQLiteDatabaseClient&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;open&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-s"&gt;"https://scrape-instances-social.s3.amazonaws.com/counts.db"&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Next we need to use an Obervable Database query cell to execute SQL against that database and pull out the data we want to plot - and store it in a &lt;code&gt;query&lt;/code&gt; variable:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;SELECT&lt;/span&gt; _commit_at &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-k"&gt;date&lt;/span&gt;, users, statuses
&lt;span class="pl-k"&gt;FROM&lt;/span&gt; item_version_detail&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;We need to make one change to that data - we need to convert the &lt;code&gt;date&lt;/code&gt; column from a string to a JavaScript date object:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-s1"&gt;points&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;query&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;map&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c1"&gt;date&lt;/span&gt;: &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;Date&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;date&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;users&lt;/span&gt;: &lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;users&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;statuses&lt;/span&gt;: &lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;statuses&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Finally, we can plot the data using the &lt;a href="https://observablehq.com/@observablehq/plot"&gt;Observable Plot&lt;/a&gt; charting library like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-v"&gt;Plot&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;plot&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c1"&gt;y&lt;/span&gt;: &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c1"&gt;grid&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;label&lt;/span&gt;: &lt;span class="pl-s"&gt;"Total users over time across all tracked instances"&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;marks&lt;/span&gt;: &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-v"&gt;Plot&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;line&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;points&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt; &lt;span class="pl-c1"&gt;x&lt;/span&gt;: &lt;span class="pl-s"&gt;"date"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;y&lt;/span&gt;: &lt;span class="pl-s"&gt;"users"&lt;/span&gt; &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;marginLeft&lt;/span&gt;: &lt;span class="pl-c1"&gt;100&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I added 100px of margin to the left of the chart to ensure there was space for the large (4,696,000 and up) labels on the y-axis.&lt;/p&gt;
&lt;h4&gt;A bunch of tricks combined&lt;/h4&gt;
&lt;p&gt;This project combines a whole bunch of tricks I've been pulling together over the past few years:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;Git scraping&lt;/a&gt; is the technique I use to gather the initial data, turning a static listing of instances into a record of changes over time&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/tools/git-history"&gt;git-history&lt;/a&gt; is my tool for turning a scraped Git history into a SQLite database that's easier to work with&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://s3-credentials.readthedocs.io/"&gt;s3-credentials&lt;/a&gt; makes working with S3 buckets - in particular creating credentials that are restricted to just one bucket - much less frustrating&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/2022/May/4/datasette-lite/"&gt;Datasette Lite&lt;/a&gt; means that once you have a SQLite database online somewhere you can explore it in your browser - without having to run my full server-side &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; Python application on a machine somewhere&lt;/li&gt;
&lt;li&gt;And finally, combining the above means I can take advantage of &lt;a href="https://observablehq.com/"&gt;Observable notebooks&lt;/a&gt; for ad-hoc visualization of data that's hosted online, in this case as a static SQLite database file served from S3&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/git-scraping"&gt;git-scraping&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/git-history"&gt;git-history&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/s3-credentials"&gt;s3-credentials&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-lite"&gt;datasette-lite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/mastodon"&gt;mastodon&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cors"&gt;cors&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="github"/><category term="projects"/><category term="datasette"/><category term="observable"/><category term="github-actions"/><category term="git-scraping"/><category term="git-history"/><category term="s3-credentials"/><category term="datasette-lite"/><category term="mastodon"/><category term="cors"/></entry><entry><title>Spevktator: OSINT analysis tool for VK</title><link href="https://simonwillison.net/2022/Sep/5/spevktator/#atom-tag" rel="alternate"/><published>2022-09-05T20:48:20+00:00</published><updated>2022-09-05T20:48:20+00:00</updated><id>https://simonwillison.net/2022/Sep/5/spevktator/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/MischaU8/spevktator/blob/master/README.md"&gt;Spevktator: OSINT analysis tool for VK&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
This is a really cool project that came out of a recent Bellingcat hackathon. Spevktator takes 67,000 posts from five popular Russian news channels on VK (a popular Russian social media platform) and makes them available in Datasette, along with automated translations to English, post sharing metrics and sentiment analysis scores. This README includes some detailed analysis of the data, plus a link to an Observable notebook that implements custom visualizations against queries run directly against the Datasette instance.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/political-hacking"&gt;political-hacking&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/bellingcat"&gt;bellingcat&lt;/a&gt;&lt;/p&gt;



</summary><category term="political-hacking"/><category term="datasette"/><category term="observable"/><category term="bellingcat"/></entry><entry><title>Open every CSV file in a GitHub repository in Datasette Lite</title><link href="https://simonwillison.net/2022/Sep/1/open-every-csv-file/#atom-tag" rel="alternate"/><published>2022-09-01T19:24:21+00:00</published><updated>2022-09-01T19:24:21+00:00</updated><id>https://simonwillison.net/2022/Sep/1/open-every-csv-file/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@simonw/open-every-csv-file-in-a-github-repository-in-datasette-lite"&gt;Open every CSV file in a GitHub repository in Datasette Lite&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I built an Observable notebook that accepts a GitHub repository as input, scans it for CSV files and generates a link to open all of those CSV files in Datasette Lite.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/simonw/status/1565419078289829888"&gt;@simonw&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-lite"&gt;datasette-lite&lt;/a&gt;&lt;/p&gt;



</summary><category term="github"/><category term="projects"/><category term="observable"/><category term="datasette-lite"/></entry><entry><title>Introducing sqlite-lines - a SQLite extension for reading files line-by-line</title><link href="https://simonwillison.net/2022/Jul/30/sqlite-lines/#atom-tag" rel="alternate"/><published>2022-07-30T19:18:53+00:00</published><updated>2022-07-30T19:18:53+00:00</updated><id>https://simonwillison.net/2022/Jul/30/sqlite-lines/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@asg017/introducing-sqlite-lines"&gt;Introducing sqlite-lines - a SQLite extension for reading files line-by-line&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Alex Garcia wrote a brilliant C module for SQLIte which adds functions (and a table-valued function) for efficiently reading newline-delimited text into SQLite. When combined with SQLite’s built-in JSON features this means you can read a huge newline-delimited JSON file into SQLite in a streaming fashion so it doesn’t exhaust memory for a large file. Alex also compiled the extension to WebAssembly, and his post here is an Observable notebook post that lets you exercise the code directly.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/agarcia_me/status/1553266886929920000"&gt;@agarcia_me&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/json"&gt;json&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/alex-garcia"&gt;alex-garcia&lt;/a&gt;&lt;/p&gt;



</summary><category term="json"/><category term="sqlite"/><category term="observable"/><category term="webassembly"/><category term="alex-garcia"/></entry><entry><title>Datasette table diagram using Mermaid</title><link href="https://simonwillison.net/2022/Feb/14/datasette-table-diagram-using-mermaid/#atom-tag" rel="alternate"/><published>2022-02-14T19:43:15+00:00</published><updated>2022-02-14T19:43:15+00:00</updated><id>https://simonwillison.net/2022/Feb/14/datasette-table-diagram-using-mermaid/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@simonw/datasette-table-diagram-using-mermaid"&gt;Datasette table diagram using Mermaid&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Mermaid is a DSL for generating diagrams from plain text, designed to be embedded in Markdown. GitHub just added support for Mermaid to their Markdown pipeline, which inspired me to try it out. Here’s an Observable Notebook I built which uses Mermaid to visualize the relationships between Datasette tables based on their foreign keys.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/simonw/status/1493305519481626626"&gt;@simonw&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/dsl"&gt;dsl&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/visualization"&gt;visualization&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/mermaid"&gt;mermaid&lt;/a&gt;&lt;/p&gt;



</summary><category term="dsl"/><category term="github"/><category term="visualization"/><category term="datasette"/><category term="observable"/><category term="mermaid"/></entry><entry><title>GitHub Burndown</title><link href="https://simonwillison.net/2022/Feb/10/github-burndown/#atom-tag" rel="alternate"/><published>2022-02-10T16:29:04+00:00</published><updated>2022-02-10T16:29:04+00:00</updated><id>https://simonwillison.net/2022/Feb/10/github-burndown/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@tmcw/github-burndown"&gt;GitHub Burndown&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Neat Observable notebook by Tom MacWright—give it a GitHub access token and the name of a repo and it pulls the details of every issue and plots a burndown chart over time, showing how long issues stay open for. The code is worth spending some time with—the way it fetches data from the paginated JSON API is a really great example of using generators with Observable, and the chart itself is a lovely clear example of Observable Plot.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/simonw/status/1491136777301929985"&gt;@tmcw&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tom-macwright"&gt;tom-macwright&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable-plot"&gt;observable-plot&lt;/a&gt;&lt;/p&gt;



</summary><category term="github"/><category term="observable"/><category term="tom-macwright"/><category term="observable-plot"/></entry><entry><title>Observable Plot Cheatsheets</title><link href="https://simonwillison.net/2022/Jan/25/observable-plot-cheatsheets/#atom-tag" rel="alternate"/><published>2022-01-25T22:12:45+00:00</published><updated>2022-01-25T22:12:45+00:00</updated><id>https://simonwillison.net/2022/Jan/25/observable-plot-cheatsheets/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@observablehq/plot-cheatsheets"&gt;Observable Plot Cheatsheets&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Beautiful new set of cheatsheets by Mike Freeman for the Observable Plot charting library. This is really top notch documentation—the cheatsheets are available as printable PDFs but the real value here is in the interactive versions of them, which include Observable-powered sliders to tweak the different examples and copy out the resulting generated code.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/mf_viz/status/1485705178925916175"&gt;@mf_viz&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/visualization"&gt;visualization&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable-plot"&gt;observable-plot&lt;/a&gt;&lt;/p&gt;



</summary><category term="visualization"/><category term="observable"/><category term="observable-plot"/></entry><entry><title>Datasette downloads per day (with Observable Plot)</title><link href="https://simonwillison.net/2021/Jul/17/datasette-downloads-per-day/#atom-tag" rel="alternate"/><published>2021-07-17T17:01:46+00:00</published><updated>2021-07-17T17:01:46+00:00</updated><id>https://simonwillison.net/2021/Jul/17/datasette-downloads-per-day/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://observablehq.com/@simonw/datasette-downloads-per-day-with-observable-plot"&gt;Datasette downloads per day (with Observable Plot)&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I built an Observable notebook that imports PyPI package download data from datasette.io (itself scraped from pypistats.org using a scheduled GitHub Action) and plots it using Observable Plot. Datasette downloads from PyPI apparently jumped from ~800/day in May to ~4,000/day in July—would love to know why!

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/simonw/status/1416132872159698947"&gt;@simonw&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/pypi"&gt;pypi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable-plot"&gt;observable-plot&lt;/a&gt;&lt;/p&gt;



</summary><category term="pypi"/><category term="datasette"/><category term="observable"/><category term="observable-plot"/></entry></feed>