@sdelsad/commodity-desk-toolkit
v1.16.0
Published
One-command publishing for the Soft Commodity Trading podcast: the daily session writes two files and runs run_episode.py, which builds the page, charts, e-mail and feed and stages them on npm for the connector to copy server-side
Maintainers
Readme
Soft Commodity Trading — toolkit
Reusable, tested scripts behind the Soft Commodity Trading podcast: a daily 10-minute briefing on physical commodity trading. Published so the daily automation runs the same code every morning instead of re-deriving it.
Naming — read this before renaming anything
The show is called Soft Commodity Trading. It was previously Commodity Desk Daily; that name must not appear in any script, page, email or feed any more.
The slug is frozen at commodity-desk-daily on purpose. It is the npm
package name, the GCS folder and every published episode URL. Renaming it would
break the RSS enclosure URLs of episodes already downloaded and force every
subscriber to re-subscribe. Display names change; the slug does not.
Install (once per fresh container)
npm pack @sdelsad/commodity-desk-toolkit && tar xzf sdelsad-commodity-desk-toolkit-*.tgz
bash package/setup.sh # installs deps, downloads the Kokoro voice model (~350 MB, cached)The package also carries the show's knowledge, so the daily run reads it instead of re-deriving it:
STYLE.md— the show bible: audience, framing rules, how it sounds, episode structure, quiz standards. Read it before writing.curriculum.md— 30 dense episode briefs (6 weeks): angle, worked example, and the depth to reach. Two concepts per episode, or one taken deep.conversions.md— 12 unit-conversion drills, one per episode on a rotating cycle, email only: the rule, the fast mental method, worked examples.
What builds what
| Script | In | Out |
|---|---|---|
| fetch_context.py | npm history | covered.md, prev_epNN.md |
| generate_audio.py | script.txt | epNN.mp3 |
| publish_episode.py | epNN.md + mp3 | npm package, feed.xml, covered.md, glossary.md |
| build_page.py | epNN.md | epNN.html, epNN_chartN.png |
| build_email.py | epNN.md | epNN_email.html, epNN_email.txt |
| build_post.py | an essay .md | writing/<slug>.html, index.json, index.html |
| chart.py | a ```chart block | inline SVG (page), PNG (e-mail) |
Write nothing by hand that one of these renders. The page and the e-mail
are built from the same epNN.md, which is why they agree; anything typed
straight into HTML drifts within a week.
0. Fetch what has already aired
python3 package/fetch_context.py --episode 12 --outdir .Writes covered.md (running log of every episode aired) and prev_epNN.md
(the J-1 and J-3 episode notes, for the quiz) into the working directory, by
walking back through published versions. Always exits 0 — missing history is
normal early on and must never block an episode.
1. Generate the audio
The spoken script is one segment per line, with the pause (in seconds) that should follow it. This is what makes the narration breathe instead of sounding like a machine reading an article.
Here is the thing nobody tells you about commodity trading. ||| 0.5
A merchant does not get paid for predicting prices. ||| 0.35
Space. Time. Form. ||| 0.7Conventions: 0.3 mid-thought, 0.5 after a completed idea, 0.7–0.8 after a
punchline or section break. Lines starting with # are ignored.
Dialogue lines use an uppercase speaker label and are rendered with two distinct voices when generated through the podcast-tts connector:
BUYER: November Santos, what have you got? ||| 0.25
SELLER: I make you plus eighty-five. ||| 0.25python3 package/generate_audio.py script.txt ep02.mp3 --voice am_michael --speed 0.97Prints DURATION_SECONDS= and SIZE_BYTES= for the publish step. A segment
that fails synthesis is skipped rather than killing the episode.
2. Publish it
Publishes to npm, which jsDelivr serves as a public CDN. Reads the currently
live version, bumps the patch, carries the cover art, RSS feed, covered.md log and glossary.md forward, and
prepends the new episode to the feed (re-publishing the same episode number
replaces its item rather than duplicating it).
Two modes: --mp3 <file> embeds a local mp3 in the npm package (Kokoro
fallback path); --mp3-url <url> --size-bytes <n> references an externally
hosted mp3 (Gemini/GCS path — the feed and email point straight at it).
Authentication: export NPM_TOKEN and the script writes a token-free
.npmrc next to the staged package, which npm expands at publish time. Never
write the token itself into a file or a config command.
export NPM_TOKEN=npm_...
python3 package/publish_episode.py \
--mp3 ep02.mp3 --notes ep02.md \
--number 2 --title "Flat Price vs Basis" \
--description "Why physical traders don't bet on price." \
--covered "Basis vs flat price; long/short the basis; basis P&L. Vocab: differential, basis risk. Example: Santos cargo." \
--glossary "lot = one futures contract, the unit desks count positions in" \
--covered-out covered.md --glossary-out glossary.md \
--duration 612 --pubdate "Tue, 11 Aug 2026 05:00:00 GMT"Add --dry-run to stage the package without publishing. Prints
PUBLISHED_URL=, PUBLISHED_VERSION= and FEED_URL=.
Linking the feed to the web
--page-url gives the episode a website. Apple Podcasts turns an item's
<link> into the episode's website button and renders anchors inside a CDATA
<description>, so the publisher writes both, plus an <itunes:summary> with
the bare URL for apps that strip HTML. The channel <link> points at the show's
site (--site-url), which is what Apple shows as the podcast's website.
The page URL is deterministic, so it can be passed in the same command that publishes the audio, before the page itself exists:
--page-url "https://storage.googleapis.com/podcast-audio-2647223968/commodity-desk-daily/epNN.html"3. Build the episode page
python3 package/build_page.py --notes ep03.md --number 3 \
--title "Futures Plumbing and the Shape of the Curve" \
--dek "One sentence under the headline." \
--audio-url "https://.../ep03.mp3" --duration 620 \
--date "Wednesday 12 August 2026" --out ep03.htmlRenders the notes into a standalone page and hosts with the connector's
publish_page tool. What the page carries:
- A contents rail built from the H2s and H3s, fixed in the left margin
above 1240px and inline below it, with the section being read highlighted.
Every heading gets a stable
idand a hover anchor. - Previous / next episode links and an archive of every episode aired.
- The glossary as it stood that day, collapsed. Episode 1 carries episode 1's terms, episode 5 carries one to five — a page dated Monday listing a word first used on Friday reads as a leak. A search box and one chip per episode narrow it further; the chips are omitted when there is only one episode to show.
- One reveal per quiz answer — checking Q1 no longer burns Q2 to Q7 — plus reveal-all / hide-all, and a link back to each question.
- Tables that survive a phone: each one scrolls inside its own box, figures are tabular and right-aligned, and moves in a change column carry an explicit sign and a restrained green or red.
schema.org/PodcastEpisodeJSON-LD, Open Graph and Twitter cards using the show's cover art, and a canonical URL.- A print stylesheet. Everything folded opens, the navigation and the player drop out, and the glossary is left off so a printout is the article.
- A reading-progress bar, playback-speed buttons, and the light/dark toggle.
Two files it picks up on its own
Both are optional and both are already in the working directory by the time this step runs, so the command above does not mention them:
| File | What it gives the page |
|---|---|
| covered.md | previous/next links with real titles, and the archive |
| glossary.md | the glossary section, filtered to episodes ≤ this one |
Written by publish_episode.py in step 2 — which is why the page is built
after the publish, not before. Override with --covered PATH / --glossary
PATH, or pass none to leave either out.
3b. Build the e-mail
The daily e-mail used to be written from scratch every morning, so it drifted.
It is now rendered from the same epNN.md as the page:
python3 package/build_email.py --notes ep05.md --number 5 \
--title "Wheat: The Map and the Screens" \
--dek "One sentence under the headline." \
--audio-url "https://.../ep05.mp3" \
--page-url "https://.../ep05.html" \
--duration 739 --date "Friday 14 August 2026" \
--charts "URL1,URL2,URL3" --glossary glossary_final.md \
--drill-index 5 --drill-file conversions.md \
--out ep05_email.htmlWrites ep05_email.html and, from the same parse, the plain-text alternative
ep05_email.txt. Send both parts.
--chartstakes the published PNG URLs, comma-separated, in the orderbuild_page.pyprinted itsCHART_PNG=lines. Chart n in the notes gets URL n, so a pulse chart lands in the pulse and lesson charts land inline in the written edition. Missing URLs are skipped with a warning, never guessed.--drill-indexdefaults to((N − 1) mod 12) + 1, the rotation inconversions.md; pass it explicitly only to override the cycle.--dekis optional and becomes the standfirst and the inbox preheader.
The section order is fixed and the builder does not let it move: header with the listen button → read online → market pulse with its chart → conversion drill → quiz → spoiler separator → solutions → the written edition → the cumulative glossary. Sections are found by their heading rather than their position, so the early episodes — which ordered their notes differently — come out in the same order as today's.
E-mail HTML is not web HTML, and the output obeys the difference: tables for
layout, every style inline, a 600px card with the width attribute set
(Outlook ignores max-width), no <style> block, no flexbox, grid, CSS
variable or media query, charts as <img> with alt text and an explicit width,
and a spoiler gap made of fixed-height table rows rather than stacked <br>,
which Gmail collapses.
3c. Publish an essay
The site's Writing section reads writing/index.json out of the bucket.
python3 package/build_post.py --notes essay.md --slug basis-is-a-verb \
--title "Basis Is a Verb" --dek "One sentence." \
--date "14 August 2026" --outdir writingWrites writing/basis-is-a-verb.html, upserts the entry in
writing/index.json and rebuilds writing/index.html. Publish all three with
publish_asset at those exact paths. Run it with no --notes to rebuild just
the index. Essays use the same renderer and palette as the episode pages.
4. Charts
chart.py renders a small JSON spec two ways from the same layout: an inline
SVG for the page, which inherits the page's CSS variables and therefore
follows the light/dark toggle, and a PNG for the e-mail, because no mail
client renders inline SVG. Types: line (add "mode":"index" to rebase series
to 100), bar, waterfall.
Charts are written straight into epNN.md as fenced blocks:
```chart
{"type":"line","unit":"c/bu","title":"…","caption":"…","source":"…",
"x":["4 Aug","5 Aug"],"series":[{"name":"Dec 26","values":[471,468]}]}
```build_page.py picks them up automatically. Add --charts-prefix ep03_chart
and it also writes ep03_chart1.png, ep03_chart2.png, printing CHART_PNG=
for each, in document order. Publish those with the connector's publish_asset
tool and pass the URLs to build_email.py --charts in the same order.
Two things worth knowing:
- The SVG sizes its type in viewBox units, so it shrinks with the chart. A phone renders 640 units into about 360 pixels, which would turn 12px type into 7px, so the stylesheet inside the SVG scales the labels back up under a width media query — and on the page the figure breaks out to the full width of a small screen.
- The PNG is only ever seen in an e-mail, scaled into a 522px column, so its type is set larger than the SVG's. Titles, captions and sources wrap onto as many lines as they need; the canvas grows to fit rather than clipping them.
Standalone: python3 package/chart.py spec.json --svg out.svg --png out.png.
PNG output needs Pillow; everything else is standard library.
See STYLE.md for when a chart earns its place.
Output
- Episode audio:
https://cdn.jsdelivr.net/npm/@sdelsad/commodity-desk-daily@<version>/epNN.mp3 - Podcast RSS feed:
https://cdn.jsdelivr.net/npm/@sdelsad/commodity-desk-daily@latest/feed.xml
Notes
- Voices:
am_michael,bm_george,af_heart,am_fenrir(Kokoro). ffmpegmust be on PATH for the mp3 conversion.- jsDelivr caches aggressively; a freshly published episode can take a few minutes to become fetchable, and podcast apps may see the feed later still.
