@sdelsad/commodity-desk-daily 1.0.18 → 1.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/STYLE.md +439 -0
- package/__pycache__/build_page.cpython-314.pyc +0 -0
- package/__pycache__/chart.cpython-314.pyc +0 -0
- package/build_email.py +773 -0
- package/build_page.py +1237 -0
- package/build_post.py +291 -0
- package/chart.py +563 -0
- package/conversions.md +156 -0
- package/curriculum.md +294 -0
- package/ep01.md +144 -0
- package/ep01.mp3 +0 -0
- package/ep01.script.txt +71 -0
- package/ep02.md +141 -0
- package/ep02.script.txt +70 -0
- package/ep03.md +163 -0
- package/ep03.script.txt +61 -0
- package/ep04.md +192 -0
- package/ep04.mp3 +0 -0
- package/ep04.script.txt +69 -0
- package/feed.xml +3 -15
- package/fetch_context.py +123 -0
- package/generate_audio.py +120 -0
- package/package.json +1 -1
- package/publish_episode.py +367 -0
- package/setup.sh +23 -0
package/build_email.py
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Soft Commodity Trading — daily e-mail builder.
|
|
4
|
+
|
|
5
|
+
The e-mail used to be improvised every morning, which meant it drifted. This
|
|
6
|
+
renders it deterministically from the same epNN.md the page is built from, so
|
|
7
|
+
the eight sections are always the same eight sections, in the same order.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 build_email.py --notes ep05.md --number 5 \
|
|
11
|
+
--title "Wheat: The Map and the Screens" \
|
|
12
|
+
--audio-url "https://.../ep05.mp3" \
|
|
13
|
+
--page-url "https://.../ep05.html" \
|
|
14
|
+
--duration 739 --date "Friday 14 August 2026" \
|
|
15
|
+
--charts URL1,URL2,URL3 --glossary glossary_final.md \
|
|
16
|
+
--drill-index 5 --drill-file conversions.md \
|
|
17
|
+
--out ep05_email.html
|
|
18
|
+
|
|
19
|
+
Writes the HTML to --out and the plain-text alternative alongside it
|
|
20
|
+
(ep05_email.txt), built from the same parse so the two cannot disagree.
|
|
21
|
+
|
|
22
|
+
E-mail HTML is not web HTML, and this file obeys the difference:
|
|
23
|
+
|
|
24
|
+
* layout is tables, every style is inline, nothing depends on a <style>
|
|
25
|
+
block, and there is no flexbox, grid, CSS variable or dark-mode query;
|
|
26
|
+
* the card is a fixed 600px table with the width attribute set, because
|
|
27
|
+
Outlook's Word engine ignores max-width;
|
|
28
|
+
* charts are <img> pointing at the published PNGs — Gmail strips inline SVG
|
|
29
|
+
— each with alt text, an explicit width, and max-width:100%;height:auto;
|
|
30
|
+
* the spoiler gap is a table row with a fixed height, because Gmail
|
|
31
|
+
collapses stacked <br> and would hand the reader the answers.
|
|
32
|
+
|
|
33
|
+
Zero runtime dependencies: standard library only.
|
|
34
|
+
"""
|
|
35
|
+
import argparse
|
|
36
|
+
import html
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import sys
|
|
41
|
+
import textwrap
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from build_page import classify_move, is_numeric, MOVE_HEADS, SIGNED, \
|
|
45
|
+
FLAT_WORDS, BLANKS, _clean
|
|
46
|
+
except ImportError: # build_page.py sits next to us
|
|
47
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
48
|
+
from build_page import classify_move, is_numeric, MOVE_HEADS, SIGNED, \
|
|
49
|
+
FLAT_WORDS, BLANKS, _clean
|
|
50
|
+
|
|
51
|
+
SHOW = "Soft Commodity Trading"
|
|
52
|
+
SITE_URL = "https://storage.googleapis.com/podcast-audio-2647223968/index.html"
|
|
53
|
+
FEED_URL = ("https://storage.googleapis.com/podcast-audio-2647223968/"
|
|
54
|
+
"commodity-desk-daily/feed.xml")
|
|
55
|
+
|
|
56
|
+
# palette, spelled out because an e-mail cannot have CSS variables
|
|
57
|
+
INK = "#16110c"
|
|
58
|
+
SOFT = "#4a4238"
|
|
59
|
+
FAINT = "#8b8375"
|
|
60
|
+
LINE = "#e3ddd2"
|
|
61
|
+
PAPER = "#faf7f1"
|
|
62
|
+
OUTER = "#ece7db"
|
|
63
|
+
GOLD = "#a8813c"
|
|
64
|
+
ACCENT = "#1d4032"
|
|
65
|
+
UP = "#215c44"
|
|
66
|
+
DOWN = "#8a2f2f"
|
|
67
|
+
SPOILER = "#8a2f2f"
|
|
68
|
+
TINT = "#f4efe4"
|
|
69
|
+
|
|
70
|
+
SERIF = "Georgia,'Times New Roman',Times,serif"
|
|
71
|
+
SANS = "'Helvetica Neue',Helvetica,Arial,sans-serif"
|
|
72
|
+
|
|
73
|
+
PAD = "0 28px" # the content gutter, used on every row
|
|
74
|
+
BODY = f"font-family:{SERIF};font-size:16px;line-height:1.62;color:{INK};"
|
|
75
|
+
KICKER = (f"font-family:{SANS};font-size:11px;line-height:1.4;font-weight:bold;"
|
|
76
|
+
f"letter-spacing:.16em;text-transform:uppercase;color:{GOLD};")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def esc(s):
|
|
80
|
+
return html.escape(s or "", quote=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------- markdown --
|
|
84
|
+
|
|
85
|
+
FENCE = "```"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def inline(text):
|
|
89
|
+
"""Inline markdown → HTML with inline styles only."""
|
|
90
|
+
text = re.sub(r"`([^`]+)`",
|
|
91
|
+
rf'<code style="font-family:Consolas,Menlo,monospace;'
|
|
92
|
+
rf'font-size:14px;background:{TINT};padding:1px 4px;">\1</code>',
|
|
93
|
+
text)
|
|
94
|
+
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
|
95
|
+
text = re.sub(r"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])", r"<em>\1</em>", text)
|
|
96
|
+
text = re.sub(r"(?<![\w_])_(?!\s)(.+?)(?<!\s)_(?![\w_])", r"<em>\1</em>", text)
|
|
97
|
+
text = re.sub(r"\[(.+?)\]\((.+?)\)",
|
|
98
|
+
rf'<a href="\2" style="color:{ACCENT};">\1</a>', text)
|
|
99
|
+
return text
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def plain(text):
|
|
103
|
+
"""Inline markdown → plain text."""
|
|
104
|
+
text = re.sub(r"`([^`]+)`", r"\1", text)
|
|
105
|
+
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
|
106
|
+
text = re.sub(r"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])", r"\1", text)
|
|
107
|
+
text = re.sub(r"(?<![\w_])_(?!\s)(.+?)(?<!\s)_(?![\w_])", r"\1", text)
|
|
108
|
+
text = re.sub(r"\[(.+?)\]\((.+?)\)", r"\1 (\2)", text)
|
|
109
|
+
return text
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def blocks(md):
|
|
113
|
+
"""Markdown → a list of (kind, payload) blocks. One parse feeds both the
|
|
114
|
+
HTML and the plain-text renderer, so they cannot drift apart."""
|
|
115
|
+
out, lines, i = [], md.split("\n"), 0
|
|
116
|
+
while i < len(lines):
|
|
117
|
+
stripped = lines[i].strip()
|
|
118
|
+
if not stripped:
|
|
119
|
+
i += 1
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if stripped.startswith(FENCE + "chart"):
|
|
123
|
+
spec, i = [], i + 1
|
|
124
|
+
while i < len(lines) and not lines[i].strip().startswith(FENCE):
|
|
125
|
+
spec.append(lines[i])
|
|
126
|
+
i += 1
|
|
127
|
+
i += 1
|
|
128
|
+
try:
|
|
129
|
+
spec = json.loads("\n".join(spec))
|
|
130
|
+
except ValueError:
|
|
131
|
+
spec = {}
|
|
132
|
+
# the spec's own title and caption become the alt text: half the
|
|
133
|
+
# readers of any e-mail have images turned off
|
|
134
|
+
out.append(("chart", spec))
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
if stripped.startswith("<") or re.match(r"^( \s*)+$", stripped):
|
|
138
|
+
i += 1 # raw HTML / e-mail spacers
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if re.match(r"^(-{3,}|\*{3,})$", stripped):
|
|
142
|
+
out.append(("rule", None))
|
|
143
|
+
i += 1
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
m = re.match(r"^(#{1,6})\s+(.*)$", stripped)
|
|
147
|
+
if m:
|
|
148
|
+
out.append(("h", (len(m.group(1)), m.group(2))))
|
|
149
|
+
i += 1
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
if stripped.startswith("|") and i + 1 < len(lines) and \
|
|
153
|
+
re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()):
|
|
154
|
+
head = [c.strip() for c in stripped.strip("|").split("|")]
|
|
155
|
+
i += 2
|
|
156
|
+
rows = []
|
|
157
|
+
while i < len(lines) and lines[i].strip().startswith("|"):
|
|
158
|
+
rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
|
|
159
|
+
i += 1
|
|
160
|
+
out.append(("table", (head, rows)))
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if re.match(r"^[-*]\s+", stripped):
|
|
164
|
+
items = []
|
|
165
|
+
while i < len(lines) and re.match(r"^[-*]\s+", lines[i].strip()):
|
|
166
|
+
items.append(re.sub(r"^[-*]\s+", "", lines[i].strip()))
|
|
167
|
+
i += 1
|
|
168
|
+
out.append(("ul", items))
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
if re.match(r"^\d+[.)]\s+", stripped):
|
|
172
|
+
items = []
|
|
173
|
+
while i < len(lines) and re.match(r"^\d+[.)]\s+", lines[i].strip()):
|
|
174
|
+
items.append(re.sub(r"^\d+[.)]\s+", "", lines[i].strip()))
|
|
175
|
+
i += 1
|
|
176
|
+
out.append(("ol", items))
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if stripped.startswith(">"):
|
|
180
|
+
quote = []
|
|
181
|
+
while i < len(lines) and lines[i].strip().startswith(">"):
|
|
182
|
+
quote.append(lines[i].strip().lstrip("> ").rstrip())
|
|
183
|
+
i += 1
|
|
184
|
+
out.append(("quote", quote))
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
para = []
|
|
188
|
+
while i < len(lines) and lines[i].strip() and \
|
|
189
|
+
not re.match(r"^(#{1,6}\s|[-*]\s|\d+[.)]\s|\||>|<|-{3,}|```)",
|
|
190
|
+
lines[i].strip()):
|
|
191
|
+
para.append(lines[i].strip())
|
|
192
|
+
i += 1
|
|
193
|
+
if para:
|
|
194
|
+
out.append(("p", " ".join(para)))
|
|
195
|
+
else:
|
|
196
|
+
i += 1
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------ tables --
|
|
201
|
+
|
|
202
|
+
def column_kinds(head, rows):
|
|
203
|
+
ncol = max([len(head)] + [len(r) for r in rows]) if rows else len(head)
|
|
204
|
+
numeric, moves = [], []
|
|
205
|
+
for c in range(ncol):
|
|
206
|
+
col = [r[c] for r in rows if c < len(r)]
|
|
207
|
+
filled = [x for x in col if x.strip().lower() not in BLANKS]
|
|
208
|
+
title = (head[c].lower().strip(" .:") if c < len(head) else "")
|
|
209
|
+
hits = sum(1 for x in filled if is_numeric(x))
|
|
210
|
+
numeric.append(bool(filled) and hits >= max(1, int(len(filled) * 0.6)))
|
|
211
|
+
signed = sum(1 for x in filled if SIGNED.match(x.strip())
|
|
212
|
+
or any(w in x.lower() for w in FLAT_WORDS))
|
|
213
|
+
moves.append(title in MOVE_HEADS or (bool(filled) and signed == len(filled)))
|
|
214
|
+
return ncol, numeric, moves
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def table_html(head, rows):
|
|
218
|
+
ncol, numeric, moves = column_kinds(head, rows)
|
|
219
|
+
th = []
|
|
220
|
+
for c in range(ncol):
|
|
221
|
+
cell = head[c] if c < len(head) else ""
|
|
222
|
+
align = "right" if (numeric[c] or moves[c]) else "left"
|
|
223
|
+
th.append(
|
|
224
|
+
f'<th align="{align}" style="font-family:{SANS};font-size:11px;'
|
|
225
|
+
f'font-weight:bold;letter-spacing:.09em;text-transform:uppercase;'
|
|
226
|
+
f'color:{SOFT};text-align:{align};padding:0 8px 8px 0;'
|
|
227
|
+
f'border-bottom:1px solid {INK};">{inline(esc(cell))}</th>')
|
|
228
|
+
body = []
|
|
229
|
+
for r in rows:
|
|
230
|
+
tds = []
|
|
231
|
+
for c in range(ncol):
|
|
232
|
+
cell = r[c] if c < len(r) else ""
|
|
233
|
+
align = "right" if (numeric[c] or moves[c]) else "left"
|
|
234
|
+
style = (f"font-family:{SANS};font-size:14px;line-height:1.45;"
|
|
235
|
+
f"color:{INK};padding:9px 8px 9px 0;text-align:{align};"
|
|
236
|
+
f"border-bottom:1px solid {LINE};vertical-align:top;")
|
|
237
|
+
if c == 0:
|
|
238
|
+
style += "font-weight:bold;"
|
|
239
|
+
rendered = inline(esc(cell))
|
|
240
|
+
if moves[c]:
|
|
241
|
+
shown, direction = classify_move(cell)
|
|
242
|
+
if direction:
|
|
243
|
+
if shown != cell:
|
|
244
|
+
rendered = inline(esc(shown))
|
|
245
|
+
colour = {"up": UP, "down": DOWN, "flat": SOFT}[direction]
|
|
246
|
+
rendered = (f'<span style="color:{colour};font-weight:bold;">'
|
|
247
|
+
f"{rendered}</span>")
|
|
248
|
+
tds.append(f'<td align="{align}" style="{style}">{rendered}</td>')
|
|
249
|
+
body.append("<tr>" + "".join(tds) + "</tr>")
|
|
250
|
+
return ('<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
|
251
|
+
'border="0" style="width:100%;border-collapse:collapse;margin:0 0 20px;">'
|
|
252
|
+
f'<thead><tr>{"".join(th)}</tr></thead>'
|
|
253
|
+
f'<tbody>{"".join(body)}</tbody></table>')
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def table_text(head, rows, width=76):
|
|
257
|
+
ncol, numeric, moves = column_kinds(head, rows)
|
|
258
|
+
grid = [[plain(head[c]) if c < len(head) else "" for c in range(ncol)]]
|
|
259
|
+
for r in rows:
|
|
260
|
+
line = []
|
|
261
|
+
for c in range(ncol):
|
|
262
|
+
cell = r[c] if c < len(r) else ""
|
|
263
|
+
if moves[c]:
|
|
264
|
+
shown, direction = classify_move(cell)
|
|
265
|
+
cell = shown if direction else cell
|
|
266
|
+
line.append(plain(cell))
|
|
267
|
+
grid.append(line)
|
|
268
|
+
# Fit the columns inside `width` by shaving the widest one, then wrap
|
|
269
|
+
# rather than truncate: a clipped contract name is worse than a long table.
|
|
270
|
+
widths = [max(len(row[c]) for row in grid) for c in range(ncol)]
|
|
271
|
+
gap = 2
|
|
272
|
+
while sum(widths) + gap * (ncol - 1) > width and max(widths) > 12:
|
|
273
|
+
widths[widths.index(max(widths))] -= 1
|
|
274
|
+
|
|
275
|
+
out = []
|
|
276
|
+
for n, row in enumerate(grid):
|
|
277
|
+
wrapped = [textwrap.wrap(row[c], widths[c]) or [""] for c in range(ncol)]
|
|
278
|
+
for line in range(max(len(w) for w in wrapped)):
|
|
279
|
+
cells = []
|
|
280
|
+
for c in range(ncol):
|
|
281
|
+
txt = wrapped[c][line] if line < len(wrapped[c]) else ""
|
|
282
|
+
right = (numeric[c] or moves[c]) and c > 0
|
|
283
|
+
cells.append(txt.rjust(widths[c]) if right else txt.ljust(widths[c]))
|
|
284
|
+
out.append((" " * gap).join(cells).rstrip())
|
|
285
|
+
if n == 0:
|
|
286
|
+
out.append("-" * (sum(widths) + gap * (ncol - 1)))
|
|
287
|
+
return "\n".join(out)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ------------------------------------------------------------ block render --
|
|
291
|
+
|
|
292
|
+
def render_html(bs, charts, chart_at, base_h=3):
|
|
293
|
+
"""Blocks → e-mail HTML. `chart_at` is the running global chart index."""
|
|
294
|
+
out = []
|
|
295
|
+
for kind, payload in bs:
|
|
296
|
+
if kind == "chart":
|
|
297
|
+
n = chart_at[0]
|
|
298
|
+
chart_at[0] += 1
|
|
299
|
+
url = charts[n] if n < len(charts) else ""
|
|
300
|
+
if not url:
|
|
301
|
+
print(f"[email] no URL for chart {n + 1}; skipped", file=sys.stderr)
|
|
302
|
+
continue
|
|
303
|
+
spec = payload or {}
|
|
304
|
+
# the PNG already carries its own title, caption and source, drawn
|
|
305
|
+
# by chart.py — only the alt text has to be supplied here
|
|
306
|
+
alt = " — ".join(x for x in (spec.get("title"), spec.get("caption"),
|
|
307
|
+
spec.get("source")) if x) \
|
|
308
|
+
or f"Chart {n + 1}"
|
|
309
|
+
out.append(
|
|
310
|
+
f'<table role="presentation" width="100%" cellpadding="0" '
|
|
311
|
+
f'cellspacing="0" border="0" style="margin:6px 0 22px;">'
|
|
312
|
+
f'<tr><td align="center" style="border:1px solid {LINE};'
|
|
313
|
+
f'background:{PAPER};padding:10px;">'
|
|
314
|
+
f'<img src="{esc(url)}" width="522" alt="{esc(alt)}" '
|
|
315
|
+
f'title="{esc(alt)}" '
|
|
316
|
+
f'style="display:block;width:100%;max-width:522px;height:auto;'
|
|
317
|
+
f'border:0;outline:none;text-decoration:none;">'
|
|
318
|
+
f"</td></tr></table>")
|
|
319
|
+
elif kind == "h":
|
|
320
|
+
level, text = payload
|
|
321
|
+
if level <= 3:
|
|
322
|
+
out.append(
|
|
323
|
+
f'<h3 style="margin:26px 0 10px;font-family:{SERIF};'
|
|
324
|
+
f'font-size:19px;line-height:1.3;font-weight:normal;'
|
|
325
|
+
f'color:{INK};">{inline(esc(text))}</h3>')
|
|
326
|
+
else:
|
|
327
|
+
out.append(
|
|
328
|
+
f'<h4 style="margin:20px 0 6px;font-family:{SANS};'
|
|
329
|
+
f'font-size:15px;line-height:1.35;color:{INK};">'
|
|
330
|
+
f"{inline(esc(text))}</h4>")
|
|
331
|
+
elif kind == "p":
|
|
332
|
+
out.append(f'<p style="margin:0 0 16px;{BODY}">{inline(esc(payload))}</p>')
|
|
333
|
+
elif kind in ("ul", "ol"):
|
|
334
|
+
tag = "ul" if kind == "ul" else "ol"
|
|
335
|
+
items = "".join(
|
|
336
|
+
f'<li style="margin:0 0 9px;{BODY}">{inline(esc(it))}</li>'
|
|
337
|
+
for it in payload)
|
|
338
|
+
out.append(f'<{tag} style="margin:0 0 16px;padding-left:22px;">'
|
|
339
|
+
f"{items}</{tag}>")
|
|
340
|
+
elif kind == "quote":
|
|
341
|
+
inner = "<br>".join(inline(esc(q)) for q in payload)
|
|
342
|
+
out.append(
|
|
343
|
+
f'<table role="presentation" width="100%" cellpadding="0" '
|
|
344
|
+
f'cellspacing="0" border="0" style="margin:0 0 20px;"><tr>'
|
|
345
|
+
f'<td style="border-left:3px solid {GOLD};padding:4px 0 4px 16px;'
|
|
346
|
+
f'font-family:{SERIF};font-size:16px;line-height:1.55;'
|
|
347
|
+
f'font-style:italic;color:{SOFT};">{inner}</td></tr></table>')
|
|
348
|
+
elif kind == "table":
|
|
349
|
+
out.append(table_html(*payload))
|
|
350
|
+
elif kind == "rule":
|
|
351
|
+
out.append(
|
|
352
|
+
f'<table role="presentation" width="100%" cellpadding="0" '
|
|
353
|
+
f'cellspacing="0" border="0"><tr><td height="1" '
|
|
354
|
+
f'style="height:1px;line-height:1px;font-size:1px;'
|
|
355
|
+
f'background:{LINE};"> </td></tr>'
|
|
356
|
+
f'<tr><td height="22" style="height:22px;line-height:22px;'
|
|
357
|
+
f'font-size:1px;"> </td></tr></table>')
|
|
358
|
+
return "\n".join(out)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def render_text(bs, charts, chart_at, width=76):
|
|
362
|
+
out = []
|
|
363
|
+
for kind, payload in bs:
|
|
364
|
+
if kind == "chart":
|
|
365
|
+
n = chart_at[0]
|
|
366
|
+
chart_at[0] += 1
|
|
367
|
+
spec = payload or {}
|
|
368
|
+
bits = [x for x in (spec.get("title"), spec.get("caption"),
|
|
369
|
+
spec.get("source")) if x]
|
|
370
|
+
if n < len(charts) and charts[n]:
|
|
371
|
+
bits.append(charts[n])
|
|
372
|
+
if bits:
|
|
373
|
+
out.append(textwrap.fill("[chart] " + " — ".join(bits), width,
|
|
374
|
+
subsequent_indent=" "))
|
|
375
|
+
elif kind == "h":
|
|
376
|
+
text = plain(payload[1])
|
|
377
|
+
out.append(text + "\n" + "-" * min(len(text), width))
|
|
378
|
+
elif kind == "p":
|
|
379
|
+
out.append(textwrap.fill(plain(payload), width))
|
|
380
|
+
elif kind == "ul":
|
|
381
|
+
for it in payload:
|
|
382
|
+
out.append(textwrap.fill(plain(it), width,
|
|
383
|
+
initial_indent=" * ",
|
|
384
|
+
subsequent_indent=" "))
|
|
385
|
+
elif kind == "ol":
|
|
386
|
+
for n, it in enumerate(payload, 1):
|
|
387
|
+
out.append(textwrap.fill(plain(it), width,
|
|
388
|
+
initial_indent=f" {n}. ",
|
|
389
|
+
subsequent_indent=" "))
|
|
390
|
+
elif kind == "quote":
|
|
391
|
+
for q in payload:
|
|
392
|
+
out.append(textwrap.fill(plain(q), width - 2,
|
|
393
|
+
initial_indent=" | ",
|
|
394
|
+
subsequent_indent=" | "))
|
|
395
|
+
elif kind == "table":
|
|
396
|
+
out.append(table_text(*payload, width=width))
|
|
397
|
+
elif kind == "rule":
|
|
398
|
+
out.append("-" * width)
|
|
399
|
+
return "\n\n".join(out)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# --------------------------------------------------------------- sections ---
|
|
403
|
+
|
|
404
|
+
def split_sections(md):
|
|
405
|
+
"""[(title, level, markdown)] for every H2/H3 section, in document order."""
|
|
406
|
+
md = re.sub(r"^#{1,6}\s*.*SOLUTIONS.*$", "## Solutions", md,
|
|
407
|
+
flags=re.MULTILINE | re.IGNORECASE)
|
|
408
|
+
md = re.sub(r"^\s*(▼|▲).*$", "", md, flags=re.MULTILINE)
|
|
409
|
+
md = re.sub(r"\A\s*#\s+.*?\n(##\s+.*?\n)?", "", md, count=1)
|
|
410
|
+
parts, cur = [], None
|
|
411
|
+
for line in md.split("\n"):
|
|
412
|
+
m = re.match(r"^(#{2,3})\s+(.*)$", line.strip())
|
|
413
|
+
if m:
|
|
414
|
+
cur = [m.group(2).strip(), len(m.group(1)), []]
|
|
415
|
+
parts.append(cur)
|
|
416
|
+
elif cur is not None:
|
|
417
|
+
cur[2].append(line)
|
|
418
|
+
# anything before the first heading is the title block: dropped
|
|
419
|
+
return [(t, lv, "\n".join(body).strip()) for t, lv, body in parts]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def classify(title):
|
|
423
|
+
t = title.lower()
|
|
424
|
+
if "market pulse" in t:
|
|
425
|
+
return "pulse"
|
|
426
|
+
if "quiz" in t:
|
|
427
|
+
return "quiz"
|
|
428
|
+
if "solution" in t:
|
|
429
|
+
return "solutions"
|
|
430
|
+
if "in writing" in t or "the episode" in t:
|
|
431
|
+
return "written"
|
|
432
|
+
return "extra"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def group_sections(md):
|
|
436
|
+
"""The notes, sorted into the e-mail's eight fixed slots.
|
|
437
|
+
|
|
438
|
+
Sections are recognised by their heading, not their position, because the
|
|
439
|
+
early episodes ordered them differently. Anything unrecognised (key
|
|
440
|
+
takeaways, vocabulary) stays with the pulse, where the notes put it.
|
|
441
|
+
"""
|
|
442
|
+
out = {"pulse": [], "extra": [], "quiz": [], "solutions": [], "written": []}
|
|
443
|
+
current = None
|
|
444
|
+
for title, level, body in split_sections(md):
|
|
445
|
+
kind = classify(title)
|
|
446
|
+
if kind == "extra" and current in ("written", "quiz", "solutions"):
|
|
447
|
+
kind = current # an H3 inside a big section stays there
|
|
448
|
+
else:
|
|
449
|
+
current = kind
|
|
450
|
+
out[kind].append((title, level, body))
|
|
451
|
+
return out
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def drill(path, index):
|
|
455
|
+
"""One conversion drill out of conversions.md."""
|
|
456
|
+
if not path or not os.path.exists(path):
|
|
457
|
+
return None
|
|
458
|
+
text = open(path, encoding="utf-8").read()
|
|
459
|
+
found = re.split(r"^##\s+Drill\s+(\d+)\s*[—–-]\s*(.*)$", text,
|
|
460
|
+
flags=re.MULTILINE)
|
|
461
|
+
# [preamble, num, title, body, num, title, body, ...]
|
|
462
|
+
for i in range(1, len(found) - 2, 3):
|
|
463
|
+
if int(found[i]) == index:
|
|
464
|
+
return found[i + 1].strip(), found[i + 2].strip()
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def glossary(path):
|
|
469
|
+
terms = []
|
|
470
|
+
if not path or not os.path.exists(path):
|
|
471
|
+
return terms
|
|
472
|
+
for line in open(path, encoding="utf-8").read().splitlines():
|
|
473
|
+
m = re.match(r"^-\s+\*\*(.+?)\*\*\s+—\s+(.+?)(?:\s+_\(ep (\d+)\)_)?$",
|
|
474
|
+
line.strip())
|
|
475
|
+
if m:
|
|
476
|
+
terms.append((m.group(1).strip(), m.group(2).strip(), m.group(3) or ""))
|
|
477
|
+
return terms
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ---------------------------------------------------------------- assembly --
|
|
481
|
+
|
|
482
|
+
def row(inner, pad=PAD, bg=PAPER):
|
|
483
|
+
return (f'<tr><td style="padding:{pad};background:{bg};">{inner}</td></tr>')
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def spacer(height, bg=PAPER):
|
|
487
|
+
return (f'<tr><td height="{height}" style="height:{height}px;'
|
|
488
|
+
f'line-height:{height}px;font-size:1px;background:{bg};">'
|
|
489
|
+
f" </td></tr>")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def kicker(text):
|
|
493
|
+
return f'<p style="margin:0 0 12px;{KICKER}">{esc(text)}</p>'
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def button(url, label, bg=ACCENT, fg=PAPER):
|
|
497
|
+
return ('<table role="presentation" cellpadding="0" cellspacing="0" border="0">'
|
|
498
|
+
f'<tr><td bgcolor="{bg}" style="border-radius:6px;">'
|
|
499
|
+
f'<a href="{esc(url)}" style="display:inline-block;padding:14px 28px;'
|
|
500
|
+
f'font-family:{SANS};font-size:15px;font-weight:bold;color:{fg};'
|
|
501
|
+
f'text-decoration:none;border-radius:6px;">{esc(label)}</a>'
|
|
502
|
+
"</td></tr></table>")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def build(args):
|
|
506
|
+
md = open(args.notes, encoding="utf-8").read()
|
|
507
|
+
groups = group_sections(md)
|
|
508
|
+
charts = [c.strip() for c in (args.charts or "").split(",") if c.strip()]
|
|
509
|
+
terms = glossary(args.glossary)
|
|
510
|
+
idx = args.drill_index or ((args.number - 1) % 12) + 1
|
|
511
|
+
dr = drill(args.drill_file, idx)
|
|
512
|
+
mins, secs = divmod(args.duration, 60)
|
|
513
|
+
length = f"{mins} min {secs:02d}"
|
|
514
|
+
|
|
515
|
+
# charts are numbered across the whole document, in document order, which
|
|
516
|
+
# is the order --charts lists them and the order build_page.py printed them
|
|
517
|
+
at = [0]
|
|
518
|
+
|
|
519
|
+
def sec(items):
|
|
520
|
+
return "\n".join(
|
|
521
|
+
(f'<h3 style="margin:26px 0 10px;font-family:{SERIF};font-size:19px;'
|
|
522
|
+
f'line-height:1.3;font-weight:normal;color:{INK};">{esc(t)}</h3>'
|
|
523
|
+
if lv >= 3 else "") +
|
|
524
|
+
render_html(blocks(body), charts, at)
|
|
525
|
+
for t, lv, body in items)
|
|
526
|
+
|
|
527
|
+
dek = args.dek.strip() or ""
|
|
528
|
+
preheader = dek or f"Episode {args.number}: {args.title}"
|
|
529
|
+
parts = []
|
|
530
|
+
|
|
531
|
+
# 1 — header, with the listen button
|
|
532
|
+
parts.append(row(
|
|
533
|
+
f'<p style="margin:0 0 6px;{KICKER}">{esc(SHOW)}</p>'
|
|
534
|
+
f'<p style="margin:0 0 14px;font-family:{SANS};font-size:11px;'
|
|
535
|
+
f'font-weight:bold;letter-spacing:.14em;text-transform:uppercase;'
|
|
536
|
+
f'color:{FAINT};">Episode {args.number:02d} · {esc(args.date)} '
|
|
537
|
+
f"· {length}</p>"
|
|
538
|
+
f'<h1 style="margin:0 0 12px;font-family:{SERIF};font-size:29px;'
|
|
539
|
+
f'line-height:1.2;font-weight:normal;color:{INK};">{esc(args.title)}</h1>'
|
|
540
|
+
+ (f'<p style="margin:0 0 22px;font-family:{SERIF};font-size:17px;'
|
|
541
|
+
f'line-height:1.5;color:{SOFT};">{esc(dek)}</p>' if dek else "")
|
|
542
|
+
+ button(args.audio_url, f"▶ Listen — {mins} min"),
|
|
543
|
+
pad="30px 28px 24px"))
|
|
544
|
+
|
|
545
|
+
# 2 — read online
|
|
546
|
+
parts.append(row(
|
|
547
|
+
f'<p style="margin:0;font-family:{SANS};font-size:13px;line-height:1.6;'
|
|
548
|
+
f'color:{SOFT};">'
|
|
549
|
+
f'<a href="{esc(args.page_url)}" style="color:{ACCENT};font-weight:bold;">'
|
|
550
|
+
f"Read this episode online →</a>"
|
|
551
|
+
f'<span style="color:{FAINT};"> · charts, the quiz and the '
|
|
552
|
+
f'running glossary</span></p>',
|
|
553
|
+
pad="0 28px 22px"))
|
|
554
|
+
parts.append(rule_row())
|
|
555
|
+
|
|
556
|
+
# 3 — market pulse, with its chart
|
|
557
|
+
if groups["pulse"] or groups["extra"]:
|
|
558
|
+
parts.append(row(kicker("Market pulse") + sec(groups["pulse"])
|
|
559
|
+
+ sec(groups["extra"]), pad="24px 28px 4px"))
|
|
560
|
+
parts.append(rule_row())
|
|
561
|
+
|
|
562
|
+
# 4 — the conversion drill
|
|
563
|
+
if dr:
|
|
564
|
+
title, body = dr
|
|
565
|
+
parts.append(row(
|
|
566
|
+
kicker(f"Conversion drill {idx} of 12")
|
|
567
|
+
+ f'<h3 style="margin:0 0 12px;font-family:{SERIF};font-size:19px;'
|
|
568
|
+
f'line-height:1.3;font-weight:normal;color:{INK};">{esc(title)}</h3>'
|
|
569
|
+
+ render_html(blocks(body), charts, at),
|
|
570
|
+
pad="24px 28px 4px", bg=TINT))
|
|
571
|
+
parts.append(rule_row())
|
|
572
|
+
|
|
573
|
+
# 5 — the quiz
|
|
574
|
+
if groups["quiz"]:
|
|
575
|
+
parts.append(row(kicker("Quiz") + sec(groups["quiz"]),
|
|
576
|
+
pad="24px 28px 4px"))
|
|
577
|
+
|
|
578
|
+
# 6 — the spoiler separator: a fixed-height row, not a stack of <br>
|
|
579
|
+
if groups["solutions"]:
|
|
580
|
+
parts.append(
|
|
581
|
+
f'<tr><td align="center" style="padding:20px 28px;background:{TINT};'
|
|
582
|
+
f'border-top:1px solid {LINE};border-bottom:1px solid {LINE};">'
|
|
583
|
+
f'<p style="margin:0;font-family:{SANS};font-size:12px;'
|
|
584
|
+
f'font-weight:bold;letter-spacing:.12em;text-transform:uppercase;'
|
|
585
|
+
f'color:{SPOILER};">Solutions below — '
|
|
586
|
+
f"answer first</p></td></tr>")
|
|
587
|
+
parts.append(spacer(240))
|
|
588
|
+
parts.append(spacer(240))
|
|
589
|
+
|
|
590
|
+
# 7 — the solutions
|
|
591
|
+
parts.append(row(kicker("Solutions") + sec(groups["solutions"]),
|
|
592
|
+
pad="10px 28px 4px"))
|
|
593
|
+
parts.append(rule_row())
|
|
594
|
+
|
|
595
|
+
# 8 — the written edition, lesson charts inline
|
|
596
|
+
if groups["written"]:
|
|
597
|
+
parts.append(row(kicker("The episode, in writing")
|
|
598
|
+
+ sec(groups["written"]), pad="24px 28px 4px"))
|
|
599
|
+
parts.append(rule_row())
|
|
600
|
+
|
|
601
|
+
# 9 — the cumulative glossary
|
|
602
|
+
if terms:
|
|
603
|
+
rows = []
|
|
604
|
+
for term, definition, ep in terms:
|
|
605
|
+
tag = (f'<span style="font-family:{SANS};font-size:10px;'
|
|
606
|
+
f'letter-spacing:.08em;text-transform:uppercase;'
|
|
607
|
+
f'color:{FAINT};"> ep {ep}</span>' if ep else "")
|
|
608
|
+
rows.append(
|
|
609
|
+
f'<p style="margin:0 0 9px;font-family:{SERIF};font-size:15px;'
|
|
610
|
+
f'line-height:1.5;color:{SOFT};"><strong style="color:{INK};">'
|
|
611
|
+
f"{esc(term)}</strong> — {inline(esc(definition))}{tag}</p>")
|
|
612
|
+
parts.append(row(
|
|
613
|
+
kicker(f"Glossary · {len(terms)} terms")
|
|
614
|
+
+ f'<p style="margin:0 0 16px;font-family:{SANS};font-size:13px;'
|
|
615
|
+
f'line-height:1.6;color:{FAINT};">Everything the show has '
|
|
616
|
+
f"introduced so far.</p>" + "".join(rows),
|
|
617
|
+
pad="24px 28px 8px"))
|
|
618
|
+
|
|
619
|
+
# footer
|
|
620
|
+
parts.append(
|
|
621
|
+
f'<tr><td style="padding:22px 28px 30px;background:{OUTER};'
|
|
622
|
+
f'border-top:1px solid {LINE};">'
|
|
623
|
+
f'<p style="margin:0 0 6px;font-family:{SANS};font-size:12px;'
|
|
624
|
+
f'line-height:1.7;color:{SOFT};"><strong>{esc(SHOW)}</strong> '
|
|
625
|
+
f"— a daily briefing on physical commodity trading.</p>"
|
|
626
|
+
f'<p style="margin:0;font-family:{SANS};font-size:12px;line-height:1.7;'
|
|
627
|
+
f'color:{FAINT};">'
|
|
628
|
+
f'<a href="{esc(SITE_URL)}" style="color:{SOFT};">All episodes</a> '
|
|
629
|
+
f' · <a href="{esc(FEED_URL)}" style="color:{SOFT};">'
|
|
630
|
+
f"Subscribe by RSS</a> · "
|
|
631
|
+
f'<a href="{esc(args.page_url)}" style="color:{SOFT};">This episode '
|
|
632
|
+
f"online</a></p></td></tr>")
|
|
633
|
+
|
|
634
|
+
doc = f"""<!DOCTYPE html>
|
|
635
|
+
<html lang="en">
|
|
636
|
+
<head>
|
|
637
|
+
<meta charset="utf-8">
|
|
638
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
639
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
640
|
+
<meta name="x-apple-disable-message-reformatting">
|
|
641
|
+
<meta name="color-scheme" content="light">
|
|
642
|
+
<meta name="supported-color-schemes" content="light">
|
|
643
|
+
<title>{esc(SHOW)} — Ep {args.number}: {esc(args.title)}</title>
|
|
644
|
+
</head>
|
|
645
|
+
<body style="margin:0;padding:0;background:{OUTER};-webkit-text-size-adjust:100%;">
|
|
646
|
+
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">{esc(preheader)}</div>
|
|
647
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{OUTER};">
|
|
648
|
+
<tr><td align="center" style="padding:20px 10px;">
|
|
649
|
+
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" align="center" style="width:100%;max-width:600px;background:{PAPER};border:1px solid {LINE};">
|
|
650
|
+
{chr(10).join(parts)}
|
|
651
|
+
</table>
|
|
652
|
+
</td></tr>
|
|
653
|
+
</table>
|
|
654
|
+
</body>
|
|
655
|
+
</html>
|
|
656
|
+
"""
|
|
657
|
+
return doc, build_text(args, groups, charts, terms, dr, idx, length)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def rule_row():
|
|
661
|
+
return (f'<tr><td style="padding:0 28px;background:{PAPER};">'
|
|
662
|
+
f'<table role="presentation" width="100%" cellpadding="0" '
|
|
663
|
+
f'cellspacing="0" border="0"><tr><td height="1" style="height:1px;'
|
|
664
|
+
f'line-height:1px;font-size:1px;background:{LINE};"> </td>'
|
|
665
|
+
f"</tr></table></td></tr>")
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def build_text(args, groups, charts, terms, dr, idx, length):
|
|
669
|
+
"""The plain-text alternative, from the same parse and the same order."""
|
|
670
|
+
at = [0]
|
|
671
|
+
W = 76
|
|
672
|
+
L = []
|
|
673
|
+
|
|
674
|
+
def head(title):
|
|
675
|
+
L.append("")
|
|
676
|
+
L.append(title.upper())
|
|
677
|
+
L.append("=" * min(len(title), W))
|
|
678
|
+
L.append("")
|
|
679
|
+
|
|
680
|
+
def body(items):
|
|
681
|
+
for t, lv, md in items:
|
|
682
|
+
if lv >= 3:
|
|
683
|
+
L.append(plain(t))
|
|
684
|
+
L.append("-" * min(len(t), W))
|
|
685
|
+
L.append("")
|
|
686
|
+
L.append(render_text(blocks(md), charts, at, W))
|
|
687
|
+
L.append("")
|
|
688
|
+
|
|
689
|
+
L.append(f"{SHOW.upper()}")
|
|
690
|
+
L.append(f"Episode {args.number:02d} · {args.date} · {length}")
|
|
691
|
+
L.append("")
|
|
692
|
+
L.append(args.title)
|
|
693
|
+
if args.dek.strip():
|
|
694
|
+
|
|
695
|
+
L.append(textwrap.fill(args.dek.strip(), W))
|
|
696
|
+
L.append("")
|
|
697
|
+
L.append(f"Listen: {args.audio_url}")
|
|
698
|
+
L.append(f"Read online: {args.page_url}")
|
|
699
|
+
|
|
700
|
+
if groups["pulse"] or groups["extra"]:
|
|
701
|
+
head("Market pulse")
|
|
702
|
+
body(groups["pulse"])
|
|
703
|
+
body(groups["extra"])
|
|
704
|
+
if dr:
|
|
705
|
+
title, md = dr
|
|
706
|
+
head(f"Conversion drill {idx} of 12 — {title}")
|
|
707
|
+
L.append(render_text(blocks(md), charts, at, W))
|
|
708
|
+
L.append("")
|
|
709
|
+
if groups["quiz"]:
|
|
710
|
+
head("Quiz")
|
|
711
|
+
body(groups["quiz"])
|
|
712
|
+
if groups["solutions"]:
|
|
713
|
+
L.append("")
|
|
714
|
+
L.append("=" * W)
|
|
715
|
+
L.append("SOLUTIONS BELOW — ANSWER FIRST".center(W))
|
|
716
|
+
L.append("=" * W)
|
|
717
|
+
L.extend([""] * 25)
|
|
718
|
+
head("Solutions")
|
|
719
|
+
body(groups["solutions"])
|
|
720
|
+
if groups["written"]:
|
|
721
|
+
head("The episode, in writing")
|
|
722
|
+
body(groups["written"])
|
|
723
|
+
if terms:
|
|
724
|
+
head(f"Glossary — {len(terms)} terms")
|
|
725
|
+
for term, definition, ep in terms:
|
|
726
|
+
L.append(textwrap.fill(
|
|
727
|
+
f"{term} — {plain(definition)}" + (f" (ep {ep})" if ep else ""),
|
|
728
|
+
W, subsequent_indent=" "))
|
|
729
|
+
L.append("")
|
|
730
|
+
L.append("-" * W)
|
|
731
|
+
L.append(f"{SHOW} — a daily briefing on physical commodity trading.")
|
|
732
|
+
L.append(f"All episodes: {SITE_URL}")
|
|
733
|
+
L.append(f"RSS: {FEED_URL}")
|
|
734
|
+
return "\n".join(L) + "\n"
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def main():
|
|
738
|
+
ap = argparse.ArgumentParser(
|
|
739
|
+
description="Render the daily e-mail (HTML + plain text) from epNN.md.")
|
|
740
|
+
ap.add_argument("--notes", required=True)
|
|
741
|
+
ap.add_argument("--number", type=int, required=True)
|
|
742
|
+
ap.add_argument("--title", required=True)
|
|
743
|
+
ap.add_argument("--dek", default="", help="one sentence under the headline")
|
|
744
|
+
ap.add_argument("--audio-url", required=True)
|
|
745
|
+
ap.add_argument("--page-url", required=True)
|
|
746
|
+
ap.add_argument("--duration", type=int, required=True, help="seconds")
|
|
747
|
+
ap.add_argument("--date", default="")
|
|
748
|
+
ap.add_argument("--charts", default="",
|
|
749
|
+
help="comma-separated published PNG URLs, in the order "
|
|
750
|
+
"build_page.py printed CHART_PNG= lines")
|
|
751
|
+
ap.add_argument("--glossary", default="",
|
|
752
|
+
help="glossary.md, for the cumulative glossary at the foot")
|
|
753
|
+
ap.add_argument("--drill-index", type=int, default=0,
|
|
754
|
+
help="which conversion drill; defaults to ((N-1) mod 12)+1")
|
|
755
|
+
ap.add_argument("--drill-file", default="conversions.md")
|
|
756
|
+
ap.add_argument("--out", required=True)
|
|
757
|
+
ap.add_argument("--text-out", default="",
|
|
758
|
+
help="plain-text alternative; defaults to --out with .txt")
|
|
759
|
+
args = ap.parse_args()
|
|
760
|
+
|
|
761
|
+
doc, text = build(args)
|
|
762
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
763
|
+
fh.write(doc)
|
|
764
|
+
txt_path = args.text_out or re.sub(r"\.html?$", "", args.out) + ".txt"
|
|
765
|
+
with open(txt_path, "w", encoding="utf-8") as fh:
|
|
766
|
+
fh.write(text)
|
|
767
|
+
print(f"[email] {args.out} | {len(doc):,} bytes")
|
|
768
|
+
print(f"[email] {txt_path} | {len(text):,} bytes")
|
|
769
|
+
return 0
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
if __name__ == "__main__":
|
|
773
|
+
sys.exit(main())
|