<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: yaml</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/yaml.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2026-06-30T16:54:26+00:00</updated><author><name>Simon Willison</name></author><entry><title>Have your agent record video demos of its work with shot-scraper video</title><link href="https://simonwillison.net/2026/Jun/30/shot-scraper-video/" rel="alternate"/><published>2026-06-30T16:54:26+00:00</published><updated>2026-06-30T16:54:26+00:00</updated><id>https://simonwillison.net/2026/Jun/30/shot-scraper-video/</id><summary type="html">
    &lt;p&gt;&lt;a href="https://shot-scraper.datasette.io/en/stable/video.html"&gt;shot-scraper video&lt;/a&gt; is a new command introduced in today's &lt;a href="https://github.com/simonw/shot-scraper/releases/tag/1.10"&gt;shot-scraper 1.10&lt;/a&gt; release which accepts a &lt;code&gt;storyboard.yml&lt;/code&gt; file defining a routine to run against a web application and uses Playwright to record a video of that routine. I've written before about the importance of &lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#proving-code-actually-works"&gt;having coding agents produce demos&lt;/a&gt; of their work; this is my latest attempt at enabling them to do that.&lt;/p&gt;
&lt;p&gt;Here's an example video created using &lt;code&gt;shot-scraper video&lt;/code&gt;, exercising a &lt;a href="https://github.com/simonw/datasette/pull/2813"&gt;still in development&lt;/a&gt; feature adding the ability to create new tables in Datasette from pasted CSV, TSV or JSON data:&lt;/p&gt;
&lt;div style="max-width: 100%; margin-bottom: 0.4em"&gt;
    &lt;video controls="controls" preload="none" aria-label="Video demo of the new CSV import for Datasette" poster="https://static.simonwillison.net/static/2026/datasette-bulk-insert-demo.jpg" loop="loop" style="width: 100%; height: auto;" muted="muted"&gt;
        &lt;source src="https://static.simonwillison.net/static/2026/datasette-bulk-insert-demo.mp4" type="video/mp4" /&gt;
    &lt;/video&gt;
&lt;/div&gt;
&lt;p&gt;That video was created by running this command:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;shot-scraper video datasette-bulk-insert-storyboard.yml \
  --auth datasette-demo-auth.json --mp4&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;(That &lt;code&gt;--auth&lt;/code&gt; JSON file &lt;a href="https://gist.github.com/simonw/287b26aff53fcb72942b19f5b69d7e5c"&gt;contains a cookie&lt;/a&gt;, as &lt;a href="https://shot-scraper.datasette.io/en/stable/authentication.html"&gt;described here&lt;/a&gt; in the documentation.)&lt;/p&gt;
&lt;p&gt;Here's the &lt;code&gt;datasette-bulk-insert-storyboard.yml&lt;/code&gt; file:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;/tmp/datasette-bulk-insert-demo.webm&lt;/span&gt;
&lt;span class="pl-ent"&gt;server&lt;/span&gt;:
  - &lt;span class="pl-s"&gt;uv&lt;/span&gt;
  - &lt;span class="pl-s"&gt;--directory&lt;/span&gt;
  - &lt;span class="pl-s"&gt;/Users/simon/Dropbox/dev/datasette&lt;/span&gt;
  - &lt;span class="pl-s"&gt;run&lt;/span&gt;
  - &lt;span class="pl-s"&gt;datasette&lt;/span&gt;
  - &lt;span class="pl-s"&gt;-p&lt;/span&gt;
  - &lt;span class="pl-c1"&gt;6419&lt;/span&gt;
  - &lt;span class="pl-s"&gt;--root&lt;/span&gt;
  - &lt;span class="pl-s"&gt;--secret&lt;/span&gt;
  - &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;1&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
  - &lt;span class="pl-s"&gt;/tmp/demo.db&lt;/span&gt;
&lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;http://127.0.0.1:6419/demo/tasks&lt;/span&gt;
&lt;span class="pl-ent"&gt;viewport&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;width&lt;/span&gt;: &lt;span class="pl-c1"&gt;1280&lt;/span&gt;
  &lt;span class="pl-ent"&gt;height&lt;/span&gt;: &lt;span class="pl-c1"&gt;720&lt;/span&gt;
