@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_post.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Soft Commodity Trading toolkit — essay builder.
|
|
4
|
+
|
|
5
|
+
The site's Writing section reads `writing/index.json` from the bucket. This
|
|
6
|
+
renders one essay to `writing/<slug>.html`, upserts its entry in that manifest
|
|
7
|
+
and rebuilds `writing/index.html`, so publishing a piece is three uploads and
|
|
8
|
+
no hand-written HTML.
|
|
9
|
+
|
|
10
|
+
python3 build_post.py --notes essay.md --slug basis-is-a-verb \
|
|
11
|
+
--title "Basis Is a Verb" \
|
|
12
|
+
--dek "One sentence under the headline." \
|
|
13
|
+
--date "14 August 2026" --outdir writing
|
|
14
|
+
|
|
15
|
+
Writes:
|
|
16
|
+
writing/basis-is-a-verb.html the essay
|
|
17
|
+
writing/index.json the manifest the site reads
|
|
18
|
+
writing/index.html the standalone index of everything
|
|
19
|
+
|
|
20
|
+
Publish all three with the connector's publish_asset, at those exact paths:
|
|
21
|
+
|
|
22
|
+
publish_asset(path="writing/basis-is-a-verb.html", ...)
|
|
23
|
+
publish_asset(path="writing/index.json", content_type="application/json")
|
|
24
|
+
publish_asset(path="writing/index.html", ...)
|
|
25
|
+
|
|
26
|
+
The essay uses the same renderer, palette and typography as the episode pages,
|
|
27
|
+
so the reading surfaces match. Zero runtime dependencies.
|
|
28
|
+
"""
|
|
29
|
+
import argparse
|
|
30
|
+
import html
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import build_page as bp
|
|
38
|
+
except ImportError:
|
|
39
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
40
|
+
import build_page as bp
|
|
41
|
+
|
|
42
|
+
SITE_URL = bp.SITE_URL
|
|
43
|
+
BUCKET = bp.BUCKET
|
|
44
|
+
WRITING_URL = f"{BUCKET}/writing"
|
|
45
|
+
AUTHOR = bp.AUTHOR
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_time(md):
|
|
49
|
+
words = len(re.findall(r"\S+", re.sub(r"```.*?```", "", md, flags=re.DOTALL)))
|
|
50
|
+
return max(1, round(words / 230))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
POST_TEMPLATE = """<!DOCTYPE html>
|
|
54
|
+
<html lang="en" data-theme="light">
|
|
55
|
+
<head>
|
|
56
|
+
<meta charset="utf-8">
|
|
57
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
58
|
+
<title>{title} · Sébastien Delsad</title>
|
|
59
|
+
<meta name="description" content="{dek_attr}">
|
|
60
|
+
<meta name="author" content="{author}">
|
|
61
|
+
<link rel="canonical" href="{url}">
|
|
62
|
+
<meta property="og:type" content="article">
|
|
63
|
+
<meta property="og:title" content="{title}">
|
|
64
|
+
<meta property="og:description" content="{dek_attr}">
|
|
65
|
+
<meta property="og:url" content="{url}">
|
|
66
|
+
<meta property="og:image" content="{ogimage}">
|
|
67
|
+
<meta property="article:published_time" content="{iso}">
|
|
68
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
69
|
+
<meta name="twitter:image" content="{ogimage}">
|
|
70
|
+
<meta name="theme-color" content="#faf7f1" media="(prefers-color-scheme: light)">
|
|
71
|
+
<meta name="theme-color" content="#14110e" media="(prefers-color-scheme: dark)">
|
|
72
|
+
<link rel="icon" href="{favicon}">
|
|
73
|
+
<script type="application/ld+json">
|
|
74
|
+
{jsonld}
|
|
75
|
+
</script>
|
|
76
|
+
<style>{css}</style>
|
|
77
|
+
<script>{headjs}</script>
|
|
78
|
+
</head>
|
|
79
|
+
<body>
|
|
80
|
+
<div id="bar" aria-hidden="true"></div>
|
|
81
|
+
<button class="themebtn" type="button" aria-label="Switch colour theme">
|
|
82
|
+
<svg class="moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
|
83
|
+
<svg class="sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
|
84
|
+
<span class="lbl"></span>
|
|
85
|
+
</button>
|
|
86
|
+
<div class="wrap">
|
|
87
|
+
<header>
|
|
88
|
+
<div class="masthead">
|
|
89
|
+
<a href="{site}">Sébastien Delsad</a>
|
|
90
|
+
<span class="epno">Writing</span>
|
|
91
|
+
</div>
|
|
92
|
+
<h1>{title}</h1>
|
|
93
|
+
{dekblock}
|
|
94
|
+
<div class="meta">{date}{datesep}<b>{minutes} min read</b></div>
|
|
95
|
+
{toc}
|
|
96
|
+
</header>
|
|
97
|
+
<main>
|
|
98
|
+
{body}
|
|
99
|
+
</main>
|
|
100
|
+
<nav class="epnav" aria-label="More">
|
|
101
|
+
<a class="epprev" href="index.html"><span class="dir">← All writing</span>
|
|
102
|
+
<span class="ept">Essays</span></a>
|
|
103
|
+
<a class="epnext" href="{site}"><span class="dir">Home →</span>
|
|
104
|
+
<span class="ept">Sébastien Delsad</span></a>
|
|
105
|
+
</nav>
|
|
106
|
+
<footer>
|
|
107
|
+
<span class="sig"><b>Sébastien Delsad</b> — commodity trading & data science.</span>
|
|
108
|
+
<span><a href="{site}">Home</a> · <a href="{feed}">Podcast RSS</a></span>
|
|
109
|
+
</footer>
|
|
110
|
+
</div>
|
|
111
|
+
<script>{js}</script>
|
|
112
|
+
</body>
|
|
113
|
+
</html>
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
INDEX_TEMPLATE = """<!DOCTYPE html>
|
|
117
|
+
<html lang="en" data-theme="light">
|
|
118
|
+
<head>
|
|
119
|
+
<meta charset="utf-8">
|
|
120
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
121
|
+
<title>Writing · Sébastien Delsad</title>
|
|
122
|
+
<meta name="description" content="Essays on commodity trading, data science and the machinery behind the daily briefing.">
|
|
123
|
+
<link rel="canonical" href="{url}">
|
|
124
|
+
<meta property="og:type" content="website">
|
|
125
|
+
<meta property="og:title" content="Writing · Sébastien Delsad">
|
|
126
|
+
<meta property="og:description" content="Essays on commodity trading, data science and the machinery behind the daily briefing.">
|
|
127
|
+
<meta property="og:url" content="{url}">
|
|
128
|
+
<meta property="og:image" content="{ogimage}">
|
|
129
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
130
|
+
<meta name="theme-color" content="#faf7f1" media="(prefers-color-scheme: light)">
|
|
131
|
+
<meta name="theme-color" content="#14110e" media="(prefers-color-scheme: dark)">
|
|
132
|
+
<link rel="icon" href="{favicon}">
|
|
133
|
+
<style>{css}
|
|
134
|
+
.postlist{{list-style:none;margin:36px 0 0;padding:0}}
|
|
135
|
+
.postlist li{{border-top:1px solid var(--line);padding:26px 0}}
|
|
136
|
+
.postlist li:last-child{{border-bottom:1px solid var(--line)}}
|
|
137
|
+
.postlist .pm{{font:12px/1 var(--sans);letter-spacing:.12em;text-transform:uppercase;
|
|
138
|
+
color:var(--ink-soft);margin:0 0 9px}}
|
|
139
|
+
.postlist h2{{font:400 25px/1.25 var(--serif);color:var(--ink);margin:0 0 8px;
|
|
140
|
+
border:none;padding:0;font-variant-caps:normal;letter-spacing:-.015em}}
|
|
141
|
+
.postlist h2 a{{text-decoration:none;color:inherit;
|
|
142
|
+
border-bottom:1px solid var(--line)}}
|
|
143
|
+
.postlist h2 a:hover{{border-color:var(--gold)}}
|
|
144
|
+
.postlist p{{color:var(--ink-soft);margin:0;font-size:17px}}
|
|
145
|
+
.nothing{{border:1px dashed var(--line);border-radius:12px;padding:28px 24px;
|
|
146
|
+
margin-top:32px;color:var(--ink-soft)}}
|
|
147
|
+
.nothing p{{margin:0 0 10px}} .nothing p:last-child{{margin:0}}
|
|
148
|
+
</style>
|
|
149
|
+
<script>{headjs}</script>
|
|
150
|
+
</head>
|
|
151
|
+
<body>
|
|
152
|
+
<button class="themebtn" type="button" aria-label="Switch colour theme">
|
|
153
|
+
<svg class="moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
|
154
|
+
<svg class="sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
|
155
|
+
<span class="lbl"></span>
|
|
156
|
+
</button>
|
|
157
|
+
<div class="wrap">
|
|
158
|
+
<header>
|
|
159
|
+
<div class="masthead">
|
|
160
|
+
<a href="{site}">Sébastien Delsad</a>
|
|
161
|
+
<span class="epno">Writing</span>
|
|
162
|
+
</div>
|
|
163
|
+
<h1>Essays</h1>
|
|
164
|
+
<p class="dek">Longer pieces that are not tied to a day's market — notes on
|
|
165
|
+
method, on the machinery behind the shows, and on whatever the reading
|
|
166
|
+
turns up.</p>
|
|
167
|
+
</header>
|
|
168
|
+
<main>
|
|
169
|
+
{list}
|
|
170
|
+
</main>
|
|
171
|
+
<footer>
|
|
172
|
+
<span class="sig"><b>Sébastien Delsad</b> — commodity trading & data science.</span>
|
|
173
|
+
<span><a href="{site}">Home</a> · <a href="{feed}">Podcast RSS</a></span>
|
|
174
|
+
</footer>
|
|
175
|
+
</div>
|
|
176
|
+
<script>{js}</script>
|
|
177
|
+
</body>
|
|
178
|
+
</html>
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def load_manifest(path):
|
|
183
|
+
if not os.path.exists(path):
|
|
184
|
+
return []
|
|
185
|
+
try:
|
|
186
|
+
data = json.load(open(path, encoding="utf-8"))
|
|
187
|
+
except (ValueError, OSError):
|
|
188
|
+
return []
|
|
189
|
+
posts = data if isinstance(data, list) else data.get("posts", [])
|
|
190
|
+
return [p for p in posts if isinstance(p, dict) and p.get("slug")]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def sort_posts(posts):
|
|
194
|
+
return sorted(posts, key=lambda p: (bp.iso_date(p.get("date", "")) or "", p["slug"]),
|
|
195
|
+
reverse=True)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def index_html(posts):
|
|
199
|
+
if not posts:
|
|
200
|
+
return ('<div class="nothing"><p><strong>Nothing published yet.</strong> '
|
|
201
|
+
'The first essay is still in draft.</p>'
|
|
202
|
+
'<p>In the meantime, every episode of <a href="' + SITE_URL +
|
|
203
|
+
'">Soft Commodity Trading</a> carries a written edition — the '
|
|
204
|
+
'same lesson, recomposed for the eye.</p></div>')
|
|
205
|
+
items = []
|
|
206
|
+
for p in posts:
|
|
207
|
+
meta = " · ".join(x for x in (p.get("date", ""),
|
|
208
|
+
f"{p['minutes']} min read" if p.get("minutes") else "")
|
|
209
|
+
if x)
|
|
210
|
+
items.append(
|
|
211
|
+
f'<li>{f"<p class=\'pm\'>{html.escape(meta)}</p>" if meta else ""}'
|
|
212
|
+
f'<h2><a href="{html.escape(p["slug"])}.html">'
|
|
213
|
+
f'{html.escape(p.get("title", p["slug"]))}</a></h2>'
|
|
214
|
+
+ (f'<p>{html.escape(p["dek"])}</p>' if p.get("dek") else "")
|
|
215
|
+
+ "</li>")
|
|
216
|
+
return f'<ol class="postlist">{"".join(items)}</ol>'
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main():
|
|
220
|
+
ap = argparse.ArgumentParser()
|
|
221
|
+
ap.add_argument("--notes", help="the essay in markdown; omit to only "
|
|
222
|
+
"rebuild the index from the manifest")
|
|
223
|
+
ap.add_argument("--slug", default="", help="the filename, without .html")
|
|
224
|
+
ap.add_argument("--title", default="")
|
|
225
|
+
ap.add_argument("--dek", default="")
|
|
226
|
+
ap.add_argument("--date", default="", help='e.g. "14 August 2026"')
|
|
227
|
+
ap.add_argument("--minutes", type=int, default=0)
|
|
228
|
+
ap.add_argument("--outdir", default="writing")
|
|
229
|
+
args = ap.parse_args()
|
|
230
|
+
|
|
231
|
+
os.makedirs(args.outdir, exist_ok=True)
|
|
232
|
+
manifest_path = os.path.join(args.outdir, "index.json")
|
|
233
|
+
posts = load_manifest(manifest_path)
|
|
234
|
+
|
|
235
|
+
if args.notes:
|
|
236
|
+
if not args.slug or not args.title:
|
|
237
|
+
print("--slug and --title are required with --notes", file=sys.stderr)
|
|
238
|
+
return 1
|
|
239
|
+
md = open(args.notes, encoding="utf-8").read()
|
|
240
|
+
md = re.sub(r"\A\s*#\s+.*?\n", "", md, count=1) # the page has a header
|
|
241
|
+
body = bp.tidy_rules(bp.render(md))
|
|
242
|
+
minutes = args.minutes or read_time(md)
|
|
243
|
+
iso = bp.iso_date(args.date)
|
|
244
|
+
url = f"{WRITING_URL}/{args.slug}.html"
|
|
245
|
+
data = {
|
|
246
|
+
"@context": "https://schema.org", "@type": "BlogPosting",
|
|
247
|
+
"headline": args.title, "url": url,
|
|
248
|
+
"description": args.dek or args.title,
|
|
249
|
+
"author": {"@type": "Person", "name": AUTHOR},
|
|
250
|
+
"image": f"{BUCKET}/og.png",
|
|
251
|
+
"wordCount": len(re.findall(r"\S+", md)),
|
|
252
|
+
}
|
|
253
|
+
if iso:
|
|
254
|
+
data["datePublished"] = iso
|
|
255
|
+
page = POST_TEMPLATE.format(
|
|
256
|
+
title=html.escape(args.title),
|
|
257
|
+
dek_attr=html.escape(args.dek or args.title, quote=True),
|
|
258
|
+
dekblock=(f'<p class="dek">{html.escape(args.dek)}</p>'
|
|
259
|
+
if args.dek.strip() else ""),
|
|
260
|
+
date=html.escape(args.date), datesep=" · " if args.date else "",
|
|
261
|
+
minutes=minutes, url=url, iso=iso, site=SITE_URL, feed=bp.FEED_URL,
|
|
262
|
+
ogimage=f"{BUCKET}/og.png", favicon=bp.FAVICON,
|
|
263
|
+
author=html.escape(AUTHOR, quote=True),
|
|
264
|
+
jsonld=json.dumps(data, ensure_ascii=False, indent=2),
|
|
265
|
+
css=bp.CSS, js=bp.JS, headjs=bp.HEAD_JS, toc=bp.toc_html(), body=body)
|
|
266
|
+
out = os.path.join(args.outdir, f"{args.slug}.html")
|
|
267
|
+
open(out, "w", encoding="utf-8").write(page)
|
|
268
|
+
print(f"[post] {out} | {len(page):,} bytes")
|
|
269
|
+
|
|
270
|
+
posts = [p for p in posts if p["slug"] != args.slug]
|
|
271
|
+
posts.append({"slug": args.slug, "title": args.title, "dek": args.dek,
|
|
272
|
+
"date": args.date, "minutes": minutes})
|
|
273
|
+
|
|
274
|
+
posts = sort_posts(posts)
|
|
275
|
+
with open(manifest_path, "w", encoding="utf-8") as fh:
|
|
276
|
+
json.dump(posts, fh, ensure_ascii=False, indent=2)
|
|
277
|
+
fh.write("\n")
|
|
278
|
+
print(f"[post] {manifest_path} | {len(posts)} post(s)")
|
|
279
|
+
|
|
280
|
+
idx = INDEX_TEMPLATE.format(
|
|
281
|
+
url=f"{WRITING_URL}/index.html", site=SITE_URL, feed=bp.FEED_URL,
|
|
282
|
+
ogimage=f"{BUCKET}/og.png", favicon=bp.FAVICON,
|
|
283
|
+
css=bp.CSS, js=bp.JS, headjs=bp.HEAD_JS, list=index_html(posts))
|
|
284
|
+
idx_path = os.path.join(args.outdir, "index.html")
|
|
285
|
+
open(idx_path, "w", encoding="utf-8").write(idx)
|
|
286
|
+
print(f"[post] {idx_path} | {len(idx):,} bytes")
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
sys.exit(main())
|