我最近开始发送每周一次的电子邮件时事通讯,其中包含我博客中的内容。我已经大部分自动化了,使用Observable Notebook生成 HTML。这是该系统的工作原理。
我的时事通讯里有什么
我的博客有三种类型的内容:条目、博客标记和引用。 “Blogmarks”是我在 2003 年为书签起的名字。
博客标记和引用显示在我博客的侧边栏中,条目显示在主栏中——但在移动设备上,这三者合并为一个流程。
它们存在于由 Django 管理的 PostgreSQL 数据库中。您可以在我博客的开源存储库的models.py 中看到它们的定义。
我的时事通讯包含自上次发送以来的所有新条目、博客标记和引文。我将条目按时间倒序放在第一位,因为通常我刚写的条目就是我想用于电子邮件主题的条目。博客标记和引用按时间顺序排列。
我包含了所有内容的完整 HTML:人们不需要点击回到我的博客来阅读它,所有内容都应该就在他们的电子邮件客户端中。
子堆栈 API:RSS 和复制粘贴
Substack 尚未提供 API,也没有这样做的公开计划。
他们确实提供了每个时事通讯的 RSS 提要 – 添加/feed
到时事通讯子域以获取它。我的位于https://simonw.substack.com/feed 。
所以我们可以再次取回数据……但是如何获取数据呢?我不想从所有这些不同的数据源手动组装新闻通讯。
这就是复制粘贴的用武之地。
Substack 组合编辑器包含一个精心打造的富文本编辑器。您可以将内容粘贴到其中,它会对其进行清理以适应 Substack 支持的 HTML 子集……但这是一个相当不错的子集。标题、段落、列表、链接、代码块和图像都受支持。
我博客上的绝大多数内容都恰好适合该子集。
至关重要的是,将图像作为富文本内容的一部分粘贴是可行的:Substack 会自动将任何图像复制到其substack-post-media
S3 存储桶中,并将指向其 CDN 的链接嵌入到时事通讯的正文中。
所以…如果我可以为我的整个时事通讯生成预期的富文本 HTML,我可以将它直接复制并粘贴到 Substack 中。
这正是我的新 Observable 笔记本所做的: https ://observablehq.com/@simonw/blog-to-newsletter
生成 HTML 是一条老生常谈的路径,但我还想要一个“复制到剪贴板”按钮,该按钮可以复制该 HTML 的富文本版本,以便将其粘贴到 Substack 中。
在MDN和ChatGPT(我的 TIL)的帮助下,我发现了以下内容:
函数copyRichText ( html ) { 常量htmlContent = html ; // 创建一个临时元素来保存 HTML 内容 const tempElement =文件。 createElement ( "div" ) ; 临时元素。 innerHTML = html内容; 文件。身体。 appendChild (临时元素) ; // 选择 HTML 内容 常量范围=文件。创建范围( ) ; 范围。选择节点(临时元素) ; // 将选中的 HTML 内容复制到剪贴板 常量选择=窗口。获取选择( ) ; 选择。 removeAllRanges ( ) ; 选择。添加范围(范围) ; 文件。 execCommand ( “复制” ) ; 选择。 removeAllRanges ( ) ; 文件。身体。 removeChild ( tempElement ) ; }
这很好用!设置一个触发该功能的按钮,然后单击该按钮会将 HTML 的富文本版本复制到剪贴板,以便将其直接粘贴到 Substack 编辑器中具有所需的效果。
组装 HTML
我喜欢将Observable Notebooks用于此类项目:需要 UI 的快速数据集成工具,并且可能会随着时间的推移而逐步改进。
对这些使用 Observable 意味着我不需要托管任何东西,我可以非常快速地迭代到正确的解决方案。
首先,我需要检索我的条目、博客标记和引文。
我从来没有直接为我的 Django 博客构建 API,但不久前我设置了一种机制,将我的博客内容导出到我的 simonwillisonblog-backup GitHub 存储库以确保安全,然后将该数据的 Datasette/SQLite 副本部署到https //数据集.simonwillison.net / 。
Datasette提供了一个用于查询该数据的 JSON API,并公开了开放的 CORS 标头,这意味着在 Observable 中运行的 JavaScript 可以直接查询它。
下面是一个针对该 Datasette 实例运行的 SQL 查询示例– 单击该页面上的.json
链接以取回 JSON 格式的数据。
然后,我的 Observable notebook 可以检索构建时事通讯的 HTML 所需的确切数据。
明智的做法是从 API 检索数据,然后在 Observable 中使用 JavaScript 将这些数据组合成 HTML 用于时事通讯。
我决定挑战自己,改用 SQL 完成大部分工作,并想出了以下绝对的查询怪物:
内容为( 选择 ' entry '作为类型,标题,已创建,slug, ' <h3><a href=" ' || ' https://simonwillison.net/ ' || strftime( ' %Y/ ' , 已创建) || substr( ' JanFebMarAprMayJunJulAugSepOctNovDec ' , (strftime( ' %m ' , created) - 1 ) * 3 + 1 , 3 ) || ' / ' || cast(strftime( ' %d ' , created) as integer ) || ' / ' ||鼻涕虫|| ' / ' || ' "> ' ||标题|| ' </a> - ' ||日期(创建) || ' </h3> ' ||身体 作为HTML, ' '作为external_url 来自博客条目 联合所有 选择 ' blogmark '作为类型, link_title,已创建,slug, ' <p><strong>链接</strong> ' ||日期(创建) || ' <a href=" ' || link_url || ' "> ' ||链接标题|| ' </a>: ' || ' ' ||评论|| ' </p> ' 作为HTML, link_url作为external_url 来自blog_blogmark 联合所有 选择 '引号'作为类型, 来源, 创建, 鼻涕虫, ' <strong>引用</strong> ' ||日期(创建) || ' <blockquote><p><em> ' ||替换(引号, ' ' , ' <br> ' ) || ' </em></p></blockquote><p><a href=" ' || 合并(source_url, ' # ' ) || ' "> ' ||来源|| ' </a></p> ' 作为HTML, source_url作为external_url 来自blog_quotation ), 收集为( 选择 类型, 标题, ' https://simonwillison.net/ ' || strftime( ' %Y/ ' , 已创建) || substr( ' JanFebMarAprMayJunJulAugSepOctNovDec ' , (strftime( ' %m ' , created) - 1 ) * 3 + 1 , 3 ) || ' / ' || cast(strftime( ' %d ' , created) as integer ) || ' / ' ||鼻涕虫|| ' / ' 作为网址, 创建, 网页, 外部网址 从内容 where created >= date ( ' now ' , ' - ' || :numdays || ' days ' ) 按创建的描述排序 ) 选择类型、标题、url、已创建、html、external_url 来自收集 订购方式 案例类型 当'进入'然后0 否则1 结尾, 案例类型 当'条目'然后创建 else - strftime( “ %s ” ,已创建) 结束描述
这真的应该在 JavaScript 中!
那里有很多技巧,但我最喜欢的是这个:
选择“ https://simonwillison.net/ ” || strftime( ' %Y/ ' , 已创建) ||子串( ' JanFebMarAprMayJunJulAugSepOctNovDec ' , (strftime( ' %m ' , 创建) - 1 ) * 3 + 1 , 3 ) || ' / ' || cast(strftime( ' %d ' , created) as integer ) || ' / ' ||鼻涕虫|| ' / ' 作为网址
这是我用来为每个条目、博客标记和引用生成 URL 的技巧。
这些作为日期时间值存储在数据库中,但最终的 URL 如下所示:
https://simonwillison.net/2023/Apr/2/calculator-for-words/
所以我需要将该日期转换为 YYYY/Mon/DD URL 组件。
一个问题:SQLite 没有生成三个字母月份缩写的日期格式字符串。但是……通过巧妙地应用substr()
函数和所有月份缩写的字符串,我可以获得我需要的东西。
上面的 SQL 查询加上一点点 JavaScript 几乎提供了我为我的时事通讯生成 HTML 所需的一切。
排除之前发送的内容
还有最后一个问题需要解决:我想发送一份时事通讯,其中包含自上一版以来的所有新内容——我不想发送相同的内容两次。
我也想出了一个令人愉快的粗糙解决方案。
如前所述,Substack 提供了以前版本的 RSS 提要。我可以使用该数据来避免包含已发送的内容。
一个问题:Substack RSS 提要不包含 CORS 标头,这意味着我无法直接从我的笔记本访问它。
GitHub 为每个存储库中的每个文件提供 CORS 标头。我已经有一个用于备份我的博客的存储库…那么为什么不将其设置为也从 Substack 备份我的 RSS 提要呢?
我将其添加到现有的backup.yml
GitHub Actions 工作流中:
-名称:备份子堆栈 运行: |- 卷曲 'https://simonw.substack.com/feed' | \ python -c "import sys, xml.dom.minidom; print(xml.dom.minidom.parseString(sys.stdin.read()).toprettyxml(indent=' '))" \ > simonw-substack-com.xml
我在这里通过一个小的 Python 脚本将其管道化,以便在保存之前漂亮地打印 XML,因为漂亮打印的 XML 稍后更容易阅读差异。
现在simonw-substack-com.xml是我在 GitHub 存储库中的 RSS 提要的副本,这意味着我可以直接从 Observable 上运行的 JavaScript 访问数据。
这是我在那里写的代码,用于获取 RSS 提要,将其解析为 XML 并返回一个仅包含所有帖子的 HTML 的字符串:
以前的通讯= { const response = await fetch ( “https://raw.githubusercontent.com/simonw/simonwillisonblog-backup/main/simonw-substack-com.xml” ) ; const rss =等待响应。文字( ) ; const parser = new DOMParser ( ) ; 常量xmlDoc =解析器。 parseFromString ( rss , "应用程序/xml" ) ; const xpathExpression = "//content:encoded" ; const namespaceResolver = ( prefix ) => { 常数ns = { 内容: “http://purl.org/rss/1.0/modules/content/” } ; 返回ns [前缀] ||空; } ; 常量结果= xmlDoc 。评估( xpath表达式, 文档, 命名空间解析器, XPath 结果。任何类型, 无效的 ) ; 让节点; 让文本= [ ] ; while ( ( node = result .iterateNext ( ) ) ) { _ 文字。推送(节点。文本内容) ; } 返回文本。加入( “\n” ) ; }
然后我扩展一个正则表达式以从该 HTML 中提取所有 URL:
以前的链接= { const regex = / (?: " | " ) ( https?: \/ \/ [ ^ \s "<> ] + ) (?: " | " ) / g ; 返回数组。 from ( previousNewsletters.matchAll ( regex ) , ( match ) = > match [ 1 ] ) ; _ }
在我的笔记本中添加了一个“跳过现有”切换复选框:
查看skipExisting =输入。切换( { label : "跳过之前通讯中发送的内容" } )
并添加此代码以根据是否选择切换来过滤原始内容:
内容= skipExisting ?原始内容。过滤器( ( e ) => !以前的链接。包括( e . url ) && !以前的链接。包括(即。external_url ) _ ) :原始内容
url
是我博客上帖子的 URL。 external_url
是博客标记或引用的原始来源的 URL。与以太币的匹配应该排除我的下一份时事通讯的内容。
我发送时事通讯的工作流程
考虑到以上所有情况,发送时事通讯几乎没有任何作用:
- 确保我的博客的最新备份已运行,以便 Datasette 实例包含我的最新内容。我通过触发这个动作来做到这一点。
- 导航至https://observablehq.com/@simonw/blog-to-newsletter – 选择“跳过之前新闻通讯中发送的内容”,然后单击“将富文本新闻通讯复制到剪贴板”按钮。
- 导航到子堆栈“发布”界面并将该内容粘贴到富文本编辑器中。
- 选择一个标题和副标题,并可能添加一些介绍性文字。
- 预览它。如果预览看起来不错,请点击“发送”。
复制和粘贴 API
我认为复制和粘贴作为一种 API 机制被低估了。
无需担心速率限制或 API 密钥。
几乎所有应用程序都支持它,甚至是那些对 API 集成有抵抗力的应用程序。
它甚至在手机上也能很好地工作,尤其是当您包含“复制到剪贴板”按钮时。
我的 Datasette数据集可复制插件是我早期对此的探索之一。它可以很容易地以各种有用的格式从 Datasette 中复制数据。
这个 Observable 时事通讯项目进一步让我相信,剪贴板是一种未被充分利用的机制,用于构建工具以帮助以创造性的方式将数据整合在一起。
原文: http://simonwillison.net/2023/Apr/4/substack-observable/#atom-everything