&lt;span class="pl-ent"&gt;cursor&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;
&lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;button[data-table-action="insert-row"]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-ent"&gt;javascript&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;  (() =&amp;gt; {&lt;/span&gt;
&lt;span class="pl-s"&gt;    let clipboardText = "";&lt;/span&gt;
&lt;span class="pl-s"&gt;    Object.defineProperty(navigator, "clipboard", {&lt;/span&gt;
&lt;span class="pl-s"&gt;      configurable: true,&lt;/span&gt;
&lt;span class="pl-s"&gt;      get: () =&amp;gt; ({&lt;/span&gt;
&lt;span class="pl-s"&gt;        writeText: async (text) =&amp;gt; {&lt;/span&gt;
&lt;span class="pl-s"&gt;          clipboardText = String(text);&lt;/span&gt;
&lt;span class="pl-s"&gt;        },&lt;/span&gt;
&lt;span class="pl-s"&gt;        readText: async () =&amp;gt; clipboardText,&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-s"&gt;  })();&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;&lt;span class="pl-ent"&gt;scenes&lt;/span&gt;:
  - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Bulk insert existing table rows&lt;/span&gt;
    &lt;span class="pl-ent"&gt;do&lt;/span&gt;:
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.8&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;button[data-table-action="insert-row"]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;#row-edit-dialog[open]&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.5&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-bulk-insert&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-bulk-textarea&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.5&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-copy-template&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;text=Copied&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.8&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;fill&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;into&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-bulk-textarea&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
          &lt;span class="pl-ent"&gt;text&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;            title,owner,status,priority,notes&lt;/span&gt;
&lt;span class="pl-s"&gt;            Prepare release video,Ana,doing,1,Recorded with shot-scraper&lt;/span&gt;
&lt;span class="pl-s"&gt;            Check pasted CSV import,Ben,review,3,Previewed before inserting&lt;/span&gt;
&lt;span class="pl-s"&gt;            Share the branch demo,Chen,queued,2,Bulk insert creates three rows&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.8&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-save&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;text=Previewing 3 rows.&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;1.2&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-save&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;text=3 rows inserted.&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;1.0&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-cancel&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;text=Prepare release video&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;1.0&lt;/span&gt;
  - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Create a table from pasted CSV&lt;/span&gt;
    &lt;span class="pl-ent"&gt;open&lt;/span&gt;: &lt;span class="pl-s"&gt;http://127.0.0.1:6419/demo&lt;/span&gt;
    &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;details.actions-menu-links summary&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-ent"&gt;do&lt;/span&gt;:
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.8&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;details.actions-menu-links summary&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;button[data-database-action="create-table"]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;#table-create-dialog[open]&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.5&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;fill&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;into&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.table-create-table-name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
          &lt;span class="pl-ent"&gt;text&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;launch_metrics&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.table-create-from-data&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.table-create-data-textarea&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.5&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;fill&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;into&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.table-create-data-textarea&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
          &lt;span class="pl-ent"&gt;text&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;            metric_id,name,score,recorded_on&lt;/span&gt;
&lt;span class="pl-s"&gt;            m001,Activation rate,87.5,2026-06-29&lt;/span&gt;
&lt;span class="pl-s"&gt;            m002,Retention check,72.25,2026-06-30&lt;/span&gt;
&lt;span class="pl-s"&gt;            m003,CSV import health,95,2026-07-01&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.8&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.table-create-save&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;text=Previewing 3 rows.&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;1.2&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.table-create-save&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for_url&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;**/demo/launch_metrics&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;text=Activation rate&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;1.2&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;a href="https://shot-scraper.datasette.io/en/stable/video.html"&gt;video command documentation&lt;/a&gt; includes simpler examples, but for the purpose of this post I thought I'd go with something more comprehensive.&lt;/p&gt;
&lt;p&gt;That demo YAML storyboard was constructed entirely by GPT-5.5 xhigh running in Codex Desktop, using the following prompt run inside my &lt;code&gt;~/dev/datasette&lt;/code&gt; checkout of &lt;a href="https://github.com/simonw/datasette/commits/b759ea548606bc9bf9a4bf0e33e2d57ead7e0ab8/"&gt;this branch&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Review the changes on this branch.&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cd to ~/dev/shot-scraper and run the command "uv run shot-scraper video --help"&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Now use that new video command to record a video demo of the new features from this branch, including running a "uv run datasette -p 6419 --root --secret 1 /tmp/demo.db" development server so you can record the video against a demo DB that you first create.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Now that I've released the feature the prompt could say "&lt;code&gt;run uvx shot-scraper video --help&lt;/code&gt;" instead and it should achieve the same result.&lt;/p&gt;
&lt;p&gt;I really like this pattern where the &lt;code&gt;--help&lt;/code&gt; output for a command provides enough detail that a coding agent can use it - it works kind of like bundling a &lt;code&gt;SKILL.md&lt;/code&gt; file directly inside the tool. I used the same pattern for &lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/"&gt;showboat and rodney&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="how-i-built-this"&gt;How I built this&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;shot-scraper video&lt;/code&gt; started as an experimental prototype. &lt;code&gt;shot-scraper&lt;/code&gt; is built on top of &lt;a href="https://playwright.dev/"&gt;Playwright&lt;/a&gt;, and the key feature it needed was for Playwright to be able to record video of browser sessions with enough control to create the desired demo.&lt;/p&gt;
&lt;p&gt;I first tried this a few years ago and found that the Playwright-produced videos included additional chrome that was useful for debugging a test failure but unwanted for a product demo.&lt;/p&gt;
&lt;p&gt;They fixed that a while ago, but there were still some minor blockers. In particular I was getting &lt;a href="https://github.com/simonw/shot-scraper/pull/194/changes/c2f3b3a52ba84f2adcf3ad6da4d39c2570328584#issuecomment-4724459369"&gt;a few white frames at the start of the videos&lt;/a&gt;, since the recording mechanism kicked in before the first URL was loaded by the browser.&lt;/p&gt;
&lt;p&gt;Playwright 1.59 added a new &lt;a href="https://playwright.dev/python/docs/api/class-screencast"&gt;screencast mechanism&lt;/a&gt; providing much more finely grained control over video recording. This was very nearly what I needed, but the resulting videos were fixed at 800px wide.&lt;/p&gt;
&lt;p&gt;I found a &lt;a href="https://github.com/microsoft/playwright/pull/41183"&gt;landed PR fixing that&lt;/a&gt; but it wasn't yet in a release. Then yesterday they shipped it in &lt;a href="https://github.com/microsoft/playwright-python/releases/tag/v1.61.0"&gt;playwright-python 1.61.0&lt;/a&gt; and I was finally unblocked to finish implementing the feature!&lt;/p&gt;
&lt;p&gt;The code itself was all written by GPT-5.5 xhigh in Codex Desktop. I had it write the documentation as well which gave me a very useful frame for reviewing the design - much of the iteration on the feature came from reviewing that documentation, spotting things that were redundant, inconsistent or confusing, and requesting (or dictating) a better design.&lt;/p&gt;
&lt;p&gt;The YAML format itself was mostly defined by the coding agent. I had it &lt;a href="https://github.com/simonw/shot-scraper/blob/1.10/shot_scraper/video.py#L24"&gt;use Pydantic&lt;/a&gt; to both define and validate the format, partly to make the design easier to review.&lt;/p&gt;
&lt;p&gt;This is a great example of the kind of feature that I almost certainly wouldn't have taken on without coding agent support. I filed the &lt;a href="https://github.com/simonw/shot-scraper/issues/142"&gt;original issue&lt;/a&gt; in February 2024, and had difficulty finding the necessary time to solve this in amongst all of my other projects.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/playwright"&gt;playwright&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/shot-scraper"&gt;shot-scraper&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/pydantic"&gt;pydantic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="python"/><category term="yaml"/><category term="ai"/><category term="datasette"/><category term="playwright"/><category term="shot-scraper"/><category term="generative-ai"/><category term="llms"/><category term="pydantic"/><category term="coding-agents"/><category term="agentic-engineering"/></entry><entry><title>datasette.io news preview</title><link href="https://simonwillison.net/2026/Apr/16/datasette-io-preview/" rel="alternate"/><published>2026-04-16T00:18:03+00:00</published><updated>2026-04-16T00:18:03+00:00</updated><id>https://simonwillison.net/2026/Apr/16/datasette-io-preview/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/datasette-io-preview"&gt;datasette.io news preview&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;The &lt;a href="https://datasette.io/"&gt;datasette.io&lt;/a&gt; website has a news section built from this &lt;a href="https://github.com/simonw/datasette.io/blob/main/news.yaml"&gt;news.yaml&lt;/a&gt; file in the underlying GitHub repository. The YAML format looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- date: 2026-04-15
  body: |-
    [Datasette 1.0a27](https://docs.datasette.io/en/latest/changelog.html#a27-2026-04-15) changes how CSRF protection works in a way that simplifies form and API integration, and introduces a new `RenameTableEvent` for when a table is renamed by a SQL query.
- date: 2026-03-18
  body: |-
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This format is a little hard to edit, so I finally &lt;a href="https://claude.ai/share/c96129b9-bcb0-4eba-aee9-4a7ad236dfb7"&gt;had Claude build a custom preview UI&lt;/a&gt; to make checking for errors have slightly less friction.&lt;/p&gt;
&lt;p&gt;I built it using standard &lt;a href="https://claude.ai/"&gt;claude.ai&lt;/a&gt; and Claude Artifacts, taking advantage of Claude's ability to clone GitHub repos and look at their content as part of a regular chat:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Clone https://github.com/simonw/datasette.io and look at the news.yaml file and how it is rendered on the homepage. Build an artifact I can paste that YAML into which previews what it will look like, and highlights any markdown errors or YAML errors&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img alt="Screenshot showing two side-by-side views of a datasette.io news preview tool. The left panel shows a dark-themed YAML editor with news entries containing date and body fields in Markdown format, with a red validation error at the bottom indicating the date field has an invalid format. The right panel shows the rendered preview output with formatted headings by date (April 2026, 18th March 2026), displaying 115 news entries with linked release names, inline code snippets, and changelog descriptions. A red badge with &amp;quot;1&amp;quot; appears on the left panel header indicating one validation error." src="https://static.simonwillison.net/static/2026/datasette-io-preview.jpg" /&gt;&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vibe-coding"&gt;vibe-coding&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="tools"/><category term="yaml"/><category term="datasette"/><category term="claude"/><category term="vibe-coding"/></entry><entry><title>model.yaml</title><link href="https://simonwillison.net/2025/Jun/21/model-yaml/" rel="alternate"/><published>2025-06-21T17:15:21+00:00</published><updated>2025-06-21T17:15:21+00:00</updated><id>https://simonwillison.net/2025/Jun/21/model-yaml/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://modelyaml.org/"&gt;model.yaml&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
From their &lt;a href="https://github.com/modelyaml/modelyaml"&gt;GitHub repo&lt;/a&gt; it looks like this effort quietly launched a couple of months ago, driven by the &lt;a href="https://lmstudio.ai/"&gt;LM Studio&lt;/a&gt; team. Their goal is to specify an "open standard for defining crossplatform, composable AI models".&lt;/p&gt;
&lt;p&gt;A model can be defined using a YAML file that &lt;a href="https://lmstudio.ai/models/mistralai/mistral-small-3.2"&gt;looks like this&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-ent"&gt;model&lt;/span&gt;: &lt;span class="pl-s"&gt;mistralai/mistral-small-3.2&lt;/span&gt;
&lt;span class="pl-ent"&gt;base&lt;/span&gt;:
  - &lt;span class="pl-ent"&gt;key&lt;/span&gt;: &lt;span class="pl-s"&gt;lmstudio-community/mistral-small-3.2-24b-instruct-2506-gguf&lt;/span&gt;
    &lt;span class="pl-ent"&gt;sources&lt;/span&gt;:
      - &lt;span class="pl-ent"&gt;type&lt;/span&gt;: &lt;span class="pl-s"&gt;huggingface&lt;/span&gt;
        &lt;span class="pl-ent"&gt;user&lt;/span&gt;: &lt;span class="pl-s"&gt;lmstudio-community&lt;/span&gt;
        &lt;span class="pl-ent"&gt;repo&lt;/span&gt;: &lt;span class="pl-s"&gt;Mistral-Small-3.2-24B-Instruct-2506-GGUF&lt;/span&gt;
&lt;span class="pl-ent"&gt;metadataOverrides&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;domain&lt;/span&gt;: &lt;span class="pl-s"&gt;llm&lt;/span&gt;
  &lt;span class="pl-ent"&gt;architectures&lt;/span&gt;:
    - &lt;span class="pl-s"&gt;mistral&lt;/span&gt;
  &lt;span class="pl-ent"&gt;compatibilityTypes&lt;/span&gt;:
    - &lt;span class="pl-s"&gt;gguf&lt;/span&gt;
  &lt;span class="pl-ent"&gt;paramsStrings&lt;/span&gt;:
    - &lt;span class="pl-c1"&gt;24B&lt;/span&gt;
  &lt;span class="pl-ent"&gt;minMemoryUsageBytes&lt;/span&gt;: &lt;span class="pl-c1"&gt;14300000000&lt;/span&gt;
  &lt;span class="pl-ent"&gt;contextLengths&lt;/span&gt;:
    - &lt;span class="pl-c1"&gt;4096&lt;/span&gt;
  &lt;span class="pl-ent"&gt;vision&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;&lt;/pre&gt;

&lt;p&gt;This should be enough information for an LLM serving engine - such as LM Studio - to understand where to get the model weights (here that's &lt;a href="https://huggingface.co/lmstudio-community/Mistral-Small-3.2-24B-Instruct-2506-GGUF"&gt;lmstudio-community/Mistral-Small-3.2-24B-Instruct-2506-GGUF&lt;/a&gt; on Hugging Face, but it leaves space for alternative providers) plus various other configuration options and important metadata about the capabilities of the model.&lt;/p&gt;
&lt;p&gt;I like this concept a lot. I've actually been considering something similar for my LLM tool - my idea was to use Markdown with a YAML frontmatter block - but now that there's an early-stage standard for it I may well build on top of this work instead.&lt;/p&gt;
&lt;p&gt;I couldn't find any evidence that anyone outside of LM Studio is using this yet, so it's effectively a one-vendor standard for the moment. All of the models in their &lt;a href="https://lmstudio.ai/models"&gt;Model Catalog&lt;/a&gt; are defined using model.yaml.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/standards"&gt;standards&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&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/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/lm-studio"&gt;lm-studio&lt;/a&gt;&lt;/p&gt;



</summary><category term="standards"/><category term="yaml"/><category term="ai"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="lm-studio"/></entry><entry><title>openai/openai-openapi</title><link href="https://simonwillison.net/2024/Dec/22/openai-openapi/" rel="alternate"/><published>2024-12-22T22:59:25+00:00</published><updated>2024-12-22T22:59:25+00:00</updated><id>https://simonwillison.net/2024/Dec/22/openai-openapi/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/openai/openai-openapi"&gt;openai/openai-openapi&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Seeing as the LLM world has semi-standardized on imitating OpenAI's API format for a whole host of different tools, it's useful to note that OpenAI themselves maintain a dedicated repository for a &lt;a href="https://www.openapis.org/"&gt;OpenAPI&lt;/a&gt; YAML representation of their current API.&lt;/p&gt;
&lt;p&gt;(I get OpenAI and OpenAPI typo-confused all the time, so &lt;code&gt;openai-openapi&lt;/code&gt; is a delightfully fiddly repository name.)&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/openai/openai-openapi/blob/master/openapi.yaml"&gt;openapi.yaml&lt;/a&gt; file itself is over 26,000 lines long, defining 76 API endpoints ("paths" in OpenAPI terminology) and 284 "schemas" for JSON that can be sent to and from those endpoints. A much more interesting view onto it is the &lt;a href="https://github.com/openai/openai-openapi/commits/master/openapi.yaml"&gt;commit history&lt;/a&gt; for that file, showing details of when each different API feature was released.&lt;/p&gt;
&lt;p&gt;Browsing 26,000 lines of YAML isn't pleasant, so I &lt;a href="https://gist.github.com/simonw/54b4e533481cc7a686b0172c3a9ac21e"&gt;got Claude&lt;/a&gt; to build me a rudimentary YAML expand/hide exploration tool. Here's that tool running against the OpenAI schema, loaded directly from GitHub via a CORS-enabled &lt;code&gt;fetch()&lt;/code&gt; call: &lt;a href="https://tools.simonwillison.net/yaml-explorer#eyJ1cmwiOiJodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vb3BlbmFpL29wZW5haS1vcGVuYXBpL3JlZnMvaGVhZHMvbWFzdGVyL29wZW5hcGkueWFtbCIsIm9wZW4iOlsiZDAiLCJkMjAiXX0="&gt;https://tools.simonwillison.net/yaml-explorer#.eyJ1c...&lt;/a&gt; - the code after that fragment is a base64-encoded JSON for the current state of the tool (mostly Claude's idea).&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of the YAML explorer, showing a partially expanded set of sections from the OpenAI API specification." src="https://static.simonwillison.net/static/2024/yaml-explorer.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;The tool is a little buggy - the expand-all option doesn't work quite how I want - but it's useful enough for the moment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update&lt;/strong&gt;: It turns out the &lt;a href="https://petstore.swagger.io/"&gt;petstore.swagger.io&lt;/a&gt; demo has an (as far as I can tell) undocumented &lt;code&gt;?url=&lt;/code&gt; parameter which can load external YAML files, so &lt;a href="https://petstore.swagger.io/?url=https://raw.githubusercontent.com/openai/openai-openapi/refs/heads/master/openapi.yaml"&gt;here's openai-openapi/openapi.yaml&lt;/a&gt; in an OpenAPI explorer interface.&lt;/p&gt;
&lt;p&gt;&lt;img alt="The Swagger API browser showing the OpenAI API" src="https://static.simonwillison.net/static/2024/swagger.jpg" /&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/apis"&gt;apis&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openai"&gt;openai&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/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-3-5-sonnet"&gt;claude-3-5-sonnet&lt;/a&gt;&lt;/p&gt;



</summary><category term="apis"/><category term="tools"/><category term="yaml"/><category term="ai"/><category term="openai"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="claude-3-5-sonnet"/></entry><entry><title>YAML Explorer</title><link href="https://simonwillison.net/2024/Dec/22/yaml-explorer/" rel="alternate"/><published>2024-12-22T22:44:37+00:00</published><updated>2024-12-22T22:44:37+00:00</updated><id>https://simonwillison.net/2024/Dec/22/yaml-explorer/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/yaml-explorer"&gt;YAML Explorer&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="yaml"/></entry><entry><title>JSON to YAML Converter</title><link href="https://simonwillison.net/2024/Dec/15/json-to-yaml/" rel="alternate"/><published>2024-12-15T20:47:29+00:00</published><updated>2024-12-15T20:47:29+00:00</updated><id>https://simonwillison.net/2024/Dec/15/json-to-yaml/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/json-to-yaml"&gt;JSON to YAML Converter&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="yaml"/></entry><entry><title>Auto-formatting YAML files with yamlfmt</title><link href="https://simonwillison.net/2023/Jul/13/yamlfmt/" rel="alternate"/><published>2023-07-13T14:11:44+00:00</published><updated>2023-07-13T14:11:44+00:00</updated><id>https://simonwillison.net/2023/Jul/13/yamlfmt/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;TIL:&lt;/strong&gt; &lt;a href="https://til.simonwillison.net/yaml/yamlfmt"&gt;Auto-formatting YAML files with yamlfmt&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="yaml"/></entry><entry><title>Automating screenshots for the Datasette documentation using shot-scraper</title><link href="https://simonwillison.net/2022/Oct/14/automating-screenshots/" rel="alternate"/><published>2022-10-14T23:44:03+00:00</published><updated>2022-10-14T23:44:03+00:00</updated><id>https://simonwillison.net/2022/Oct/14/automating-screenshots/</id><summary type="html">
    &lt;p&gt;I released &lt;a href="https://shot-scraper.datasette.io/"&gt;shot-scraper&lt;/a&gt; back &lt;a href="https://simonwillison.net/2022/Mar/10/shot-scraper/"&gt;in March&lt;/a&gt; as a tool for keeping screenshots in documentation up-to-date.&lt;/p&gt;
&lt;p&gt;It's very easy for feature screenshots in documentation for a web application to drift out-of-date with the latest design of the software itself.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;shot-scraper&lt;/code&gt; is a command-line tool that aims to solve this.&lt;/p&gt;
&lt;p&gt;You can use it to take one-off screenshots like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper https://latest.datasette.io/ --height 800
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or you can define multiple screenshots in a single YAML file - let's call this &lt;code&gt;shots.yml&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://latest.datasette.io/&lt;/span&gt;
  &lt;span class="pl-ent"&gt;height&lt;/span&gt;: &lt;span class="pl-c1"&gt;800&lt;/span&gt;
  &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;index.png&lt;/span&gt;
- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://latest.datasette.io/fixtures&lt;/span&gt;
  &lt;span class="pl-ent"&gt;height&lt;/span&gt;: &lt;span class="pl-c1"&gt;800&lt;/span&gt;
  &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;database.png&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And run them all at once like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper multi shots.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This morning I used &lt;code&gt;shot-scraper&lt;/code&gt; to replace all of the existing screenshots in the &lt;a href="https://docs.datasette.io/en/latest/"&gt;Datasette documentation&lt;/a&gt; with up-to-date, automated equivalents.&lt;/p&gt;
&lt;p&gt;I decided to use this as an opportunity to create a more detailed tutorial for how to use &lt;code&gt;shot-scraper&lt;/code&gt; for this kind of screenshot automation project.&lt;/p&gt;
&lt;h4&gt;Four screenshots to replace&lt;/h4&gt;
&lt;p&gt;Datasette's documentation included four screenshots that I wanted to replace with automated equivalents.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/datasette/blob/0.62/docs/full_text_search.png"&gt;full_text_search.png&lt;/a&gt; illustrates the full-text search feature:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/simonw/datasette/0.62/docs/full_text_search.png" alt="A search for cherry running against the Street_Tree_List table, returning 14,663 rows" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://raw.githubusercontent.com/simonw/datasette/0.62/docs/advanced_export.png"&gt;advanced_export.png&lt;/a&gt; displays Datasette's "advanced export" dialog:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/simonw/datasette/0.62/docs/advanced_export.png" alt="Advanced export dialog, with four links 3 checkboxes and an Export CSV button" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/datasette/blob/0.62/docs/binary_data.png"&gt;binary_data.png&lt;/a&gt; displays just a small fragment of a table with binary download links:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/simonw/datasette/0.62/docs/binary_data.png" alt="A small screenshot showing binary data download links" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/datasette/blob/0.62/docs/facets.png"&gt;facets.png&lt;/a&gt; demonstrates faceting against a table:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://github.com/simonw/datasette/raw/0.62/docs/facets.png?raw=true" alt="Datasette's facet interface, showing one suggested facet and three facet lists" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I'll walk through each screenshot in turn.&lt;/p&gt;
&lt;h4&gt;full_text_search.png&lt;/h4&gt;
&lt;p&gt;I decided to use a different example for the new screenshot, because I don't currently have a live instance for that table running against the most recent Datasette release.&lt;/p&gt;
&lt;p&gt;I went with &lt;a href="https://register-of-members-interests.datasettes.com/regmem/items?_search=hamper&amp;amp;_sort_desc=date"&gt;https://register-of-members-interests.datasettes.com/regmem/items?_search=hamper&amp;amp;_sort_desc=date&lt;/a&gt; - a search against the UK register of members interests for "hamper" (see &lt;a href="https://simonwillison.net/2018/Apr/25/register-members-interests/"&gt;Exploring the UK Register of Members Interests with SQL and Datasette&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;The existing image in the documentation was 960 pixels wide, so I stuck with that and tried a few iterations until I found a height that I liked.&lt;/p&gt;
&lt;p&gt;I &lt;a href="https://shot-scraper.datasette.io/en/stable/installation.html"&gt;installed shot-scraper&lt;/a&gt; and ran the following, in my &lt;code&gt;/tmp&lt;/code&gt; directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper 'https://register-of-members-interests.datasettes.com/regmem/items?_search=hamper&amp;amp;_sort_desc=date' \
  -h 585 \
  -w 960
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This produced a &lt;code&gt;register-of-members-interests-datasettes-com-regmem-items.png&lt;/code&gt; file which looked good when I opened it in Preview.&lt;/p&gt;
&lt;p&gt;I turned that into the following YAML in my &lt;code&gt;shots.yml&lt;/code&gt; file:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://register-of-members-interests.datasettes.com/regmem/items?_search=hamper&amp;amp;_sort_desc=date&lt;/span&gt;
  &lt;span class="pl-ent"&gt;height&lt;/span&gt;: &lt;span class="pl-c1"&gt;585&lt;/span&gt;
  &lt;span class="pl-ent"&gt;width&lt;/span&gt;: &lt;span class="pl-c1"&gt;960&lt;/span&gt;
  &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;regmem-search.png&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Running &lt;code&gt;shot-scraper multi shots.yml&lt;/code&gt; against that file produced this &lt;code&gt;regmem-search.png&lt;/code&gt; image:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/non-retina/regmem-search.png" alt="A screenshot of that search, with the most recent design for Datasette" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4&gt;advanced_export.png&lt;/h4&gt;
&lt;p&gt;This next image isn't a full page screenshot - it's just a small fragment of the page.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;shot-scraper&lt;/code&gt; can take partial screenshots based on one or more CSS selectors. Given a CSS selector the tool draws a box around just that element and uses that to take the screenshot - adding optional padding.&lt;/p&gt;
&lt;p&gt;Here's the recipe for the advanced export box - I used the same &lt;code&gt;register-of-members-interests.datasettes.com&lt;/code&gt; example for it as this had enough rows to trigger all of the advanced options to be displayed:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper 'https://register-of-members-interests.datasettes.com/regmem/items?_search=hamper' \
  -s '#export' \
  -p 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-p 10&lt;/code&gt; here specifies 10px of padding, needed to capture the drop shadow on the box.&lt;/p&gt;
&lt;p&gt;Here's the equivalent YAML:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://register-of-members-interests.datasettes.com/regmem/items?_search=hamper&lt;/span&gt;
  &lt;span class="pl-ent"&gt;selector&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;#export&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;advanced-export.png&lt;/span&gt;
  &lt;span class="pl-ent"&gt;padding&lt;/span&gt;: &lt;span class="pl-c1"&gt;10&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And the result:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/advanced-export.png" alt="A screenshot of the advanced export box" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4&gt;binary_data.png&lt;/h4&gt;
&lt;p&gt;This screenshot required a different trick.&lt;/p&gt;
&lt;p&gt;I wanted to take a screenshot of the table &lt;a href="https://latest.datasette.io/fixtures/binary_data"&gt;on this page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The full table looks like this, with three rows:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/shot-scraper-binary-table.png" alt="A table with three rows - two containing binary data and one that is empty" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I only wanted the first two of these to be shown in the screenshot though.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;shot-scraper&lt;/code&gt; has the ability to execute JavaScript on the page before the screenshot is taken. This can be used to remove elements first.&lt;/p&gt;
&lt;p&gt;Here's the JavaScript I came up with to remove all but the first two rows (actually the first three, because the table header counts as a row too):&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&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-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;querySelectorAll&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'tr:nth-child(n+3)'&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;el&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;el&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;parentNode&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;el&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;I did it this way so that if I add any more rows to that test table in the future the code will still remove everything but the first two.&lt;/p&gt;
&lt;p&gt;The CSS selector &lt;code&gt;tr:nth-child(n+3)&lt;/code&gt; selects all rows that are not the first three (one header plus two content rows).&lt;/p&gt;
&lt;p&gt;Here's how to run that from the command-line, and then take a 10 pixel padded screenshot of just the table on the page after it has been modified by the JavaScript:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper 'https://latest.datasette.io/fixtures/binary_data' \
  -j 'Array.from(document.querySelectorAll("tr:nth-child(n+3)"), el =&amp;gt; el.parentNode.removeChild(el));' \
  -s table -p 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The YAML I added to &lt;code&gt;shots.yml&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://latest.datasette.io/fixtures/binary_data&lt;/span&gt;
  &lt;span class="pl-ent"&gt;selector&lt;/span&gt;: &lt;span class="pl-s"&gt;table&lt;/span&gt;
  &lt;span class="pl-ent"&gt;javascript&lt;/span&gt;: &lt;span class="pl-s"&gt;|-&lt;/span&gt;
&lt;span class="pl-s"&gt;    Array.from(&lt;/span&gt;
&lt;span class="pl-s"&gt;      document.querySelectorAll('tr:nth-child(n+3)'),&lt;/span&gt;
&lt;span class="pl-s"&gt;      el =&amp;gt; el.parentNode.removeChild(el)&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-ent"&gt;padding&lt;/span&gt;: &lt;span class="pl-c1"&gt;10&lt;/span&gt;
  &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;binary-data.png&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And the resulting image:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/binary-data.png" alt="A screenshot of the binary data table, with just the first two rows" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4&gt;facets.png&lt;/h4&gt;
&lt;p&gt;I left the most complex screenshot to last.&lt;/p&gt;
&lt;p&gt;For the faceting screenshot, I wanted to include the "suggested facet" links at the top of the page, a set of active facets and then the first three rows of the following table.&lt;/p&gt;
&lt;p&gt;But... the table has quite a lot of columns. For a neater screenshot I only wanted to include a subset of columns in the final shot.&lt;/p&gt;
&lt;p&gt;Here's the screenshot I ended up taking:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/non-retina/faceting-details.png" alt="A screenshot of the suggested facet,s facets and first three rows and ten columns of the following table" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;And the YAML recipe:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://congress-legislators.datasettes.com/legislators/legislator_terms?_facet=type&amp;amp;_facet=party&amp;amp;_facet=state&amp;amp;_facet_size=10&lt;/span&gt;
  &lt;span class="pl-ent"&gt;selectors_all&lt;/span&gt;:
  - &lt;span class="pl-s"&gt;.suggested-facets a&lt;/span&gt;
  - &lt;span class="pl-s"&gt;tr:not(tr:nth-child(n+4)) td:not(:nth-child(n+11))&lt;/span&gt;
  &lt;span class="pl-ent"&gt;padding&lt;/span&gt;: &lt;span class="pl-c1"&gt;10&lt;/span&gt;
  &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;faceting-details.png&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The key trick I'm using here is that &lt;code&gt;selectors_all&lt;/code&gt; list.&lt;/p&gt;
&lt;p&gt;The usual &lt;code&gt;shot-scraper&lt;/code&gt; selector option finds the first element on the page matching the specified CSS selector and takes a screenshot of that.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--selector-all&lt;/code&gt; - or the YAML equivalent &lt;code&gt;selectors_all&lt;/code&gt; - instead finds EVERY element that matches any of the specified selectors and draws a bounding box containing all of them.&lt;/p&gt;
&lt;p&gt;I wanted that bounding box to surround a subset of the table cells on the page. I used this CSS selector to indicate that subset:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;tr:not(tr:nth-child(n+4)) td:not(:nth-child(n+11))&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Here's what GPT-3 says if you &lt;a href="https://simonwillison.net/2022/Jul/9/gpt-3-explain-code/"&gt;ask it to explain&lt;/a&gt; the selector:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Explain this CSS selector:&lt;/p&gt;
&lt;p&gt;tr:not(tr:nth-child(n+4)) td:not(:nth-child(n+11))&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This selector is selecting all table cells in rows that are not the fourth row or greater, and are not in columns that are the 11th column or greater.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;(See also &lt;a href="https://til.simonwillison.net/shot-scraper/subset-of-table-columns"&gt;this TIL&lt;/a&gt;.)&lt;/p&gt;
&lt;h4&gt;Automating everything using GitHub Actions&lt;/h4&gt;
&lt;p&gt;Here's the full &lt;code&gt;shots.yml&lt;/code&gt; YAML needed to generate all four of these screenshots:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://register-of-members-interests.datasettes.com/regmem/items?_search=hamper&amp;amp;_sort_desc=date&lt;/span&gt;
  &lt;span class="pl-ent"&gt;height&lt;/span&gt;: &lt;span class="pl-c1"&gt;585&lt;/span&gt;
  &lt;span class="pl-ent"&gt;width&lt;/span&gt;: &lt;span class="pl-c1"&gt;960&lt;/span&gt;
  &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;regmem-search.png&lt;/span&gt;
- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://register-of-members-interests.datasettes.com/regmem/items?_search=hamper&lt;/span&gt;
  &lt;span class="pl-ent"&gt;selector&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;#export&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;advanced-export.png&lt;/span&gt;
  &lt;span class="pl-ent"&gt;padding&lt;/span&gt;: &lt;span class="pl-c1"&gt;10&lt;/span&gt;
- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://congress-legislators.datasettes.com/legislators/legislator_terms?_facet=type&amp;amp;_facet=party&amp;amp;_facet=state&amp;amp;_facet_size=10&lt;/span&gt;
  &lt;span class="pl-ent"&gt;selectors_all&lt;/span&gt;:
  - &lt;span class="pl-s"&gt;.suggested-facets a&lt;/span&gt;
  - &lt;span class="pl-s"&gt;tr:not(tr:nth-child(n+4)) td:not(:nth-child(n+11))&lt;/span&gt;
  &lt;span class="pl-ent"&gt;padding&lt;/span&gt;: &lt;span class="pl-c1"&gt;10&lt;/span&gt;
  &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;faceting-details.png&lt;/span&gt;
- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://latest.datasette.io/fixtures/binary_data&lt;/span&gt;
  &lt;span class="pl-ent"&gt;selector&lt;/span&gt;: &lt;span class="pl-s"&gt;table&lt;/span&gt;
  &lt;span class="pl-ent"&gt;javascript&lt;/span&gt;: &lt;span class="pl-s"&gt;|-&lt;/span&gt;
&lt;span class="pl-s"&gt;    Array.from(&lt;/span&gt;
&lt;span class="pl-s"&gt;      document.querySelectorAll('tr:nth-child(n+3)'),&lt;/span&gt;
&lt;span class="pl-s"&gt;      el =&amp;gt; el.parentNode.removeChild(el)&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-ent"&gt;padding&lt;/span&gt;: &lt;span class="pl-c1"&gt;10&lt;/span&gt;
  &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;binary-data.png&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Running &lt;code&gt;shot-scraper shots shots.yml&lt;/code&gt; against this file takes all four screenshots.&lt;/p&gt;
&lt;p&gt;But I want this to be fully automated! So I turned to &lt;a href="https://github.com/features/actions"&gt;GitHub Actions&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A while ago I created a template repository for setting up GitHub Actions to take screenshots using &lt;code&gt;shot-scraper&lt;/code&gt; and write them back to the same repo. I wrote about that in &lt;a href="https://simonwillison.net/2022/Mar/14/shot-scraper-template/"&gt;Instantly create a GitHub repository to take screenshots of a web page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I had previously used that recipe to create my &lt;a href="https://github.com/simonw/datasette-screenshots"&gt;datasette-screenshots&lt;/a&gt; repository - with its own &lt;code&gt;shots.yml&lt;/code&gt; file.&lt;/p&gt;
&lt;p&gt;So I added the new YAML to that existing file, committed the change, waited a minute and the result was all four images stored in that repository!&lt;/p&gt;
&lt;p&gt;My &lt;code&gt;datasette-screenshots&lt;/code&gt; &lt;a href="https://github.com/simonw/datasette-screenshots/blob/main/.github/workflows/shots.yml"&gt;workflow&lt;/a&gt; actually has two key changes from my default template. First, it takes every screenshot twice - once as a retina image and once as a regular image:&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;Take retina shots&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;        shot-scraper multi shots.yml --retina&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;    - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Take non-retina shots&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;        mkdir -p non-retina&lt;/span&gt;
&lt;span class="pl-s"&gt;        cd non-retina&lt;/span&gt;
&lt;span class="pl-s"&gt;        shot-scraper multi ../shots.yml&lt;/span&gt;
&lt;span class="pl-s"&gt;        cd ..&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This provides me with both a high quality image and a smaller, faster-loading image for each screenshot.&lt;/p&gt;
&lt;p&gt;Secondly, it runs &lt;code&gt;oxipng&lt;/code&gt; to optimize the PNGs before committing them to the repo:&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;Optimize PNGs&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;        oxipng -o 4 -i 0 --strip safe *.png&lt;/span&gt;
&lt;span class="pl-s"&gt;        oxipng -o 4 -i 0 --strip safe non-retina/*.png&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;a href="https://shot-scraper.datasette.io/en/stable/github-actions.html#optimizing-pngs-using-oxipng"&gt;shot-scraper documentation&lt;/a&gt; describes this pattern in more detail.&lt;/p&gt;
&lt;p&gt;With all of that in place, simply committing a change to the &lt;code&gt;shots.yml&lt;/code&gt; file is enough to generate and store the new screenshots.&lt;/p&gt;
&lt;h4&gt;Linking to the images&lt;/h4&gt;
&lt;p&gt;One last problem to solve: I want to include these images in my documentation, which means I need a way to link to them.&lt;/p&gt;
&lt;p&gt;I decided to use GitHub to host these directly, via the &lt;code&gt;raw.githubusercontent.com&lt;/code&gt; domain - which is fronted by the Fastly CDN.&lt;/p&gt;
&lt;p&gt;I care about up-to-date images, but I also want different versions of the Datasette documentation to reflect the corresponding design in their screenshots - so I needed a way to snapshot those screenshots to a known version.&lt;/p&gt;
&lt;p&gt;Repository tags are one way to do this.&lt;/p&gt;
&lt;p&gt;I tagged the &lt;code&gt;datasette-screenshots&lt;/code&gt; repository with &lt;code&gt;0.62&lt;/code&gt;, since that's the version of Datasette that the screenshots were taken for.&lt;/p&gt;
&lt;p&gt;This gave me the following URLs for the images:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/advanced-export.png"&gt;https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/advanced-export.png&lt;/a&gt; (retina)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/non-retina/regmem-search.png"&gt;https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/non-retina/regmem-search.png&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/binary-data.png"&gt;https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/binary-data.png&lt;/a&gt; (retina)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/non-retina/faceting-details.png"&gt;https://raw.githubusercontent.com/simonw/datasette-screenshots/0.62/non-retina/faceting-details.png&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To save on page loading time I decided to use the non-retina URLs for the two larger images.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/simonw/datasette/commit/fdf9891c3f0313af9244778574c7ebaac9c3a438"&gt;the commit&lt;/a&gt; that updated the Datasette documentation to link to these new images (and deleted the old images from the repo).&lt;/p&gt;
&lt;p&gt;You can see the new images in the documentation on these pages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.datasette.io/en/latest/csv_export.html"&gt;https://docs.datasette.io/en/latest/csv_export.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.datasette.io/en/latest/binary_data.html"&gt;https://docs.datasette.io/en/latest/binary_data.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.datasette.io/en/latest/facets.html"&gt;https://docs.datasette.io/en/latest/facets.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.datasette.io/en/latest/full_text_search.html"&gt;https://docs.datasette.io/en/latest/full_text_search.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/documentation"&gt;documentation&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&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/shot-scraper"&gt;shot-scraper&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="documentation"/><category term="yaml"/><category term="datasette"/><category term="github-actions"/><category term="shot-scraper"/></entry><entry><title>shot-scraper: automated screenshots for documentation, built on Playwright</title><link href="https://simonwillison.net/2022/Mar/10/shot-scraper/" rel="alternate"/><published>2022-03-10T00:13:30+00:00</published><updated>2022-03-10T00:13:30+00:00</updated><id>https://simonwillison.net/2022/Mar/10/shot-scraper/</id><summary type="html">
    &lt;p&gt;&lt;a href="https://github.com/simonw/shot-scraper"&gt;shot-scraper&lt;/a&gt; is a new tool that I’ve built to help automate the process of keeping screenshots up-to-date in my documentation. It also doubles as a scraping tool - hence the name - which I picked as a complement to my &lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;git scraping&lt;/a&gt; and &lt;a href="https://simonwillison.net/2022/Feb/2/help-scraping/"&gt;help scraping&lt;/a&gt; techniques.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update 13th March 2022:&lt;/strong&gt; The new &lt;code&gt;shot-scraper javascript&lt;/code&gt; command can now be used to &lt;a href="https://simonwillison.net/2022/Mar/14/scraping-web-pages-shot-scraper/"&gt;scrape web pages from the command line&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update 14th October 2022:&lt;/strong&gt; &lt;a href="https://simonwillison.net/2022/Oct/14/automating-screenshots/"&gt;Automating screenshots for the Datasette documentation using shot-scraper&lt;/a&gt; offers a tutorial introduction to using the tool.&lt;/p&gt;
&lt;h4&gt;The problem&lt;/h4&gt;
&lt;p&gt;I like to include screenshots in documentation. I recently &lt;a href="https://simonwillison.net/2022/Feb/27/datasette-tutorials/"&gt;started writing end-user tutorials&lt;/a&gt; for Datasette, which are particularly image heavy (&lt;a href="https://datasette.io/tutorials/explore"&gt;for example&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;As software changes over time, screenshots get out-of-date. I don't like the idea of stale screenshots, but I also don't want to have to manually recreate them every time I make the tiniest tweak to the visual appearance of my software.&lt;/p&gt;
&lt;h4&gt;Introducing shot-scraper&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;shot-scraper&lt;/code&gt; is a tool for automating this process. You can install it using &lt;code&gt;pip&lt;/code&gt; like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install shot-scraper
shot-scraper install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That second &lt;code&gt;shot-scraper install&lt;/code&gt; line will install the browser it needs to do its job - more on that later.&lt;/p&gt;
&lt;p&gt;You can use it in two ways. To take a one-off screenshot, you can run it like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper https://simonwillison.net/ -o simonwillison.png
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or if you want to take a set of screenshots in a repeatable way, you can define them in a YAML file that looks like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://simonwillison.net/&lt;/span&gt;
  &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;simonwillison.png&lt;/span&gt;
- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://www.example.com/&lt;/span&gt;
  &lt;span class="pl-ent"&gt;width&lt;/span&gt;: &lt;span class="pl-c1"&gt;400&lt;/span&gt;
  &lt;span class="pl-ent"&gt;height&lt;/span&gt;: &lt;span class="pl-c1"&gt;400&lt;/span&gt;
  &lt;span class="pl-ent"&gt;quality&lt;/span&gt;: &lt;span class="pl-c1"&gt;80&lt;/span&gt;
  &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;example.jpg&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And then use &lt;code&gt;shot-scraper multi&lt;/code&gt; to execute every screenshot in one go:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;% shot-scraper multi shots.yml 
Screenshot of 'https://simonwillison.net/' written to 'simonwillison.png'
Screenshot of 'https://www.example.com/' written to 'example.jpg'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://shot-scraper.datasette.io/en/stable/screenshots.html"&gt;The documentation&lt;/a&gt; describes all of the available options you can use when taking a screenshot.&lt;/p&gt;
&lt;p&gt;Each option can be provided to the &lt;code&gt;shot-scraper&lt;/code&gt; one-off tool, or can be embedded in the YAML file for use with &lt;code&gt;shot-scraper multi&lt;/code&gt;.&lt;/p&gt;
&lt;h4&gt;JavaScript and CSS selectors&lt;/h4&gt;
&lt;p&gt;The default behaviour for &lt;code&gt;shot-scraper&lt;/code&gt; is to take a full page screenshot, using a browser width of 1280px.&lt;/p&gt;
&lt;p&gt;For documentation screenshots you probably don't want the whole page though - you likely want to create an image of one specific part of the interface.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;--selector&lt;/code&gt; option allows you to specify an area of the page by CSS selector. The resulting image will consist just of that part of the page.&lt;/p&gt;
&lt;p&gt;What if you want to modify the page in addition to selecting a specific area?&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;--javascript&lt;/code&gt; option lets you pass in a block of JavaScript code which will be injected into the page and executed after the page has loaded, but before the screenshot is taken.&lt;/p&gt;
&lt;p&gt;The combination of these two options - also available as &lt;code&gt;javascript:&lt;/code&gt; and &lt;code&gt;selector:&lt;/code&gt; keys in the YAML file - should be flexible enough to cover the custom screenshot case for documentation.&lt;/p&gt;
&lt;h4 id="a-complex-example"&gt;A complex example&lt;/h4&gt;
&lt;p&gt;To prove to myself that the tool works, I decided to try replicating this screenshot from &lt;a href="https://datasette.io/tutorials/explore"&gt;my tutorial&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I made the original using &lt;a href="https://cleanshot.com/"&gt;CleanShot X&lt;/a&gt;, manually adding the two pink arrows:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/select-facets-original.jpg" alt="A screenshot of a portion of the table interface in Datasette, with a menu open and two pink arrows pointing to menu items" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;This is pretty tricky!&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It's not &lt;a href="https://congress-legislators.datasettes.com/legislators/executive_terms?start__startswith=18&amp;amp;type=prez"&gt;this whole page&lt;/a&gt;, just a subset of the page&lt;/li&gt;
&lt;li&gt;The cog menu for one of the columns is open, which means the cog icon needs to be clicked before taking the screenshot&lt;/li&gt;
&lt;li&gt;There are two pink arrows superimposed on the image&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I decided to do use just one arrow for the moment, which should hopefully result in a clearer image.&lt;/p&gt;
&lt;p&gt;I started by &lt;a href="https://github.com/simonw/shot-scraper/issues/9#issuecomment-1063314278"&gt;creating my own pink arrow SVG&lt;/a&gt; using Figma:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/pink-arrow.png" alt="A big pink arrow, with a drop shadow" style="width: 200px; max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I then fiddled around in the Firefox developer console for quite a while, working out the JavaScript needed to trim the page down to the bit I wanted, open the menu and position the arrow.&lt;/p&gt;
&lt;p&gt;With the JavaScript figured out, I pasted it into a YAML file called &lt;code&gt;shot.yml&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;- &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://congress-legislators.datasettes.com/legislators/executive_terms?start__startswith=18&amp;amp;type=prez&lt;/span&gt;
  &lt;span class="pl-ent"&gt;javascript&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;    new Promise(resolve =&amp;gt; {&lt;/span&gt;
&lt;span class="pl-s"&gt;      // Run in a promise so we can sleep 1s at the end&lt;/span&gt;
&lt;span class="pl-s"&gt;      function remove(el) { el.parentNode.removeChild(el);}&lt;/span&gt;
&lt;span class="pl-s"&gt;      // Remove header and footer&lt;/span&gt;
&lt;span class="pl-s"&gt;      remove(document.querySelector('header'));&lt;/span&gt;
&lt;span class="pl-s"&gt;      remove(document.querySelector('footer'));&lt;/span&gt;
&lt;span class="pl-s"&gt;      // Remove most of the children of .content&lt;/span&gt;
&lt;span class="pl-s"&gt;      Array.from(document.querySelectorAll('.content &amp;gt; *:not(.table-wrapper,.suggested-facets)')).map(remove)&lt;/span&gt;
&lt;span class="pl-s"&gt;      // Bit of breathing room for the screenshot&lt;/span&gt;
&lt;span class="pl-s"&gt;      document.body.style.marginTop = '10px';&lt;/span&gt;
&lt;span class="pl-s"&gt;      // Add a bit of padding to .content&lt;/span&gt;
&lt;span class="pl-s"&gt;      var content = document.querySelector('.content');&lt;/span&gt;
&lt;span class="pl-s"&gt;      content.style.width = '820px';&lt;/span&gt;
&lt;span class="pl-s"&gt;      content.style.padding = '10px';&lt;/span&gt;
&lt;span class="pl-s"&gt;      // Open the menu - it's an SVG so we need to use dispatchEvent here&lt;/span&gt;
&lt;span class="pl-s"&gt;      document.querySelector('th.col-executive_id svg').dispatchEvent(new Event('click'));&lt;/span&gt;
&lt;span class="pl-s"&gt;      // Remove all but table header and first 11 rows&lt;/span&gt;
&lt;span class="pl-s"&gt;      Array.from(document.querySelectorAll('tr')).slice(12).map(remove);&lt;/span&gt;
&lt;span class="pl-s"&gt;      // Add a pink SVG arrow&lt;/span&gt;
&lt;span class="pl-s"&gt;      let div = document.createElement('div');&lt;/span&gt;
&lt;span class="pl-s"&gt;      div.innerHTML = `&amp;lt;svg width="104" height="60" fill="none" xmlns="http://www.w3.org/2000/svg"&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;        &amp;lt;g filter="url(#a)"&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;          &amp;lt;path fill-rule="evenodd" clip-rule="evenodd" d="m76.7 1 2 2 .2-.1.1.4 20 20a3.5 3.5 0 0 1 0 5l-20 20-.1.4-.3-.1-1.9 2a3.5 3.5 0 0 1-5.4-4.4l3.2-14.4H4v-12h70.6L71.3 5.4A3.5 3.5 0 0 1 76.7 1Z" fill="#FF31A0"/&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;        &amp;lt;/g&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;        &amp;lt;defs&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;          &amp;lt;filter id="a" x="0" y="0" width="104" height="59.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;              &amp;lt;feFlood flood-opacity="0" result="BackgroundImageFix"/&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;              &amp;lt;feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;              &amp;lt;feOffset dy="4"/&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;              &amp;lt;feGaussianBlur stdDeviation="2"/&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;              &amp;lt;feComposite in2="hardAlpha" operator="out"/&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;              &amp;lt;feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;              &amp;lt;feBlend in2="BackgroundImageFix" result="effect1_dropShadow_2_26"/&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;              &amp;lt;feBlend in="SourceGraphic" in2="effect1_dropShadow_2_26" result="shape"/&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;          &amp;lt;/filter&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;        &amp;lt;/defs&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;      &amp;lt;/svg&amp;gt;`;&lt;/span&gt;
&lt;span class="pl-s"&gt;      let svg = div.firstChild;&lt;/span&gt;
&lt;span class="pl-s"&gt;      content.appendChild(svg);&lt;/span&gt;
&lt;span class="pl-s"&gt;      content.style.position = 'relative';&lt;/span&gt;
&lt;span class="pl-s"&gt;      svg.style.position = 'absolute';&lt;/span&gt;
&lt;span class="pl-s"&gt;      // Give the menu time to finish fading in&lt;/span&gt;
&lt;span class="pl-s"&gt;      setTimeout(() =&amp;gt; {&lt;/span&gt;
&lt;span class="pl-s"&gt;        // Position arrow pointing to the 'facet by this' menu item&lt;/span&gt;
&lt;span class="pl-s"&gt;        var pos = document.querySelector('.dropdown-facet').getBoundingClientRect();&lt;/span&gt;
&lt;span class="pl-s"&gt;        svg.style.left = (pos.left - pos.width) + 'px';&lt;/span&gt;
&lt;span class="pl-s"&gt;        svg.style.top = (pos.top - 20) + 'px';&lt;/span&gt;
&lt;span class="pl-s"&gt;        resolve();&lt;/span&gt;
&lt;span class="pl-s"&gt;      }, 1000);&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-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;annotated-screenshot.png&lt;/span&gt;
  &lt;span class="pl-ent"&gt;selector&lt;/span&gt;: &lt;span class="pl-s"&gt;.content&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And ran this command to generate the screenshot:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper multi shot.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The generated &lt;code&gt;annotated-screenshot.png&lt;/code&gt; image looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/annotated-screenshot.png" alt="A screenshot of the table with the menu open and a single pink arrow pointing to the 'facet by this' menu item" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I'm pretty happy with this! I think it works very well as a proof of concept for the process.&lt;/p&gt;
&lt;h4 id="how-it-works-playwright"&gt;How it works: Playwright&lt;/h4&gt;
&lt;p&gt;I built the &lt;a href="https://github.com/simonw/shot-scraper/tree/44995cd45ca6c56d34c5c3d131217f7b9170f6f7"&gt;first prototype&lt;/a&gt; of &lt;code&gt;shot-scraper&lt;/code&gt; using Puppeteer, because I had &lt;a href="https://simonwillison.net/2020/Sep/3/weeknotes-airtable-screenshots-dogsheep/"&gt;used that before&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Then I noticed that the &lt;a href="https://www.npmjs.com/package/puppeteer-cli"&gt;puppeteer-cli&lt;/a&gt; package I was using hadn't had an update in two years, which reminded me to check out Playwright.&lt;/p&gt;
&lt;p&gt;I've been looking for an excuse to learn &lt;a href="https://playwright.dev/"&gt;Playwright&lt;/a&gt; for a while now, and this project turned out to be ideal.&lt;/p&gt;
&lt;p&gt;Playwright is Microsoft's open source browser automation framework. They promote it as a testing tool, but it has plenty of applications outside of testing - screenshot automation and screen scraping being two of the most obvious.&lt;/p&gt;
&lt;p&gt;Playwright is comprehensive: it downloads its own custom browser builds, and can run tests across multiple different rendering engines.&lt;/p&gt;
&lt;p&gt;The second prototype used the &lt;a href="https://github.com/simonw/shot-scraper/tree/b3318b2f27ca1526d5a9f06de50cf9900dd4d8d0"&gt;Playwright CLI utility&lt;/a&gt; instead, &lt;a href="https://github.com/simonw/shot-scraper/blob/b3318b2f27ca1526d5a9f06de50cf9900dd4d8d0/shot_scraper/cli.py#L39-L50"&gt;executed via npx&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;subprocess&lt;/span&gt;.&lt;span class="pl-en"&gt;run&lt;/span&gt;(
    [
        &lt;span class="pl-s"&gt;"npx"&lt;/span&gt;,
        &lt;span class="pl-s"&gt;"playwright"&lt;/span&gt;,
        &lt;span class="pl-s"&gt;"screenshot"&lt;/span&gt;,
        &lt;span class="pl-s"&gt;"--full-page"&lt;/span&gt;,
        &lt;span class="pl-s1"&gt;url&lt;/span&gt;,
        &lt;span class="pl-s1"&gt;output&lt;/span&gt;,
    ],
    &lt;span class="pl-s1"&gt;capture_output&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;,
)&lt;/pre&gt;
&lt;p&gt;This could take a full page screenshot, but that CLI tool wasn't flexible enough to take screenshots of specific elements. So I needed to switch to the Playwright programmatic API.&lt;/p&gt;
&lt;p&gt;I started out trying to get Python to generate and pass JavaScript to the Node.js library... and then I spotted the official &lt;a href="https://playwright.dev/python/docs/intro"&gt;Playwright for Python&lt;/a&gt; package.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install playwright
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It's amazing! It has the exact same functionality as the JavaScript library - the same classes, the same methods. Everything just works, in both languages.&lt;/p&gt;
&lt;p&gt;I was curious how they pulled this off, so I dug inside the &lt;code&gt;playwright&lt;/code&gt; Python package in my &lt;code&gt;site-packages&lt;/code&gt; folder... and found it bundles a full Node.js binary executable and uses it to bridge the two worlds! What a wild hack.&lt;/p&gt;
&lt;p&gt;Thanks to Playwright, the entire implementation of &lt;code&gt;shot-scraper&lt;/code&gt; is currently just &lt;a href="https://github.com/simonw/shot-scraper/blob/0.3/shot_scraper/cli.py"&gt;181 lines of Python code&lt;/a&gt; - it's all glue code tying together a &lt;a href="https://click.palletsprojects.com/"&gt;Click&lt;/a&gt; CLI interface with some code that calls Playwright to do the actual work.&lt;/p&gt;
&lt;p&gt;I couldn't be more impressed with Playwright. I'll definitely be using it for other projects - for one thing, I think I'll finally be able to add automated tests to my &lt;a href="https://datasette.io/desktop"&gt;Datasette Desktop&lt;/a&gt; Electron application.&lt;/p&gt;
&lt;h4&gt;Hooking shot-scraper up to GitHub Actions&lt;/h4&gt;
&lt;p&gt;I built &lt;code&gt;shot-scraper&lt;/code&gt; very much with GitHub Actions in mind.&lt;/p&gt;
&lt;p&gt;My &lt;a href="https://github.com/simonw/shot-scraper-demo"&gt;shot-scraper-demo&lt;/a&gt; repository is my first live demo of the tool.&lt;/p&gt;
&lt;p&gt;Once a day, it runs &lt;a href="https://github.com/simonw/shot-scraper-demo/blob/3fdd9d3e79f95d9d396aeefd5bf65e85a7700ef4/.github/workflows/shots.yml"&gt;this shots.yml&lt;/a&gt; file, generates two screenshots and commits them back to the repository.&lt;/p&gt;
&lt;p&gt;One of them is the tutorial screenshot described above.&lt;/p&gt;
&lt;p&gt;The other is a screenshot of the list of "recently spotted owls" from &lt;a href="https://www.owlsnearme.com/?place=127871"&gt;this page&lt;/a&gt; on &lt;a href="https://www.owlsnearme.com/"&gt;owlsnearme.com&lt;/a&gt;. I wanted a page that would change on an occasional basis, to demonstrate GitHub's neat image diffing interface.&lt;/p&gt;
&lt;p&gt;I may need to change that demo though! That page includes "spotted 5 hours ago" text, which means that there's almost always a tiny pixel difference, &lt;a href="https://github.com/simonw/shot-scraper-demo/commit/bc86510f49b6f8d6728c9f1880b999c83361dd5a#diff-897c3444fbbb2033cbba5840da4994d01c3f396e0cdf4b0613d7f410db9887e0"&gt;like this one&lt;/a&gt; (use the "swipe" comparison tool to watch 6 hours ago change to 7 hours ago under the top left photo).&lt;/p&gt;
&lt;p&gt;Storing image files that change frequently in a free repository on GitHub feels rude to me, so please use this tool cautiously there!&lt;/p&gt;
&lt;h4&gt;What's next?&lt;/h4&gt;
&lt;p&gt;I had ambitious plans to add utilities to the tool that would &lt;a href="https://github.com/simonw/shot-scraper/issues/9"&gt;help with annotations&lt;/a&gt;, such as adding pink arrows and drawing circles around different elements on the page.&lt;/p&gt;
&lt;p&gt;I've shelved those plans for the moment: as the demo above shows, the JavaScript hook is good enough. I may revisit this later once common patterns have started to emerge.&lt;/p&gt;
&lt;p&gt;So really, my next step is to start using this tool for my own projects - to generate screenshots for my documentation.&lt;/p&gt;
&lt;p&gt;I'm also very interested to see what kinds of things other people use this for.&lt;/p&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/documentation"&gt;documentation&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/scraping"&gt;scraping&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&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/puppeteer"&gt;puppeteer&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/playwright"&gt;playwright&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/shot-scraper"&gt;shot-scraper&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="cli"/><category term="documentation"/><category term="projects"/><category term="scraping"/><category term="yaml"/><category term="github-actions"/><category term="git-scraping"/><category term="puppeteer"/><category term="playwright"/><category term="shot-scraper"/></entry><entry><title>yaml-to-sqlite 1.0</title><link href="https://simonwillison.net/2021/Jun/13/yaml-to-sqlite/" rel="alternate"/><published>2021-06-13T04:45:52+00:00</published><updated>2021-06-13T04:45:52+00:00</updated><id>https://simonwillison.net/2021/Jun/13/yaml-to-sqlite/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/yaml-to-sqlite/releases/tag/1.0"&gt;yaml-to-sqlite 1.0&lt;/a&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/yaml"&gt;yaml&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="sqlite"/><category term="yaml"/></entry><entry><title>yaml-to-sqlite 0.3.1</title><link href="https://simonwillison.net/2020/Dec/7/yaml-to-sqlite/" rel="alternate"/><published>2020-12-07T21:12:55+00:00</published><updated>2020-12-07T21:12:55+00:00</updated><id>https://simonwillison.net/2020/Dec/7/yaml-to-sqlite/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/yaml-to-sqlite/releases/tag/0.3.1"&gt;yaml-to-sqlite 0.3.1&lt;/a&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/yaml"&gt;yaml&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="sqlite"/><category term="yaml"/></entry><entry><title>Controlling the style of dumped YAML using PyYAML</title><link href="https://simonwillison.net/2020/Dec/7/style-yaml-dump/" rel="alternate"/><published>2020-12-07T20:48:39+00:00</published><updated>2020-12-07T20:48:39+00:00</updated><id>https://simonwillison.net/2020/Dec/7/style-yaml-dump/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;TIL:&lt;/strong&gt; &lt;a href="https://til.simonwillison.net/python/style-yaml-dump"&gt;Controlling the style of dumped YAML using PyYAML&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="yaml"/></entry><entry><title>datasette-yaml 0.1.1</title><link href="https://simonwillison.net/2020/Dec/3/datasette-yaml/" rel="alternate"/><published>2020-12-03T00:09:35+00:00</published><updated>2020-12-03T00:09:35+00:00</updated><id>https://simonwillison.net/2020/Dec/3/datasette-yaml/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/datasette-yaml/releases/tag/0.1.1"&gt;datasette-yaml 0.1.1&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="yaml"/><category term="datasette"/></entry><entry><title>datasette-yaml 0.1</title><link href="https://simonwillison.net/2020/Sep/14/datasette-yaml/" rel="alternate"/><published>2020-09-14T22:13:36+00:00</published><updated>2020-09-14T22:13:36+00:00</updated><id>https://simonwillison.net/2020/Sep/14/datasette-yaml/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/datasette-yaml/releases/tag/0.1"&gt;datasette-yaml 0.1&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="yaml"/><category term="datasette"/></entry><entry><title>Weeknotes: airtable-export, generating screenshots in GitHub Actions, Dogsheep!</title><link href="https://simonwillison.net/2020/Sep/3/weeknotes-airtable-screenshots-dogsheep/" rel="alternate"/><published>2020-09-03T23:28:29+00:00</published><updated>2020-09-03T23:28:29+00:00</updated><id>https://simonwillison.net/2020/Sep/3/weeknotes-airtable-screenshots-dogsheep/</id><summary type="html">
    &lt;p&gt;This week I figured out how to populate Datasette from Airtable, wrote code to generate social media preview card page screenshots using Puppeteer, and made a big breakthrough with my Dogsheep project.&lt;/p&gt;
&lt;h4 id="weeknotes-2020-09-03-airtable-export"&gt;airtable-export&lt;/h4&gt;
&lt;p&gt;I wrote about &lt;a href="https://www.rockybeaches.com/"&gt;Rocky Beaches&lt;/a&gt; in my weeknotes &lt;a href="https://simonwillison.net/2020/Aug/21/weeknotes-rocky-beaches/"&gt;two weeks ago&lt;/a&gt;. It's a new website built by Natalie Downe that showcases great places to go rockpooling (tidepooling in American English), mixing in tide data from NOAA and species sighting data from iNaturalist.&lt;/p&gt;
&lt;p&gt;Rocky Beaches is powered by Datasette, using a GitHub Actions workflow that builds the site's underlying SQLite database using API calls and YAML data stored in the GitHub repository.&lt;/p&gt;
&lt;p&gt;Natalie wanted to use Airtable to maintain the structured data for the site, rather than hand-editing a YAML file. So I built &lt;a href="https://github.com/simonw/airtable-export"&gt;airtable-export&lt;/a&gt;, a command-line script for sucking down all of the data from an Airtable instance and writing it to disk as YAML or JSON.&lt;/p&gt;
&lt;p&gt;You run it like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;airtable-export out/ mybaseid table1 table2 --key=key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will create a folder called &lt;code&gt;out/&lt;/code&gt; with a &lt;code&gt;.yml&lt;/code&gt; file for each of the tables.&lt;/p&gt;
&lt;p&gt;Sadly the Airtable API doesn't yet provide a mechanism to list all of the tables in a database (a &lt;a href="https://community.airtable.com/t/list-tables-given-api-key-and-baseid/1173"&gt;long-running feature request&lt;/a&gt;) so you have to list the tables yourself.&lt;/p&gt;
&lt;p&gt;We're now &lt;a href="https://github.com/natbat/rockybeaches/blob/32a010292e7c1ba47db1a86523a61c666d977074/.github/workflows/deploy.yml#L31-L44"&gt;running that command&lt;/a&gt; as part of the Rocky Beaches build script, and committing the latest version of the YAML file back to the GitHub repo (thus gaining a &lt;a href="https://github.com/natbat/rockybeaches/commits/main/airtable"&gt;full change history&lt;/a&gt; for that data).&lt;/p&gt;
&lt;h4 id="weeknotes-2020-09-03-social-media-cards-tils"&gt;Social media cards for my TILs&lt;/h4&gt;
&lt;p&gt;I really like social media cards - &lt;code&gt;og:image&lt;/code&gt; HTML meta attributes for Facebook and &lt;code&gt;twitter:image&lt;/code&gt; for Twitter. I wanted them for articles on my &lt;a href="https://til.simonwillison.net/"&gt;TIL website&lt;/a&gt; since I often share those via Twitter.&lt;/p&gt;
&lt;p&gt;One catch: my TILs aren't very image heavy. So I decided to generate screenshots of the pages and use those as the 2x1 social media card images.&lt;/p&gt;
&lt;p&gt;The best way I know of programatically generating screenshots is to use &lt;a href="https://developers.google.com/web/tools/puppeteer"&gt;Puppeteer&lt;/a&gt;, a Node.js library for automating a headless instance of the Chrome browser that is maintained by the Chrome DevTools team.&lt;/p&gt;
&lt;p&gt;My first attempt was to run Puppeteer in an AWS Lambda function on &lt;a href="https://vercel.com/"&gt;Vercel&lt;/a&gt;. I remembered seeing an example of how to do this in the Vercel documentation a few years ago. The example isn't there any more, but I found the &lt;a href="https://github.com/vercel/now-examples/pull/207"&gt;original pull request&lt;/a&gt; that introduced it.&lt;/p&gt;
&lt;p&gt;Since the example was MIT licensed I created my own fork at &lt;a href="https://github.com/simonw/puppeteer-screenshot"&gt;simonw/puppeteer-screenshot&lt;/a&gt; and updated it to work with the latest Chrome.&lt;/p&gt;
&lt;p&gt;It's pretty resource intensive, so I also added a secret &lt;code&gt;?key=&lt;/code&gt; mechanism so only my own automation code could call my instance running on Vercel.&lt;/p&gt;
&lt;p&gt;I needed to store the generated screenshots somewhere. They're pretty small - on the order of 60KB each - so I decided to store them in my SQLite database itself and use my &lt;a href="https://github.com/simonw/datasette-media"&gt;datasette-media&lt;/a&gt; plugin (see &lt;a href="https://simonwillison.net/2020/Jul/30/fun-binary-data-and-sqlite/"&gt;Fun with binary data and SQLite&lt;/a&gt;) to serve them up.&lt;/p&gt;
&lt;p&gt;This worked! Until it didn't... I ran into a showstopper bug when I realized that the screenshot process relies on the page being live on the site... but when a new article is added it's not live when the build process works, so the generated screenshot &lt;a href="https://github.com/simonw/til/issues/23"&gt;is of the 404 page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;So I reworked it to generate the screenshots inside the GitHub Action as part of the build script, using &lt;a href="https://github.com/JarvusInnovations/puppeteer-cli"&gt;puppeteer-cli&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;My &lt;a href="https://github.com/simonw/til/blob/3fca996228ad54ee433b25840fcd3682e9f7bbfd/generate_screenshots.py"&gt;generate_screenshots.py&lt;/a&gt; script handles this, by first shelling out to &lt;code&gt;datasette --get&lt;/code&gt; to render the HTML for the page, then running &lt;code&gt;puppeteer&lt;/code&gt; to generate the screenshot. Relevant code:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;png_for_path&lt;/span&gt;(&lt;span class="pl-s1"&gt;path&lt;/span&gt;):
    &lt;span class="pl-c"&gt;# Path is e.g. /til/til/python_debug-click-with-pdb.md&lt;/span&gt;
    &lt;span class="pl-s1"&gt;page_html&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;str&lt;/span&gt;(&lt;span class="pl-v"&gt;TMP_PATH&lt;/span&gt; &lt;span class="pl-c1"&gt;/&lt;/span&gt; &lt;span class="pl-s"&gt;"generate-screenshots-page.html"&lt;/span&gt;)
    &lt;span class="pl-c"&gt;# Use datasette to generate HTML&lt;/span&gt;
    &lt;span class="pl-s1"&gt;proc&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;subprocess&lt;/span&gt;.&lt;span class="pl-en"&gt;run&lt;/span&gt;([&lt;span class="pl-s"&gt;"datasette"&lt;/span&gt;, &lt;span class="pl-s"&gt;"."&lt;/span&gt;, &lt;span class="pl-s"&gt;"--get"&lt;/span&gt;, &lt;span class="pl-s1"&gt;path&lt;/span&gt;], &lt;span class="pl-s1"&gt;capture_output&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
    &lt;span class="pl-en"&gt;open&lt;/span&gt;(&lt;span class="pl-s1"&gt;page_html&lt;/span&gt;, &lt;span class="pl-s"&gt;"wb"&lt;/span&gt;).&lt;span class="pl-en"&gt;write&lt;/span&gt;(&lt;span class="pl-s1"&gt;proc&lt;/span&gt;.&lt;span class="pl-s1"&gt;stdout&lt;/span&gt;)
    &lt;span class="pl-c"&gt;# Now use puppeteer screenshot to generate a PNG&lt;/span&gt;
    &lt;span class="pl-s1"&gt;proc2&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;subprocess&lt;/span&gt;.&lt;span class="pl-en"&gt;run&lt;/span&gt;(
        [
            &lt;span class="pl-s"&gt;"puppeteer"&lt;/span&gt;,
            &lt;span class="pl-s"&gt;"screenshot"&lt;/span&gt;,
            &lt;span class="pl-s1"&gt;page_html&lt;/span&gt;,
            &lt;span class="pl-s"&gt;"--viewport"&lt;/span&gt;,
            &lt;span class="pl-s"&gt;"800x400"&lt;/span&gt;,
            &lt;span class="pl-s"&gt;"--full-page=false"&lt;/span&gt;,
        ],
        &lt;span class="pl-s1"&gt;capture_output&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;,
    )
    &lt;span class="pl-s1"&gt;png_bytes&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;proc2&lt;/span&gt;.&lt;span class="pl-s1"&gt;stdout&lt;/span&gt;
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;png_bytes&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This worked great! Except for one thing... the site is hosted on Vercel, and Vercel has a 5MB &lt;a href="https://vercel.com/docs/platform/limits#serverless-function-payload-size-limit"&gt;response size limit&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Every time my GitHub build script runs it downloads the previous SQLite database file, so it can avoid regenerating screenshots and HTML for pages that haven't changed.&lt;/p&gt;
&lt;p&gt;The addition of the binary screenshots drove the size of the SQLite database over 5MB, so the part of my script that retrieved the previous database &lt;a href="https://github.com/simonw/til/issues/25"&gt;no longer worked&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I needed a reliable way to store that 5MB (and probably eventually 10-50MB) database file in between runs of my action.&lt;/p&gt;
&lt;p&gt;The best place to put this would be an S3 bucket, but I find the process of setting up IAM permissions for access to a new bucket so infuriating that I couldn't bring myself to do it.&lt;/p&gt;
&lt;p&gt;So... I created a new dedicated GitHub repository, &lt;a href="https://github.com/simonw/til-db"&gt;simonw/til-db&lt;/a&gt;, and updated my action to store the binary file in that repo - using &lt;a href="https://github.com/simonw/til/blob/1e29c3fe5e90c29b0e71d87dba805484ceb4393c/.github/workflows/build.yml#L80-L86"&gt;a force push&lt;/a&gt; so the repo doesn't need to maintain unnecessary version history of the binary asset.&lt;/p&gt;
&lt;p&gt;This is an abomination of a hack, and it made me cackle a lot. I &lt;a href="https://twitter.com/simonw/status/1301029346614718465"&gt;tweeted about it&lt;/a&gt; and got the suggestion to try &lt;a href="https://git-lfs.github.com/"&gt;Git LFS&lt;/a&gt; instead, which would definitely be a more appropriate way to solve this problem.&lt;/p&gt;
&lt;h4 id="weeknotes-2020-09-03-rendering-markdown"&gt;Rendering Markdown&lt;/h4&gt;
&lt;p&gt;I write my blog entries in Markdown and transform them into HTML before I post them on my blog. Some day I'll teach my blog to render Markdown itself, but so far I've got by through copying and pasting into Markdown tools.&lt;/p&gt;
&lt;p&gt;My favourite Markdown flavour is GitHub's, which adds a bunch of useful capabilities - most notably the ability to apply syntax highlighting. GitHub &lt;a href="https://docs.github.com/en/rest/reference/markdown"&gt;expose an API&lt;/a&gt; that applies their Markdown formatter and returns the resulting HTML.&lt;/p&gt;
&lt;p&gt;I built myself &lt;a href="https://til.simonwillison.net/tools/render-markdown"&gt;a quick and scrappy tool&lt;/a&gt; in JavaScript that sends Markdown through their API and then applies a few DOM manipulations to clean up what comes back. It was a nice opportunity to write some modern vanilla JavaScript using &lt;code&gt;fetch()&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;render&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;markdown&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-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://api.github.com/markdown'&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;method&lt;/span&gt;: &lt;span class="pl-s"&gt;'POST'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
        &lt;span class="pl-c1"&gt;headers&lt;/span&gt;: &lt;span class="pl-kos"&gt;{&lt;/span&gt;
            &lt;span class="pl-s"&gt;'Content-Type'&lt;/span&gt;: &lt;span class="pl-s"&gt;'application/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-c1"&gt;body&lt;/span&gt;: &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&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-s"&gt;'mode'&lt;/span&gt;: &lt;span class="pl-s"&gt;'markdown'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;'text'&lt;/span&gt;: &lt;span class="pl-s1"&gt;markdown&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-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-kos"&gt;}&lt;/span&gt;

&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;button&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;getElementsByTagName&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'button'&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;0&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;output&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;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'output'&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;preview&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;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'preview'&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;button&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;addEventListener&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'click'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;function&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;rendered&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-en"&gt;render&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;input&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;value&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;output&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;value&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;rendered&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;preview&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;rendered&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="weeknotes-2020-09-03-dogsheep-beta"&gt;Dogsheep Beta&lt;/h4&gt;
&lt;p&gt;My most exciting project this week was getting out the first working version of &lt;a href="https://github.com/dogsheep/beta"&gt;Dogsheep Beta&lt;/a&gt; - the search engine that ties together results from my &lt;a href="https://dogsheep.github.io/"&gt;Dogsheep&lt;/a&gt; family of tools for personal analytics.&lt;/p&gt;
&lt;p&gt;I'm giving a talk about this tonight at PyCon Australia: &lt;a href="https://2020.pycon.org.au/program/73uk8x/"&gt;Build your own data warehouse for personal analytics with SQLite and Datasette&lt;/a&gt;. I'll be writing up detailed notes in the next few days, so watch this space.&lt;/p&gt;
&lt;h4 id="weeknotes-2020-09-03-til-this-week"&gt;TIL this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/jq_reformatting-airtable-json.md"&gt;Converting Airtable JSON for use with sqlite-utils using jq&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/javascript_minifying-uglify-npx.md"&gt;Minifying JavaScript with npx uglify-js&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/pytest_subprocess-server.md"&gt;Start a server in a subprocess during a pytest session&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/bash_loop-over-csv.md"&gt;Looping over comma-separated values in Bash&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/cloudrun_gcloud-run-services-list.md"&gt;Using the gcloud run services list command&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/python_debug-click-with-pdb.md"&gt;Debugging a Click application using pdb&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="weeknotes-2020-09-03-releases-this-week"&gt;Releases this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.4.1"&gt;dogsheep-beta 0.4.1&lt;/a&gt; - 2020-09-03&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.4"&gt;dogsheep-beta 0.4&lt;/a&gt; - 2020-09-03&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.4a1"&gt;dogsheep-beta 0.4a1&lt;/a&gt; - 2020-09-03&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.4a0"&gt;dogsheep-beta 0.4a0&lt;/a&gt; - 2020-09-03&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.3"&gt;dogsheep-beta 0.3&lt;/a&gt; - 2020-09-02&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.2"&gt;dogsheep-beta 0.2&lt;/a&gt; - 2020-09-01&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.1"&gt;dogsheep-beta 0.1&lt;/a&gt; - 2020-09-01&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.1a2"&gt;dogsheep-beta 0.1a2&lt;/a&gt; - 2020-09-01&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.1a"&gt;dogsheep-beta 0.1a&lt;/a&gt; - 2020-09-01&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.4"&gt;airtable-export 0.4&lt;/a&gt; - 2020-08-30&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/datasette-yaml/releases/tag/0.1a"&gt;datasette-yaml 0.1a&lt;/a&gt; - 2020-08-29&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.3.1"&gt;airtable-export 0.3.1&lt;/a&gt; - 2020-08-29&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.3"&gt;airtable-export 0.3&lt;/a&gt; - 2020-08-29&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.2"&gt;airtable-export 0.2&lt;/a&gt; - 2020-08-29&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.1.1"&gt;airtable-export 0.1.1&lt;/a&gt; - 2020-08-29&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.1"&gt;airtable-export 0.1&lt;/a&gt; - 2020-08-29&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/datasette/releases/tag/0.49a0"&gt;datasette 0.49a0&lt;/a&gt; - 2020-08-28&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/sqlite-utils/releases/tag/2.16.1"&gt;sqlite-utils 2.16.1&lt;/a&gt; - 2020-08-28&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/markdown"&gt;markdown&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/dogsheep"&gt;dogsheep&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&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/airtable"&gt;airtable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/puppeteer"&gt;puppeteer&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="yaml"/><category term="markdown"/><category term="dogsheep"/><category term="weeknotes"/><category term="github-actions"/><category term="airtable"/><category term="puppeteer"/></entry><entry><title>datasette-yaml 0.1a</title><link href="https://simonwillison.net/2020/Aug/29/datasette-yaml/" rel="alternate"/><published>2020-08-29T22:35:22+00:00</published><updated>2020-08-29T22:35:22+00:00</updated><id>https://simonwillison.net/2020/Aug/29/datasette-yaml/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/datasette-yaml/releases/tag/0.1a"&gt;datasette-yaml 0.1a&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="yaml"/><category term="datasette"/></entry><entry><title>airtable-export</title><link href="https://simonwillison.net/2020/Aug/29/airtable-export/" rel="alternate"/><published>2020-08-29T21:48:37+00:00</published><updated>2020-08-29T21:48:37+00:00</updated><id>https://simonwillison.net/2020/Aug/29/airtable-export/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/airtable-export"&gt;airtable-export&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I wrote a command-line utility for exporting data from Airtable and dumping it to disk as YAML, JSON or newline delimited JSON files. This means you can backup an Airtable database from a GitHub Action and get a commit history of changes made to your data.


    &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/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/airtable"&gt;airtable&lt;/a&gt;&lt;/p&gt;



</summary><category term="json"/><category term="projects"/><category term="yaml"/><category term="airtable"/></entry><entry><title>Goodbye Zeit Now v1, hello datasette-publish-now - and talking to myself in GitHub issues</title><link href="https://simonwillison.net/2020/Apr/8/weeknotes-zeit-now-v2/" rel="alternate"/><published>2020-04-08T03:32:24+00:00</published><updated>2020-04-08T03:32:24+00:00</updated><id>https://simonwillison.net/2020/Apr/8/weeknotes-zeit-now-v2/</id><summary type="html">
    &lt;p&gt;This week I’ve been mostly dealing with the finally announced shutdown of Zeit Now v1. And having long-winded conversations with myself in GitHub issues.&lt;/p&gt;

&lt;h3&gt;How Zeit Now inspired Datasette&lt;/h3&gt;

&lt;p&gt;I first started experiencing with Zeit’s serverless &lt;a href="https://zeit.co/home"&gt;Now&lt;/a&gt; hosting platform back &lt;a href="https://simonwillison.net/2017/Oct/14/async-python-sanic-now/"&gt;in October 2017&lt;/a&gt;, when I used it to deploy &lt;a href="https://json-head.now.sh/"&gt;json-head.now.sh&lt;/a&gt; - an updated version of an API tool I originally built for Google App Engine &lt;a href="https://simonwillison.net/2008/Jul/29/jsonhead/"&gt;in July 2008&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I liked Zeit Now, a lot. Instant, inexpensive deploys of any stateless project that could be defined using a Dockerfile? Just type &lt;code&gt;now&lt;/code&gt; to deploy the project in your current directory? Every deployment gets its own permanent URL? Amazing!&lt;/p&gt;

&lt;p&gt;There was just one catch: Since Now deployments are ephemeral applications running on them need to be stateless. If you want a database, you need to involve another (potentially costly) service. It's a limitation shared by other scalable hosting solutions - Heroku, App Engine and so on. How much interesting stuff can you build without a database?&lt;/p&gt;

&lt;p&gt;I was musing about this in the shower one day (that &lt;a href="https://lifehacker.com/science-explains-why-our-best-ideas-come-in-the-shower-5987858"&gt;old cliche&lt;/a&gt; really happened for me) when I had a thought: sure, you can't write to a database... but if your data is read-only, why not bundle the database alongside the application code as part of the Docker image?&lt;/p&gt;

&lt;p&gt;Ever since I &lt;a href="https://simonwillison.net/2009/Mar/10/openplatform/"&gt;helped launch the Datablog&lt;/a&gt; at the Guardian back in 2009 I had been interested in finding better ways to publish data journalism datasets than CSV files or a Google spreadsheets - so building something that could package and bundle read-only data was of extreme interest to me.&lt;/p&gt;

&lt;p&gt;In November 2017 I released &lt;a href="https://simonwillison.net/2017/Nov/13/datasette/"&gt;the first version&lt;/a&gt; of Datasette. The original idea was very much inspired by Zeit Now.&lt;/p&gt;

&lt;p&gt;I gave &lt;a href="https://www.youtube.com/watch?v=_uwrqB--eM4"&gt;a talk about Datasette&lt;/a&gt; at the Zeit Day conference in San Francisco in April 2018. Suffice to say I was a huge fan!&lt;/p&gt;

&lt;h3&gt;Goodbye, Zeit Now v1&lt;/h3&gt;

&lt;p&gt;In November 2018, Zeit &lt;a href="https://simonwillison.net/2018/Nov/19/smaller-python-docker-images/"&gt;announced Now v2&lt;/a&gt;. And it was, &lt;em&gt;different&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;v2 is an entirely different architecture from v1. Where v1 built on Docker containers, v2 is built on top of serverless functions - AWS Lambda in particular.&lt;/p&gt;

&lt;p&gt;I can see why Zeit did this. Lambda functions can launch from cold &lt;em&gt;way faster&lt;/em&gt; - v1's Docker infrastructure had tough cold-start times. They are much cheaper to run as well - crucial for Zeit given their &lt;a href="https://zeit.co/pricing"&gt;extremely generous pricing plans&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But it was bad news for my projects. Lambdas are tightly size constrained, which is tough when you're bundling potentially large SQLite database files with your deployments.&lt;/p&gt;

&lt;p&gt;More importantly, in 2018 Amazon were deliberately excluding the Python &lt;code&gt;sqlite3&lt;/code&gt; standard library module from the Python Lambda environment! I guess they hadn't considered people who might want to work with read-only database files.&lt;/p&gt;

&lt;p&gt;So Datasette on Now v2 just wasn't going to work. Zeit kept v1 supported for the time being, but the writing was clearly on the wall.&lt;/p&gt;

&lt;p&gt;In April 2019 &lt;a href="https://cloud.google.com/blog/products/serverless/announcing-cloud-run-the-newest-member-of-our-serverless-compute-stack"&gt;Google announced Cloud Run&lt;/a&gt;, a serverless, scale-to-zero hosting environment based around Docker containers. In many ways it's Google's version of Zeit Now v1 - it has many of the characteristics I loved about v1, albeit with a clunkier developer experience and much more friction in assigning nice URLs to projects. Romain Primet &lt;a href="https://github.com/simonw/datasette/pull/434"&gt;contributed Cloud Run support to Datasette&lt;/a&gt; and it has since become my preferred hosting target for my new projects (see &lt;a href="https://simonwillison.net/2020/Jan/21/github-actions-cloud-run/"&gt;Deploying a data API using GitHub Actions and Cloud Run&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Last week, Zeit &lt;a href="https://twitter.com/simonw/status/1246300304917680128"&gt;finally announced&lt;/a&gt; the sunset date for v1. From 1st of May new deploys won't be allowed, and on the 7th of August they'll be turning off the old v1 infrastructure and deleting all existing Now v1 deployments.&lt;/p&gt;

&lt;p&gt;I engaged in &lt;a href="https://twitter.com/simonw/status/1246300304917680128"&gt;an extensive Twitter conversation&lt;/a&gt; about this, where I praised Zeit's handling of the shutdown while bemoaning the loss of the v1 product I had loved so much.&lt;/p&gt;

&lt;h3 id="migrating-my-projects"&gt;Migrating my projects&lt;/h3&gt;

&lt;p&gt;My newer projects have been on Cloud Run for quite some time, but I still have a bunch of old projects that I care about and want to keep running past the v1 shutdown.&lt;/p&gt;

&lt;p&gt;The first project I ported was &lt;a href="https://latest.datasette.io/"&gt;latest.datasette.io&lt;/a&gt;, a live demo of Datasette which updates with the latest code any time I push to the Datasette master branch on GitHub.&lt;/p&gt;

&lt;p&gt;Any time I do some kind of ops task like this I've gotten into the habit of meticulously documenting every single step in comments on a GitHub issue. Here's &lt;a href="https://github.com/simonw/datasette/issues/705"&gt;the issue&lt;/a&gt; for porting latest.datasette.io to Cloud Run (and switching from Circle CI to GitHub Actions at the same time).&lt;/p&gt;

&lt;p&gt;My next project was &lt;a href="https://global-power-plants.datasettes.com/global-power-plants/global-power-plants"&gt;global-power-plants-datasette&lt;/a&gt;, a small project which takes a database of global power plants &lt;a href="https://www.wri.org/publication/global-power-plant-database"&gt;published by the World Resources Institute&lt;/a&gt; and publishes it using Datasette. It checks for new updates to &lt;a href="https://github.com/wri/global-power-plant-database"&gt;their repo&lt;/a&gt; once a day. I originally built it as a demo for &lt;a href="https://github.com/simonw/datasette-cluster-map"&gt;datasette-cluster-map&lt;/a&gt;, since it's fun seeing 33,000 power plants on a single map. Here's &lt;a href="https://github.com/simonw/global-power-plants-datasette/issues/1"&gt;that issue&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Having warmed up with these two, my next target was the most significant: porting my &lt;a href="https://www.niche-museums.com/"&gt;Niche Museums&lt;/a&gt; website.&lt;/p&gt;

&lt;p&gt;Niche Museums is the most heavily customized Datasette instance I've run anywhere - it incorporates custom templates, CSS and plugins.&lt;/p&gt;

&lt;p&gt;Here's &lt;a href="https://github.com/simonw/museums/issues/20"&gt;the tracking issue&lt;/a&gt; for porting it to Cloud Run. I ran into a few hurdles with DNS and TLS certificates, and I had to do &lt;a href="https://github.com/simonw/museums/issues/21"&gt;some additional work&lt;/a&gt; to ensure &lt;code&gt;niche-museums.com&lt;/code&gt; redirects to &lt;code&gt;www.niche-musums.com&lt;/code&gt;, but it's now fully migrated.&lt;/p&gt;

&lt;h3 id="hello-zeit-now-v2"&gt;Hello, Zeit Now v2&lt;/h3&gt;

&lt;p&gt;In &lt;a href="https://twitter.com/simonw/status/1246302021608591360"&gt;complaining about&lt;/a&gt; the lack of that essential &lt;code&gt;sqlite3&lt;/code&gt; module I figured it would be responsible to double-check and make sure that was still true.&lt;/p&gt;

&lt;p&gt;It was not! Today Now's Python environment &lt;a href="https://twitter.com/simonw/status/1246600935289184256"&gt;includes sqlite3&lt;/a&gt; after all.&lt;/p&gt;

&lt;p&gt;Datasette's &lt;a href="https://datasette.readthedocs.io/en/0.39/plugins.html#publish-subcommand-publish"&gt;publish_subcommand() plugin hook&lt;/a&gt; lets plugins add new publishing targets to the &lt;code&gt;datasette publish&lt;/code&gt; command (I used it to build &lt;a href="https://github.com/simonw/datasette-publish-fly"&gt;datasette-publish-fly&lt;/a&gt; last month). How hard would it be to build a plugin for Zeit Now v2?&lt;/p&gt;

&lt;p&gt;I fired up a new &lt;a href="https://github.com/simonw/datasette/issues/717"&gt;lengthy talking-to-myself GitHub issue&lt;/a&gt; and started prototyping.&lt;/p&gt;

&lt;p&gt;Now v2 may not support Docker, but it does support the &lt;a href="https://asgi.readthedocs.io/en/latest/"&gt;ASGI Python standard&lt;/a&gt; (the asynchronous alternative to WSGI, shepherded by Andrew Godwin).&lt;/p&gt;

&lt;p&gt;Zeit are keen proponents of the &lt;a href="https://jamstack.org/"&gt;Jamstack&lt;/a&gt; approach, where websites are built using static pre-rendered HTML and JavaScript that calls out to APIs for dynamic data. v2 deployments are expected to consist of static HTML with "serverless functions" - standalone server-side scripts that live in an &lt;code&gt;api/&lt;/code&gt; directory by convention and are compiled into separate lambdas.&lt;/p&gt;

&lt;p&gt;Datasette works just fine without JavaScript, which means it needs to handle all of the URL routes for a site. Essentually I need to build a single function that runs the whole of Datasette, then route all incoming traffic to it.&lt;/p&gt;

&lt;p&gt;It took me a while to figure it out, but it turns out the Now v2 recipe for that is a &lt;code&gt;now.json&lt;/code&gt; file that looks like this:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
    "version": 2,
    "builds": [
        {
            "src": "index.py",
            "use": "@now/python"
        }
    ],
    "routes": [
        {
            "src": "(.*)",
            "dest": "index.py"
        }
    ]
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Thanks Aaron Boodman for &lt;a href="https://twitter.com/aboodman/status/1246605658067066882"&gt;the tip&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Given the above configuration, Zeit will install any Python dependencies in a &lt;code&gt;requirements.txt&lt;/code&gt; file, then treat an &lt;code&gt;app&lt;/code&gt; variable in the &lt;code&gt;index.py&lt;/code&gt; file as an ASGI application it should route all incoming traffic to. Exactly what I need to deploy Datasette!&lt;/p&gt;

&lt;p&gt;This was everything I needed to build the new plugin. &lt;a href="https://github.com/simonw/datasette-publish-now"&gt;datasette-publish-now&lt;/a&gt; is the result.&lt;/p&gt;

&lt;p&gt;Here's &lt;a href="https://datasette-public.now.sh/_src"&gt;the generated source code&lt;/a&gt; for a project deployed using the plugin, showing how the underlyinng ASGI application is configured.&lt;/p&gt;

&lt;p&gt;It's currently an alpha - not every feature is supported (see &lt;a href="https://github.com/simonw/datasette-publish-now/milestone/1"&gt;this milestone&lt;/a&gt;) and it relies on a minor deprecated feature (which I've &lt;a href="https://github.com/zeit/now/discussions/4021"&gt;implored Zeit to reconsider&lt;/a&gt;) but it's already full-featured enough that I can start using it to upgrade some of my smaller existing Now projects.&lt;/p&gt;

&lt;p&gt;The first I upgraded is one of my favourites: &lt;a href="https://polar-bears.now.sh/"&gt;polar-bears.now.sh&lt;/a&gt;, which visualizes tracking data from polar bear ear tags (using &lt;a href="https://github.com/simonw/datasette-cluster-map"&gt;datasette-cluster-map&lt;/a&gt;) that was &lt;a href="https://alaska.usgs.gov/products/data.php?dataid=130"&gt;published by the USGS Alaska Science Center, Polar Bear Research Program&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here's the command I used to deploy the site:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$ pip install datasette-publish-now
$ datasette publish now2 polar-bears.db \
    --title "Polar Bear Ear Tags, 2009-2011" \
    --source "USGS Alaska Science Center, Polar Bear Research Program" \
    --source_url "https://alaska.usgs.gov/products/data.php?dataid=130" \
    --install datasette-cluster-map \
    --project=polar-bears&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I exported a full list of my Now v1 projects from their handy &lt;a href="https://zeit.co/dashboard/active-v1-instances"&gt;active v1 instances&lt;/a&gt; page.&lt;/p&gt;

&lt;h3&gt;The rest of my projects&lt;/h3&gt;

&lt;p&gt;I scraped the page using the following JavaScript, constructed with the help of the &lt;a href="https://simonwillison.net/2020/Apr/7/new-developer-features-firefox-75/"&gt;instant evaluation&lt;/a&gt; console feature in Firefox 75:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;console.log(
  JSON.stringify(
    Array.from(
      Array.from(
        document.getElementsByTagName("table")[1].
          getElementsByTagName("tr")
      ).slice(1).map(
        (tr) =&amp;gt;
          Array.from(
            tr.getElementsByTagName("td")
        ).map((td) =&amp;gt; td.innerText)
      )
    )
  )
);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then I loaded them into Datasette for analysis.&lt;/p&gt;

&lt;p&gt;After filtering out the &lt;code&gt;datasette-latest-commithash.now.sh&lt;/code&gt; projects I had deployed for every push to GitHub it turns out I have 34 distinct projects running there.&lt;/p&gt;

&lt;p&gt;I won't port all of them, but given &lt;code&gt;datasette-publish-now&lt;/code&gt; I should be able to port the ones that I care about without too much trouble.&lt;/p&gt;

&lt;h3 id="git-bisect"&gt;Debugging Datasette with git bisect run&lt;/h3&gt;

&lt;p&gt;I fixed two bugs in Datasette this week using &lt;code&gt;git bisect run&lt;/code&gt; - a tool I've been meaning to figure out for years, which lets you run an automated binary search against a commit log to find the source of a bug.&lt;/p&gt;

&lt;p&gt;Since I was figuring out a new tool, I fired up another GitHub issue self-conversation: in &lt;a href="https://github.com/simonw/datasette/issues/716"&gt;issue #716&lt;/a&gt; I document my process of both learning to use &lt;code&gt;git bisect run&lt;/code&gt; and using it to find a solution to that particular bug.&lt;/p&gt;

&lt;p&gt;It worked great, so I used the same trick on &lt;a href="https://github.com/simonw/datasette/issues/689"&gt;issue 689&lt;/a&gt; as well.&lt;/p&gt;

&lt;p&gt;Watching &lt;code&gt;git bisect run&lt;/code&gt; churn through 32 revisions in a few seconds and pinpoint the exact moment a bug was introduced is pretty delightful:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$ git bisect start master 0.34
Bisecting: 32 revisions left to test after this (roughly 5 steps)
[dc80e779a2e708b2685fc641df99e6aae9ad6f97] Handle scope path if it is a string
$ git bisect run python check_templates_considered.py
running python check_templates_considered.py
Traceback (most recent call last):
...
AssertionError
Bisecting: 15 revisions left to test after this (roughly 4 steps)
[7c6a9c35299f251f9abfb03fd8e85143e4361709] Better tests for prepare_connection() plugin hook, refs #678
running python check_templates_considered.py
Traceback (most recent call last):
...
AssertionError
Bisecting: 7 revisions left to test after this (roughly 3 steps)
[0091dfe3e5a3db94af8881038d3f1b8312bb857d] More reliable tie-break ordering for facet results
running python check_templates_considered.py
Traceback (most recent call last):
...
AssertionError
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[ce12244037b60ba0202c814871218c1dab38d729] Release notes for 0.35
running python check_templates_considered.py
Traceback (most recent call last):
...
AssertionError
Bisecting: 1 revision left to test after this (roughly 1 step)
[70b915fb4bc214f9d064179f87671f8a378aa127] Datasette.render_template() method, closes #577
running python check_templates_considered.py
Traceback (most recent call last):
...
AssertionError
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[286ed286b68793532c2a38436a08343b45cfbc91] geojson-to-sqlite
running python check_templates_considered.py
70b915fb4bc214f9d064179f87671f8a378aa127 is the first bad commit
commit 70b915fb4bc214f9d064179f87671f8a378aa127
Author: Simon Willison
Date:   Tue Feb 4 12:26:17 2020 -0800

    Datasette.render_template() method, closes #577

    Pull request #664.

:040000 040000 def9e31252e056845609de36c66d4320dd0c47f8 da19b7f8c26d50a4c05e5a7f05220b968429725c M	datasette
bisect run success&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Supporting metadata.yaml&lt;/h3&gt;

&lt;p&gt;The other Datasette project I completed this week is a relatively small feature with hopefully a big impact: you can &lt;a href="https://github.com/simonw/datasette/issues/713"&gt;now use YAML for Datasette's metadata configuration&lt;/a&gt; as an alternative to JSON.&lt;/p&gt;

&lt;p&gt;I'm not crazy about YAML: I still don't feel like I've mastered it, and I've been &lt;a href="https://simonwillison.net/tags/yaml/"&gt;tracking it for 18 years&lt;/a&gt;! But it has one big advantage over JSON for configuration files: robust support for multi-line strings.&lt;/p&gt;

&lt;p&gt;Datasette's &lt;a href="https://datasette.readthedocs.io/en/latest/metadata.html"&gt;metadata file&lt;/a&gt; can include lengthy SQL statements and strings of HTML, both of which benefit from multi-line strings.&lt;/p&gt;

&lt;p&gt;I first used YAML for metadata for my &lt;a href="https://simonwillison.net/2018/Aug/6/russian-facebook-ads/"&gt;Analyzing US Election Russian Facebook Ads&lt;/a&gt; project. The &lt;a href="https://github.com/simonw/russian-ira-facebook-ads-datasette/blob/336ba87ef8071e664441ad0a95e3b8d0a33f682a/russian-ads-metadata.yaml"&gt;metadata file for that&lt;/a&gt; demonstrates both embedded HTML and embedded SQL - and an accompanying &lt;a href="https://github.com/simonw/russian-ira-facebook-ads-datasette/blob/336ba87ef8071e664441ad0a95e3b8d0a33f682a/build_metadata.py"&gt;build_metadata.py&lt;/a&gt; script converted it to JSON at build time. I've since used the same trick for a number of other projects.&lt;/p&gt;

&lt;p&gt;The next release of Datasette (hopefully within a week) will ship the new feature, at which point those conversion scripts won't be necessary.&lt;/p&gt;

&lt;p&gt;This should work particularly well with the forthcoming &lt;a href="https://github.com/simonw/datasette/issues/698"&gt;ability for a canned query to write to a database&lt;/a&gt;. Getting that wrapped up and shipped will be my focus for the next few days.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/git"&gt;git&lt;/a&gt;, &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/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/zeit-now"&gt;zeit-now&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-issues"&gt;github-issues&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="git"/><category term="github"/><category term="projects"/><category term="yaml"/><category term="zeit-now"/><category term="datasette"/><category term="weeknotes"/><category term="github-issues"/></entry><entry><title>yaml-to-sqlite 0.3</title><link href="https://simonwillison.net/2019/Nov/29/yaml-to-sqlite/" rel="alternate"/><published>2019-11-29T20:07:05+00:00</published><updated>2019-11-29T20:07:05+00:00</updated><id>https://simonwillison.net/2019/Nov/29/yaml-to-sqlite/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/yaml-to-sqlite/releases/tag/0.3"&gt;yaml-to-sqlite 0.3&lt;/a&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/yaml"&gt;yaml&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="sqlite"/><category term="yaml"/></entry><entry><title>niche-museums.com, powered by Datasette</title><link href="https://simonwillison.net/2019/Nov/25/niche-museums/" rel="alternate"/><published>2019-11-25T22:27:46+00:00</published><updated>2019-11-25T22:27:46+00:00</updated><id>https://simonwillison.net/2019/Nov/25/niche-museums/</id><summary type="html">
    &lt;p&gt;I just released a major upgrade to my &lt;a href="https://www.niche-museums.com/"&gt;www.niche-museums.com&lt;/a&gt; website (launched &lt;a href="https://simonwillison.net/2019/Oct/28/niche-museums-kepler/"&gt;last month&lt;/a&gt;).&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The site is now rendered server-side. The previous version used &lt;a href="https://lit-html.polymer-project.org/"&gt;lit-html&lt;/a&gt; to render content using JavaScript.&lt;/li&gt;
&lt;li&gt;Each museum now has its own page. Here&amp;#39;s today&amp;#39;s new museum listing for the &lt;a href="https://www.niche-museums.com/browse/museums/46"&gt;Conservatory of Flowers&lt;/a&gt; in San Francisco. These pages have a map on them.&lt;/li&gt;
&lt;li&gt;The site has an &lt;a href="https://www.niche-museums.com/about"&gt;about page&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;You can now link to the page for a specific latitude and longitude, e.g. &lt;a href="https://www.niche-museums.com/?latitude=37.77&amp;amp;longitude=-122.458"&gt;this location in Golden Gate Park&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The source code for the site is now &lt;a href="https://github.com/simonw/museums"&gt;available on GitHub&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Notably, the site is entirely powered by &lt;a href="https://github.com/simonw/datasette"&gt;Datasette&lt;/a&gt;. It&amp;#39;s a heavily customized Datasette instance, making extensive use of &lt;a href="https://datasette.readthedocs.io/en/0.32/custom_templates.html#custom-templates"&gt;custom templates&lt;/a&gt; and &lt;a href="https://datasette.readthedocs.io/en/0.32/plugins.html"&gt;plugins&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It&amp;#39;s a really fun experiment. I&amp;#39;m essentially using Datasette as a weird twist on a static site generator - no moving parts since the database is immutable but there&amp;#39;s still stuff happening server-side to render the pages.&lt;/p&gt;
&lt;h3 id="continuous-deployment"&gt;Continuous deployment&lt;/h3&gt;
&lt;p&gt;The site is entirely stateless and is published &lt;a href="https://circleci.com/gh/simonw/museums"&gt;using Circle CI&lt;/a&gt; to a serverless hosting provider (currently Zeit Now v1, but I&amp;#39;ll probably move it to Google Cloud Run in the near future.)&lt;/p&gt;
&lt;p&gt;The site content - 46 museums and counting - lives in the &lt;a href="https://github.com/simonw/museums/blob/master/museums.yaml"&gt;museums.yaml&lt;/a&gt; file. I&amp;#39;ve been adding a new museum listing every day by editing the YAML file using &lt;a href="https://workingcopyapp.com/"&gt;Working Copy&lt;/a&gt; on my iPhone.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/simonw/museums/blob/master/.circleci/config.yml"&gt;build script&lt;/a&gt; runs automatically on every commit. It converts the YAML file into a SQLite database using my &lt;a href="https://github.com/simonw/yaml-to-sqlite"&gt;yaml-to-sqlite&lt;/a&gt; tool, then runs &lt;code&gt;datasette publish now...&lt;/code&gt; to deploy the resulting database.&lt;/p&gt;
&lt;p&gt;The full deployment command is as follows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;datasette publish now browse.db about.db \
    --token=$NOW_TOKEN \
    --alias=www.niche-museums.com \
    --name=niche-museums \
    --install=datasette-haversine \
    --install=datasette-pretty-json \
    --install=datasette-template-sql \
    --install=datasette-json-html \
    --install=datasette-cluster-map~=0.8 \
    --metadata=metadata.json \
    --template-dir=templates \
    --plugins-dir=plugins \
    --branch=master
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;There&amp;#39;s a lot going on here.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;browse.db&lt;/code&gt; is the SQLite database file that was built by running &lt;code&gt;yaml-to-sqlite&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;about.db&lt;/code&gt; is an empty database built using &lt;code&gt;sqlite3 about.db &amp;#39;&amp;#39;&lt;/code&gt; - more on this later.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;--alias=&lt;/code&gt; option tells Zeit Now to alias that URL to the resulting deployment. This is the single biggest feature that I&amp;#39;m missing from Google Cloud Run at the moment. It&amp;#39;s possible to point domains at deployments there but it&amp;#39;s not nearly as easy to script.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;--install=&lt;/code&gt; options tell &lt;code&gt;datasette publish&lt;/code&gt; which plugins should be installed on the resulting instance.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--metadata=&lt;/code&gt;, &lt;code&gt;--template-dir=&lt;/code&gt; and &lt;code&gt;--plugins-dir=&lt;/code&gt; are the options that customize the instance.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--branch=master&lt;/code&gt; means we always deploy the latest master of Datasette directly from GitHub, ignoring the most recent release to PyPI. This isn&amp;#39;t strictly necessary here.&lt;/p&gt;
&lt;h3 id="customization"&gt;Customization&lt;/h3&gt;
&lt;p&gt;The site itself is built almost entirely using Datasette custom templates. I have four of them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/museums/blob/c81e8ec9f39d87f13481608832c94b8e824fd347/templates/index.html"&gt;index.html&lt;/a&gt; is the template used for the homepage, and for the page you see when you search for museums near a specific latitude and longitude.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/museums/blob/c81e8ec9f39d87f13481608832c94b8e824fd347/templates/row-browse-museums.html"&gt;row-browse-museums.html&lt;/a&gt; is the template used for the &lt;a href="https://www.niche-museums.com/browse/museums/43"&gt;individual museum pages&lt;/a&gt;. It includes the JavaScript used for the map (which is powered by &lt;a href="https://leafletjs.com/"&gt;Leaflet&lt;/a&gt; and uses &lt;a href="https://foundation.wikimedia.org/wiki/Maps_Terms_of_Use"&gt;Wikimedia&amp;#39;s OpenStreetMap tiles&lt;/a&gt;, which I discovered thanks to &lt;a href="https://observablehq.com/@tmcw/leaflet"&gt;this Observable notebook&lt;/a&gt; by Tom MacWright).&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/museums/blob/c81e8ec9f39d87f13481608832c94b8e824fd347/templates/_museum_card.html"&gt;_museum_card.html&lt;/a&gt; is an included template rendering a card for a museum, shared by the index and museum pages.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/museums/blob/c81e8ec9f39d87f13481608832c94b8e824fd347/templates/database-about.html"&gt;database-about.html&lt;/a&gt; is the template for &lt;a href="https://www.niche-museums.com/about"&gt;the about page&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The about page uses a particularly devious hack.&lt;/p&gt;
&lt;p&gt;Datasette doesn&amp;#39;t have an easy way to create additional custom pages with URLs at the moment (without abusing the &lt;a href="https://datasette.readthedocs.io/en/stable/plugins.html#asgi-wrapper-datasette"&gt;asgi_wrapper()&lt;/a&gt; hook, which is pretty low-level).&lt;/p&gt;
&lt;p&gt;But... every attached database gets its own URL at &lt;code&gt;/database-name&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So, to create the &lt;code&gt;/about&lt;/code&gt; page I create an empty database called &lt;code&gt;about.db&lt;/code&gt; using the &lt;code&gt;sqlite3 about.db &amp;quot;&amp;quot;&lt;/code&gt; command. I serve that using Datasette, then create a custom template for that specific database using Datasette&amp;#39;s &lt;a href="https://datasette.readthedocs.io/en/0.32/custom_templates.html#custom-templates"&gt;template naming conventions&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I&amp;#39;ll probably come up with a less grotesque way of doing this and bake it into Datasette in the future. For the moment this seems to work pretty well.&lt;/p&gt;
&lt;h3 id="plugins"&gt;Plugins&lt;/h3&gt;
&lt;p&gt;The two key plugins here are &lt;code&gt;datasette-haversine&lt;/code&gt; and &lt;code&gt;datasette-template-sql&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/datasette-haversine"&gt;datasette-haversine&lt;/a&gt; adds a custom SQL function to Datasette called &lt;code&gt;haversine()&lt;/code&gt;, which calculates the haversine distance between two latitude/longitude points.&lt;/p&gt;
&lt;p&gt;It&amp;#39;s used by the SQL query which finds the nearest museums to the user.&lt;/p&gt;
&lt;p&gt;This is very inefficient - it&amp;#39;s essentially a brute-force approach which calculates that distance for every museum in the database and sorts them accordingly - but it will be years before I have enough museums listed for that to cause any kind of performance issue.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/datasette-template-sql"&gt;datasette-template-sql&lt;/a&gt; is the new plugin I &lt;a href="https://simonwillison.net/2019/Nov/18/datasette-template-sql/"&gt;described last week&lt;/a&gt;, made possible by Datasette dropping Python 3.5 support. It allows SQL queries to be executed directly from templates. I&amp;#39;m using it here to &lt;a href="https://github.com/simonw/museums/blob/c81e8ec9f39d87f13481608832c94b8e824fd347/templates/index.html#L58-L69"&gt;run the queries&lt;/a&gt; that power homepage.&lt;/p&gt;
&lt;p&gt;I tried to get the site working just using code in the templates, but it got pretty messy. Instead, I took advantage of Datasette&amp;#39;s &lt;code&gt;--plugins-dir&lt;/code&gt; option, which causes Datasette to treat all Python modules in a specific directory as plugins and attempt to load them.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/museums/blob/c81e8ec9f39d87f13481608832c94b8e824fd347/plugins/index_vars.py"&gt;index_vars.py&lt;/a&gt; is a single custom plugin that I&amp;#39;m bundling with the site. It uses the &lt;a href="https://datasette.readthedocs.io/en/0.32/plugins.html#extra-template-vars-template-database-table-view-name-request-datasette"&gt;extra_template_vars()&lt;/a&gt; plugin took to detect requests to the &lt;code&gt;index&lt;/code&gt; page and inject some additional custom template variables based on values read from the querystring.&lt;/p&gt;
&lt;p&gt;This ends up acting a little bit like a custom Django view function. It&amp;#39;s a slightly weird pattern but again it does the job - and helps me further explore the potential of Datasette as a tool for powering websites in addition to just providing an API.&lt;/p&gt;
&lt;h2 id="weeknotes"&gt;Weeknotes&lt;/h2&gt;
&lt;p&gt;This post is standing in for my regular weeknotes, because it represents most of what I achieved this last week. A few other bits and pieces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I&amp;#39;ve been exploring ways to enable CSV upload directly into a Datasette instance. I&amp;#39;m building a prototype of this on top of &lt;a href="https://www.starlette.io/"&gt;Starlette&lt;/a&gt;, because it has solid ASGI &lt;a href="https://www.starlette.io/requests/#request-files"&gt;file upload support&lt;/a&gt;. This is currently a standalone web application but I&amp;#39;ll probably make it work as a Datasette ASGI plugin once I have something I like.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sixcolors.com/post/2019/09/13-features-of-ios-13-shortcuts/"&gt;Shortcuts in iOS 13&lt;/a&gt; got some very interesting new features, most importantly the ability to trigger shortcuts automatically on specific actions - including every time you open a specific app. I&amp;#39;ve been experimenting with using this to automatically copy data from my iPhone up to a custom web application - maybe this could help ingest notes and photos into &lt;a href="https://simonwillison.net/2019/Oct/7/dogsheep/"&gt;Dogsheep&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Posted seven new museums to niche-museums.com: &lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.niche-museums.com/browse/museums/39"&gt;Cable Car Museum&lt;/a&gt; in San Francisco&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.niche-museums.com/browse/museums/40"&gt;Audium&lt;/a&gt; in San Francisco&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.niche-museums.com/browse/museums/41"&gt;House of Broel Dollhouse Museum&lt;/a&gt; in New Orleans&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.niche-museums.com/browse/museums/43"&gt;Neptune Society Columbarium&lt;/a&gt; in San Francisco&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.niche-museums.com/browse/museums/44"&gt;Recoleta Cemetery&lt;/a&gt; in Buenos Aires&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.niche-museums.com/browse/museums/45"&gt;NASA Glenn Visitor Center&lt;/a&gt; in Cleveland&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.niche-museums.com/browse/museums/46"&gt;Conservatory of Flowers&lt;/a&gt; in San Francisco&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;I composed &lt;a href="https://www.niche-museums.com/browse?sql=select+json_object%28%22pre%22%2C+group_concat%28%27*+%5B%27+%7C%7C+name+%7C%7C+%27%5D%28https%3A%2F%2Fwww.niche-museums.com%2Fbrowse%2Fmuseums%2F%27+%7C%7C+id+%7C%7C++%2B+%27%29+in+%27+%7C%7C+coalesce%28osm_city%2C+osm_county%2C+osm_state%2C+osm_country%2C+%27%27%29%2C+%27%0D%0A%27%29%29+from+%28select+*+from+%28select+*+from+museums+order+by+id+desc+limit+7%29+order+by+id%29%3B"&gt;devious SQL query&lt;/a&gt; for generating the markdown for the seven most recently added museums.&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/museums"&gt;museums&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/baked-data"&gt;baked-data&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="museums"/><category term="projects"/><category term="yaml"/><category term="datasette"/><category term="weeknotes"/><category term="baked-data"/></entry><entry><title>yaml-to-sqlite 0.2.1</title><link href="https://simonwillison.net/2019/Nov/8/yaml-to-sqlite/" rel="alternate"/><published>2019-11-08T06:46:02+00:00</published><updated>2019-11-08T06:46:02+00:00</updated><id>https://simonwillison.net/2019/Nov/8/yaml-to-sqlite/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/yaml-to-sqlite/releases/tag/0.2.1"&gt;yaml-to-sqlite 0.2.1&lt;/a&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/yaml"&gt;yaml&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="sqlite"/><category term="yaml"/></entry><entry><title>yaml-to-sqlite 0.2</title><link href="https://simonwillison.net/2019/Jun/23/yaml-to-sqlite/" rel="alternate"/><published>2019-06-23T22:55:50+00:00</published><updated>2019-06-23T22:55:50+00:00</updated><id>https://simonwillison.net/2019/Jun/23/yaml-to-sqlite/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/yaml-to-sqlite/releases/tag/0.2"&gt;yaml-to-sqlite 0.2&lt;/a&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/yaml"&gt;yaml&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="sqlite"/><category term="yaml"/></entry><entry><title>Analyzing US Election Russian Facebook Ads</title><link href="https://simonwillison.net/2018/Aug/6/russian-facebook-ads/" rel="alternate"/><published>2018-08-06T16:01:18+00:00</published><updated>2018-08-06T16:01:18+00:00</updated><id>https://simonwillison.net/2018/Aug/6/russian-facebook-ads/</id><summary type="html">
    &lt;p&gt;Two interesting data sources have emerged in the past few weeks concerning the Russian impact on the 2016 US elections.&lt;/p&gt;
&lt;p&gt;FiveThirtyEight &lt;a href="https://fivethirtyeight.com/features/why-were-sharing-3-million-russian-troll-tweets/"&gt;published nearly 3 million tweets&lt;/a&gt; from accounts associated with the Russian “Internet Research Agency” - see &lt;a href="https://simonwillison.net/2018/Aug/6/troll-tweets/"&gt;my article and searchable tweet archive here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Separately, the House Intelligence Committee Minority &lt;a href="https://democrats-intelligence.house.gov/social-media-content/"&gt;released 3,517 Facebook ads&lt;/a&gt; that were reported to have been bought by the Russian Internet Research Agency as a set of redacted PDF files.&lt;/p&gt;
&lt;h3&gt;&lt;a id="Exploring_the_Russian_Facebook_Ad_spend_18"&gt;&lt;/a&gt;Exploring the Russian Facebook Ad spend&lt;/h3&gt;
&lt;p&gt;The initial data was released as &lt;a href="https://democrats-intelligence.house.gov/social-media-content/social-media-advertisements.htm"&gt;zip files full of PDFs&lt;/a&gt;, one of the least friendly formats you can use to publish data.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://twitter.com/edsu"&gt;Ed Summers&lt;/a&gt; took on the intimidating task of cleaning that up. &lt;a href="https://github.com/edsu/irads"&gt;His results are incredible&lt;/a&gt;: he used the &lt;a href="https://pypi.org/project/pytesseract/"&gt;pytesseract OCR library&lt;/a&gt; and &lt;a href="https://pypi.org/project/PyPDF2/"&gt;PyPDF2&lt;/a&gt; to extract both the images and the associated metadata and convert the whole lot into a single 3.9MB JSON file.&lt;/p&gt;
&lt;p&gt;I &lt;a href="https://github.com/simonw/russian-ira-facebook-ads-datasette"&gt;wrote some code&lt;/a&gt; to convert his JSON file to SQLite (more on the details later) and the result can be found here:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://russian-ira-facebook-ads.datasettes.com/"&gt;https://russian-ira-facebook-ads.datasettes.com/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Here’s an &lt;a href="https://russian-ira-facebook-ads.datasettes.com/russian-ads-919cbfd/display_ads?_search=cops&amp;amp;_sort_desc=spend_usd"&gt;example search for “cops” ordered by the USD equivalent spent on the ad&lt;/a&gt; (some of the spends are in rubles, so I convert those to USD using today’s exchange rate of 0.016).&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%" src="https://static.simonwillison.net/static/2018/ads-cops-sorted-by-usd.png" alt="Search ads for cops, order by USD descending" /&gt;&lt;/p&gt;
&lt;p&gt;One of the most interesting things about this data is that it includes the Facebook ad targetting options that were used to promote the ads. I’ve built a separate interface for browsing those - you can see &lt;a href="https://russian-ira-facebook-ads.datasettes.com/russian-ads-919cbfd/top_targets"&gt;the most frequently applied targets&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%" src="https://static.simonwillison.net/static/2018/top-targets.png" alt="Top targets" /&gt;&lt;/p&gt;
&lt;p&gt;And by browsing &lt;a href="https://russian-ira-facebook-ads.datasettes.com/russian-ads-919cbfd/faceted-targets?targets=%5B%22d6ade%22%5D"&gt;through the different facets&lt;/a&gt; you can construct e.g. a search for all ads that targeted people interested in both &lt;code&gt;interests:Martin Luther King&lt;/code&gt; and  &lt;code&gt;interests:Police Brutality is a Crime&lt;/code&gt;: &lt;a href="https://russian-ira-facebook-ads.datasettes.com/russian-ads-919cbfd/display_ads?_targets_json=%5B%22d6ade%22%2C%2240c27%22%5D"&gt;https://russian-ira-facebook-ads.datasettes.com/russian-ads-919cbfd/display_ads?_targets_json=[&amp;quot;d6ade&amp;quot;%2C&amp;quot;40c27&amp;quot;]&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;&lt;a id="New_tooling_under_the_hood_40"&gt;&lt;/a&gt;New tooling under the hood&lt;/h3&gt;
&lt;p&gt;I ended up spinning up several new projects to help process and explore this data.&lt;/p&gt;
&lt;h4&gt;&lt;a id="sqliteutils_44"&gt;&lt;/a&gt;sqlite-utils&lt;/h4&gt;
&lt;p&gt;The first is a new library called &lt;a href="https://sqlite-utils.readthedocs.io/en/latest/"&gt;sqlite-utils&lt;/a&gt;. If data is already in CSV I tend to convert it using csvs-to-sqlite, but if data is in a less tabular format (JSON or XML for example) I have to hand-write code. Here’s &lt;a href="https://github.com/simonw/register-of-members-interests/blob/2baf75956b8b9e93a3985ebeb2259f7f2af760c8/convert_xml_to_sqlite.py"&gt;a script&lt;/a&gt; I wrote to process the XML version of &lt;a href="https://simonwillison.net/2018/Apr/25/register-members-interests/"&gt;the UK Register of Members Interests for example&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;My goal with sqlite-utils is to take some of the common patterns from those scripts and make them as easy to use as possible, in particular when running inside a Jupyter notebook. It’s still very early, but &lt;a href="https://github.com/simonw/russian-ira-facebook-ads-datasette/blob/336ba87ef8071e664441ad0a95e3b8d0a33f682a/fetch_and_build_russian_ads.py"&gt;the script I wrote&lt;/a&gt; to process the Russian ads JSON is a good example of the kind of thing I want to do with it.&lt;/p&gt;
&lt;h4&gt;&lt;a id="datasettejsonhtml_50"&gt;&lt;/a&gt;datasette-json-html&lt;/h4&gt;
&lt;p&gt;The second new tool is a new Datasette plugin (and &lt;a href="https://github.com/simonw/datasette/issues/352"&gt;corresponding plugin hook&lt;/a&gt;) called &lt;a href="https://github.com/simonw/datasette-json-html"&gt;datasette-json-html&lt;/a&gt;. I used this to solve the need to display both rendered images and customized links as part of the regular Datasette instance.&lt;/p&gt;
&lt;p&gt;It’s a pretty crazy solution (hence why it’s implemented as a plugin and not part of Datasette core) but it works surprisingly well. The basic idea is to support a mini JSON language which can be detected and rendered as HTML. A couple of examples:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &amp;quot;img_src&amp;quot;: &amp;quot;https://raw.githubusercontent.com/edsu/irads/03fb4b/site/images/0771.png&amp;quot;,
  &amp;quot;width&amp;quot;: 200
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Is rendered as an HTML &lt;code&gt;&amp;lt;img src=&amp;quot;&amp;quot;&amp;gt;&lt;/code&gt; element.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[
  {
    &amp;quot;label&amp;quot;: &amp;quot;location:United States&amp;quot;,
    &amp;quot;href&amp;quot;: &amp;quot;/russian-ads/display_ads?_target=ec3ac&amp;quot;
  },
  {
    &amp;quot;label&amp;quot;: &amp;quot;interests:Martin Luther King&amp;quot;,
    &amp;quot;href&amp;quot;: &amp;quot;/russian-ads/display_ads?_target=d6ade&amp;quot;
  },
  {
    &amp;quot;label&amp;quot;: &amp;quot;interests:Jr.&amp;quot;,
    &amp;quot;href&amp;quot;: &amp;quot;/russian-ads/display_ads?_target=8e7b3&amp;quot;
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Is rendered as a comma-separated list of HTML links.&lt;/p&gt;
&lt;p&gt;Why use JSON for this? Because SQLite has some &lt;a href="https://www.sqlite.org/json1.html"&gt;incredibly powerful JSON features&lt;/a&gt;, making it trivial to output JSON as part of the result of a SQL query. Most interestingly of all it has &lt;code&gt;json_group_array()&lt;/code&gt; which can work as an aggregation function to combine a set of related rows into a single JSON array.&lt;/p&gt;
&lt;p&gt;The display_ads page shown above is powered by a SQL view. Here’s the relevant subset of that view:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select ads.id,
    case when image is not null then
        json_object(&amp;quot;img_src&amp;quot;, &amp;quot;https://raw.githubusercontent.com/edsu/irads/03fb4b/site/&amp;quot; || image, &amp;quot;width&amp;quot;, 200)
    else
        &amp;quot;no image&amp;quot;
    end as img,
    json_group_array(
        json_object(
            &amp;quot;label&amp;quot;, targets.name,
            &amp;quot;href&amp;quot;, &amp;quot;/russian-ads/display_ads?_target=&amp;quot;
                || urllib_quote_plus(targets.id)
        )
    ) as targeting
from ads
    join ad_targets on ads.id = ad_targets.ad_id
    join targets on ad_targets.target_id = targets.id
group by ads.id limit 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I’m using SQLite’s JSON functions to dynamically assemble the JSON format that datasette-json-html knows how to render. I’m delighted at how well it works.&lt;/p&gt;
&lt;p&gt;I’ve turned off arbitrary SQL querying against the main Facebook ads Datasette instance, but there’s a copy running at &lt;a href="https://russian-ira-facebook-ads-sql-allowed.now.sh/russian-ads"&gt;https://russian-ira-facebook-ads-sql-allowed.now.sh/russian-ads&lt;/a&gt; if you want to play with these queries.&lt;/p&gt;
&lt;h4&gt;&lt;a id="Weird_implementation_details_106"&gt;&lt;/a&gt;Weird implementation details&lt;/h4&gt;
&lt;p&gt;The full source code for my implementation &lt;a href="https://github.com/simonw/russian-ira-facebook-ads-datasette"&gt;is available on GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I ended up using &lt;a href="https://github.com/simonw/datasette/commit/5116c4ec8aed5091e1f75415424b80f613518dc6"&gt;an experimental plugin hook&lt;/a&gt; to enable additional custom filtering on Datasette views in order to support showing ads against multiple m2m targets, but hopefully that will be made unnecessary as work on Datasette’s &lt;a href="https://github.com/simonw/datasette/issues/354"&gt;support for m2m relationships&lt;/a&gt; progresses.&lt;/p&gt;
&lt;p&gt;I also experimented with YAML to generate the &lt;code&gt;metadata.json&lt;/code&gt; file as JSON strings aren’t a great way of &lt;a href="https://github.com/simonw/russian-ira-facebook-ads-datasette/blob/336ba87ef8071e664441ad0a95e3b8d0a33f682a/russian-ads-metadata.yaml"&gt;representing multi-line HTML and SQL&lt;/a&gt;. And if you want to see some &lt;em&gt;really&lt;/em&gt; convoluted SQL have a look at how the &lt;a href="https://github.com/simonw/russian-ira-facebook-ads-datasette/blob/336ba87ef8071e664441ad0a95e3b8d0a33f682a/russian-ads-metadata.yaml#L52-L81"&gt;canned query&lt;/a&gt; for the &lt;a href="https://russian-ira-facebook-ads.datasettes.com/russian-ads-919cbfd/faceted-targets?targets=%5B%22371f0%22%2C%22cc5ed%22%5D"&gt;faceted targeting interface&lt;/a&gt; works.&lt;/p&gt;
&lt;p&gt;This was a really fun project, which further stretched my ideas about what Datasette should be capable of out of the box. I’m hoping that the &lt;a href="https://github.com/simonw/datasette/issues/354"&gt;m2m work&lt;/a&gt; will make a lot of these crazy hacks redundant.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/politics"&gt;politics&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="politics"/><category term="projects"/><category term="yaml"/><category term="datasette"/><category term="sqlite-utils"/></entry><entry><title>twitter-text-conformance</title><link href="https://simonwillison.net/2010/Feb/6/twitter/" rel="alternate"/><published>2010-02-06T15:39:27+00:00</published><updated>2010-02-06T15:39:27+00:00</updated><id>https://simonwillison.net/2010/Feb/6/twitter/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://github.com/mzsanford/twitter-text-conformance"&gt;twitter-text-conformance&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
This is a neat idea: Twitter have released open source libraries for parsing standard tweet syntax in Ruby and Java, but they’ve also released a set of YAML unit tests aimed at anyone who wants to implement the same parsing logic in other languages.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="http://engineering.twitter.com/2010/02/introducing-open-source-twitter-text.html"&gt;Twitter Engineering Blog&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/java"&gt;java&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ruby"&gt;ruby&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/twitter"&gt;twitter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/conformance-suites"&gt;conformance-suites&lt;/a&gt;&lt;/p&gt;



</summary><category term="java"/><category term="ruby"/><category term="testing"/><category term="twitter"/><category term="yaml"/><category term="conformance-suites"/></entry><entry><title>More YAML</title><link href="https://simonwillison.net/2003/Feb/5/moreYaml/" rel="alternate"/><published>2003-02-05T23:49:43+00:00</published><updated>2003-02-05T23:49:43+00:00</updated><id>https://simonwillison.net/2003/Feb/5/moreYaml/</id><summary type="html">
    &lt;p&gt;Paul Tchistopolskii's &lt;a href="http://www.pault.com/pault/pxml/xmlalternatives.html"&gt;XML Alternatives&lt;/a&gt; reminded me to take another look at &lt;a href="YAML Ain&amp;apos;t Markup Language"&gt;YAML&lt;/a&gt;. The specification has been updated since &lt;a href="/2002/Dec/05/yaml/"&gt;I last looked&lt;/a&gt; and seems to be a bit more complicated, but it's still a very nicely designed format. Implementations are available for Perl, Python and Ruby with C and Java on the way but strangely no one seems to be doing one for &lt;acronym title="PHP: Hypertext Preprocessor"&gt;PHP&lt;/acronym&gt; yet. I'm doing a course at Uni on compilers at the moment which includes quite a lot of stuff about writing parsers so I'm very tempted to have a go at a YAML implementation in the next few weeks just to try stuff out. The possibility of easily swapping relatively complex data structures between &lt;acronym title="PHP: Hypertext Preprocessor"&gt;PHP&lt;/acronym&gt; and Python is pretty tempting as well.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/xml"&gt;xml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="xml"/><category term="yaml"/></entry><entry><title>YAML</title><link href="https://simonwillison.net/2002/Dec/5/yaml/" rel="alternate"/><published>2002-12-05T02:49:08+00:00</published><updated>2002-12-05T02:49:08+00:00</updated><id>https://simonwillison.net/2002/Dec/5/yaml/</id><summary type="html">
    &lt;p&gt;I forget quite how I got there, but the other day I found myself reading about &lt;acronym title="YAML Ain&amp;apos;t Markup Language"&gt;YAML&lt;/acronym&gt; - &lt;a href="http://www.yaml.org/"&gt;YAML Ain't Markup Language&lt;/a&gt;. It looks really interesting. YAML aims to be an easily human readable format for storing and transferring structured data - so far, so &lt;acronym title="eXtensible Markup Language"&gt;XML&lt;/acronym&gt;. Where it differs from the &lt;acronym title="Information Technology"&gt;IT&lt;/acronym&gt; world's favourite buzzword is that YAML is specifically designed to handle the three most common data structures - scalars (single values), lists and dictionaries. Here's a sample (taken from the &lt;a href="http://www.yaml.org/spec/" title="YAML Ain&amp;apos;t Markup Language"&gt;official specification&lt;/a&gt;):&lt;/p&gt;
&lt;pre&gt;
Time: 2001-11-23 15:01:42 -05:00
User: ed
Warning: &amp;gt;
  This is an error message
  for the log file
&lt;/pre&gt;
&lt;p&gt;YAML has a number of obvious influences, including Python and &lt;acronym title="Multipurpose Internet Mail Extensions"&gt;MIME&lt;/acronym&gt;. Implementations already exist for &lt;a href="http://wiki.yaml.org/yamlwiki/YamlPm" title="YamlPm"&gt;Perl&lt;/a&gt;, &lt;a href="http://wiki.yaml.org/yamlwiki/PurePythonParserForYaml" title="PurePythonParserForYaml"&gt;Python&lt;/a&gt; and &lt;a href="http://helide.com/g/yaml/" title="A YAML parser written in Java (work in progress)"&gt;Java&lt;/a&gt;. &lt;acronym title="eXtensible Markup Language - Remote Procedure Calls"&gt;XML-RPC&lt;/acronym&gt; aptly demonstrates how powerful the combination of lists, dictionaries and arrays can be for exchanging data between different systems and YAML looks like it offers a very nice alternative to XML based data structure syntax. I have to admit to being slightly concerned by the length of the specification - while YAML is definitely human readable it looks like it could take a while for a human to learn to write it. Then again, the actual generation of the format is meant to be handled by computers (I imagine that humans will make simple edits to YAML files more often than they create them from scratch) so the complexity of the more advanced parts of the specification is probably not too much of a problem.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/markup"&gt;markup&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="markup"/><category term="yaml"/></entry></feed>