automatic 14.12.2 → 26.08
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.
- checksums.yaml +5 -5
- data/README.md +635 -83
- data/VERSION +1 -1
- data/automatic.gemspec +109 -248
- data/bin/automatic +20 -139
- data/config/feed2console.yml +10 -4
- data/config/feed2markdown.yml +41 -0
- data/doc/AI_TUTORIAL.md +518 -0
- data/doc/BASIC_DESIGN.md +516 -0
- data/doc/COPYING.LESSER +165 -0
- data/doc/DEPLOYMENT.md +824 -0
- data/doc/LICENSE.md +14 -0
- data/doc/PLUGINS.md +1875 -0
- data/doc/PLUGIN_DEVELOPMENT.md +86 -0
- data/doc/POLICY.md +857 -0
- data/doc/QUICKSTART.md +256 -0
- data/doc/RELEASING.md +381 -0
- data/doc/REQUIREMENTS.md +526 -0
- data/doc/VERSIONS +208 -0
- data/lib/automatic/cli.rb +248 -0
- data/lib/automatic/environment.rb +31 -5
- data/lib/automatic/feed_maker.rb +10 -9
- data/lib/automatic/feed_parser.rb +51 -35
- data/lib/automatic/http.rb +107 -0
- data/lib/automatic/log.rb +49 -18
- data/lib/automatic/opml.rb +3 -1
- data/lib/automatic/pipeline.rb +63 -32
- data/lib/automatic/recipe.rb +56 -17
- data/lib/automatic/version.rb +14 -1
- data/lib/automatic.rb +78 -20
- data/plugins/custom_feed/svn_log.rb +73 -32
- data/plugins/custom_feed/web.rb +348 -0
- data/plugins/filter/absolute_uri.rb +43 -27
- data/plugins/filter/accept.rb +38 -45
- data/plugins/filter/claude.rb +217 -0
- data/plugins/filter/clear.rb +12 -8
- data/plugins/filter/description_link.rb +49 -51
- data/plugins/filter/full_feed.rb +158 -52
- data/plugins/filter/gemini.rb +216 -0
- data/plugins/filter/github_feed.rb +38 -26
- data/plugins/filter/ignore.rb +33 -44
- data/plugins/filter/image.rb +36 -25
- data/plugins/filter/image_source.rb +58 -52
- data/plugins/filter/join.rb +107 -0
- data/plugins/filter/one.rb +19 -26
- data/plugins/filter/open_ai.rb +198 -0
- data/plugins/filter/rand.rb +16 -17
- data/plugins/filter/sakura_ai.rb +205 -0
- data/plugins/filter/sanitize.rb +29 -34
- data/plugins/filter/sort.rb +20 -27
- data/plugins/filter/tumblr_resize.rb +31 -23
- data/plugins/notify/ikachan.rb +86 -48
- data/plugins/provide/fluentd.rb +43 -24
- data/plugins/publish/amazon_s3.rb +73 -40
- data/plugins/publish/console.rb +19 -16
- data/plugins/publish/console_link.rb +20 -17
- data/plugins/publish/eject.rb +48 -26
- data/plugins/publish/fluentd.rb +50 -30
- data/plugins/publish/hatena_bookmark.rb +88 -71
- data/plugins/publish/instapaper.rb +69 -59
- data/plugins/publish/markdown.rb +278 -0
- data/plugins/publish/memcached.rb +35 -29
- data/plugins/store/database.rb +50 -48
- data/plugins/store/digest.rb +212 -0
- data/plugins/store/file.rb +99 -68
- data/plugins/store/full_text.rb +32 -25
- data/plugins/store/permalink.rb +18 -22
- data/plugins/subscription/feed.rb +34 -21
- data/plugins/subscription/link.rb +31 -32
- data/plugins/subscription/text.rb +32 -46
- data/plugins/subscription/tumblr.rb +55 -44
- data/plugins/subscription/xml.rb +40 -36
- metadata +108 -321
- data/Gemfile +0 -38
- data/Rakefile +0 -59
- data/doc/ChangeLog +0 -303
- data/doc/PLUGINS +0 -750
- data/doc/PLUGINS.ja +0 -753
- data/doc/README +0 -511
- data/doc/README.ja +0 -519
- data/plugins/filter/google_news.rb +0 -50
- data/plugins/publish/google_calendar.rb +0 -84
- data/plugins/publish/hipchat.rb +0 -46
- data/plugins/publish/pocket.rb +0 -45
- data/plugins/publish/twitter.rb +0 -58
- data/plugins/subscription/chan_toru.rb +0 -57
- data/plugins/subscription/g_guide.rb +0 -57
- data/plugins/subscription/pocket.rb +0 -51
- data/plugins/subscription/twitter.rb +0 -69
- data/plugins/subscription/twitter_search.rb +0 -50
- data/plugins/subscription/weather.rb +0 -33
- data/script/build +0 -84
- data/spec/fixtures/sampleFeeds.tsv +0 -1
- data/spec/fixtures/sampleFeeds2.tsv +0 -2
- data/spec/fixtures/sampleRecipe.yml +0 -24
- data/spec/lib/automatic/log_spec.rb +0 -32
- data/spec/lib/automatic/pipeline_spec.rb +0 -68
- data/spec/lib/automatic/recipe_spec.rb +0 -40
- data/spec/lib/automatic_spec.rb +0 -99
- data/spec/plugins/custom_feed/svn_log_spec.rb +0 -31
- data/spec/plugins/filter/absolute_uri_spec.rb +0 -61
- data/spec/plugins/filter/accept_spec.rb +0 -331
- data/spec/plugins/filter/clear_spec.rb +0 -49
- data/spec/plugins/filter/description_link_spec.rb +0 -138
- data/spec/plugins/filter/full_feed_spec.rb +0 -129
- data/spec/plugins/filter/github_feed_spec.rb +0 -55
- data/spec/plugins/filter/google_news_spec.rb +0 -69
- data/spec/plugins/filter/ignore_spec.rb +0 -328
- data/spec/plugins/filter/image_source_spec.rb +0 -89
- data/spec/plugins/filter/image_spec.rb +0 -65
- data/spec/plugins/filter/one_spec.rb +0 -71
- data/spec/plugins/filter/rand_spec.rb +0 -52
- data/spec/plugins/filter/sanitize_spec.rb +0 -153
- data/spec/plugins/filter/sort_spec.rb +0 -189
- data/spec/plugins/filter/tumblr_resize_spec.rb +0 -109
- data/spec/plugins/notify/ikachan_spec.rb +0 -58
- data/spec/plugins/provide/fluentd_spec.rb +0 -49
- data/spec/plugins/publish/amazon_s3_spec.rb +0 -40
- data/spec/plugins/publish/console_spec.rb +0 -30
- data/spec/plugins/publish/eject_spec.rb +0 -40
- data/spec/plugins/publish/fluentd_spec.rb +0 -40
- data/spec/plugins/publish/google_calendar_spec.rb +0 -83
- data/spec/plugins/publish/hatena_bookmark_spec.rb +0 -134
- data/spec/plugins/publish/hipchat_spec.rb +0 -69
- data/spec/plugins/publish/instapaper_spec.rb +0 -82
- data/spec/plugins/publish/memcached_spec.rb +0 -63
- data/spec/plugins/publish/pocket_spec.rb +0 -51
- data/spec/plugins/publish/twitter_spec.rb +0 -73
- data/spec/plugins/store/file_spec.rb +0 -58
- data/spec/plugins/store/full_text_spec.rb +0 -152
- data/spec/plugins/store/permalink_spec.rb +0 -206
- data/spec/plugins/subscription/chan_toru_spec.rb +0 -56
- data/spec/plugins/subscription/feed_spec.rb +0 -71
- data/spec/plugins/subscription/g_guide_spec.rb +0 -82
- data/spec/plugins/subscription/link_spec.rb +0 -72
- data/spec/plugins/subscription/pocket_spec.rb +0 -57
- data/spec/plugins/subscription/text_spec.rb +0 -84
- data/spec/plugins/subscription/tumblr_spec.rb +0 -74
- data/spec/plugins/subscription/twitter_search_spec.rb +0 -57
- data/spec/plugins/subscription/twitter_spec.rb +0 -73
- data/spec/plugins/subscription/weather_spec.rb +0 -44
- data/spec/plugins/subscription/xml_spec.rb +0 -84
- data/spec/spec_helper.rb +0 -106
- data/spec/user_dir/plugins/store/mock.rb +0 -16
- data/test/fixtures/sampleOPML.xml +0 -11
- data/test/integration/test_absoluteurl.yml +0 -25
- data/test/integration/test_activerecord.yml +0 -24
- data/test/integration/test_add_pocket.yml +0 -26
- data/test/integration/test_chan_toru.yml +0 -21
- data/test/integration/test_descriptionlink.yml +0 -21
- data/test/integration/test_fluentd.yml +0 -22
- data/test/integration/test_fulltext.yml +0 -30
- data/test/integration/test_google_news.yml +0 -21
- data/test/integration/test_googlealert.yml +0 -21
- data/test/integration/test_hatenabookmark.yml +0 -30
- data/test/integration/test_ignore.yml +0 -25
- data/test/integration/test_ignore2.yml +0 -22
- data/test/integration/test_image2local.yml +0 -33
- data/test/integration/test_instapaper.yml +0 -26
- data/test/integration/test_link2local.yml +0 -34
- data/test/integration/test_one.yml +0 -23
- data/test/integration/test_pocket.yml +0 -22
- data/test/integration/test_rand.yml +0 -21
- data/test/integration/test_sanitize.yml +0 -23
- data/test/integration/test_sort.yml +0 -36
- data/test/integration/test_svnlog.yml +0 -15
- data/test/integration/test_text2feed.yml +0 -36
- data/test/integration/test_tumblr2local.yml +0 -43
- data/test/integration/test_twitter_search.yml +0 -22
- data/test/integration/test_weather.yml +0 -19
- data/test/integration/test_xml2fluentd.yml +0 -21
- data/vendor/.gitkeep +0 -0
data/doc/AI_TUTORIAL.md
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# AI Tutorial
|
|
2
|
+
|
|
3
|
+
This tutorial builds one Recipe that reads a set of public index pages, collects
|
|
4
|
+
the articles they list, turns them into a single text, asks an AI service one
|
|
5
|
+
question about that text, and writes the answer out as Markdown. It is built up
|
|
6
|
+
one plugin at a time, and every stage in between is a Recipe that runs and
|
|
7
|
+
produces something a person can read.
|
|
8
|
+
|
|
9
|
+
It assumes [`QUICKSTART.md`](QUICKSTART.md) has been followed once, so that
|
|
10
|
+
`automatic` runs, `~/.automatic` exists, and the habit of asking "which plugins
|
|
11
|
+
does this Recipe name, and what do they need" is already in place. What each
|
|
12
|
+
plugin does in full is [`PLUGINS.md`](PLUGINS.md) section 6; this document is
|
|
13
|
+
the order they go in and why.
|
|
14
|
+
|
|
15
|
+
One claim is worth stating before the first Recipe, because it is what the
|
|
16
|
+
tutorial is for:
|
|
17
|
+
|
|
18
|
+
**An AI filter is one filter in a pipeline.** It has no privileged position, it
|
|
19
|
+
is not the point of the Recipe, and it is not what makes the Recipe useful. It
|
|
20
|
+
takes a text, returns a text, and sits between two plugins that neither know nor
|
|
21
|
+
care that it is there. Everything before it is worth running on its own, and the
|
|
22
|
+
whole design follows from that.
|
|
23
|
+
|
|
24
|
+
## 1. The finished shape
|
|
25
|
+
|
|
26
|
+
This is where the tutorial ends:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
CustomFeedWeb find the articles the index pages list
|
|
30
|
+
↓
|
|
31
|
+
StorePermalink drop the links already seen
|
|
32
|
+
↓
|
|
33
|
+
FilterFullFeed replace each summary with the article body
|
|
34
|
+
↓
|
|
35
|
+
FilterSanitize reduce the body to plain text
|
|
36
|
+
↓
|
|
37
|
+
FilterJoin make one text out of every article
|
|
38
|
+
↓
|
|
39
|
+
FilterSakuraAI ask one question about that text
|
|
40
|
+
↓
|
|
41
|
+
PublishMarkdown write the answer to a document
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Seven plugins, each doing one thing, each handing its result to the next. The
|
|
45
|
+
Recipe that expresses it is in section 4, and nothing in it is a special case:
|
|
46
|
+
every entry is a plugin the framework loads by name, and the order of the list
|
|
47
|
+
is the order of the work.
|
|
48
|
+
|
|
49
|
+
The store plugin's position is not an aesthetic choice. `FilterJoin` produces
|
|
50
|
+
one item with **no link** — it is several articles at once, so there is no page
|
|
51
|
+
it points at — and the store plugins are keyed on the link and drop an item
|
|
52
|
+
without one. `StorePermalink`, `StoreFullText` and `StoreDigest` therefore
|
|
53
|
+
belong **before** `FilterJoin`, where there is still one item per article to
|
|
54
|
+
record. Put one after it and the Recipe stores nothing and publishes nothing.
|
|
55
|
+
|
|
56
|
+
## 2. Build it without AI first
|
|
57
|
+
|
|
58
|
+
Write this to `~/.automatic/config/ai-digest.yml`. It is the finished Recipe
|
|
59
|
+
without `FilterJoin` and without the AI filter:
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
plugins:
|
|
63
|
+
- module: CustomFeedWeb
|
|
64
|
+
config:
|
|
65
|
+
retry: 2
|
|
66
|
+
interval: 2
|
|
67
|
+
sites:
|
|
68
|
+
- name: Python Insider
|
|
69
|
+
url: https://blog.python.org/
|
|
70
|
+
link_selector: 'a[href]'
|
|
71
|
+
include:
|
|
72
|
+
- '^https://blog\.python\.org/20[0-9]{2}/[0-9]{2}/[^/]+/?$'
|
|
73
|
+
fetch_items: 20
|
|
74
|
+
|
|
75
|
+
- name: Rust Blog
|
|
76
|
+
url: https://blog.rust-lang.org/
|
|
77
|
+
link_selector: 'a[href]'
|
|
78
|
+
include:
|
|
79
|
+
- '^https://blog\.rust-lang\.org/20[0-9]{2}/[0-9]{2}/[0-9]{2}/[^/]+/?$'
|
|
80
|
+
fetch_items: 20
|
|
81
|
+
|
|
82
|
+
- name: The Go Blog
|
|
83
|
+
url: https://go.dev/blog/
|
|
84
|
+
link_selector: 'a[href]'
|
|
85
|
+
include:
|
|
86
|
+
- '^https://go\.dev/blog/[^/]+$'
|
|
87
|
+
fetch_items: 20
|
|
88
|
+
|
|
89
|
+
- module: StorePermalink
|
|
90
|
+
config:
|
|
91
|
+
db: ai-digest.db
|
|
92
|
+
|
|
93
|
+
- module: FilterFullFeed
|
|
94
|
+
config:
|
|
95
|
+
siteinfo: items_all.json
|
|
96
|
+
|
|
97
|
+
- module: FilterSanitize
|
|
98
|
+
config:
|
|
99
|
+
mode: restricted
|
|
100
|
+
|
|
101
|
+
- module: PublishMarkdown
|
|
102
|
+
config:
|
|
103
|
+
file: ~/.automatic/markdown/ai-digest.md
|
|
104
|
+
mode: append
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
What each plugin is responsible for, and nothing more than that:
|
|
108
|
+
|
|
109
|
+
- **`CustomFeedWeb`** makes one HTTP request per site, reads the index page as
|
|
110
|
+
HTML, and builds a feed from the article links it lists. `include` is what
|
|
111
|
+
tells an article from a navigation link. It does not follow those links and
|
|
112
|
+
does not read an article body.
|
|
113
|
+
- **`StorePermalink`** records each item's link in SQLite and passes on only the
|
|
114
|
+
links it had not recorded already. It is what makes the Recipe safe to run
|
|
115
|
+
from `cron`, and — put here, ahead of everything that costs anything — it is
|
|
116
|
+
also what keeps the later plugins from working on an article twice. `db` is a
|
|
117
|
+
file name under `~/.automatic/db`.
|
|
118
|
+
- **`FilterFullFeed`** takes the link of each surviving item, finds a siteinfo
|
|
119
|
+
record whose URL pattern matches it, fetches the page, and replaces the
|
|
120
|
+
summary with the body that record's XPath selects.
|
|
121
|
+
- **`FilterSanitize`** strips the markup out of the description, leaving text.
|
|
122
|
+
- **`PublishMarkdown`** writes one level-2 heading per item, with its metadata
|
|
123
|
+
and its body, to a document.
|
|
124
|
+
|
|
125
|
+
The Recipe's dependencies are the union of its plugins', as always:
|
|
126
|
+
`CustomFeedWeb` and `FilterFullFeed` need `nokogiri`, `StorePermalink` needs
|
|
127
|
+
`activerecord` and `sqlite3`, `FilterSanitize` needs `sanitize`, and
|
|
128
|
+
`PublishMarkdown` needs nothing of its own.
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
gem install nokogiri
|
|
132
|
+
gem install activerecord sqlite3
|
|
133
|
+
gem install sanitize
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
In a checkout the same three groups are selected together:
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
bundle config set --local with "html store sanitize"
|
|
140
|
+
bundle install
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Run it, then read what it wrote:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
automatic -c ~/.automatic/config/ai-digest.yml
|
|
147
|
+
sed -n '1,60p' ~/.automatic/markdown/ai-digest.md
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**What comes out is already useful**, and that is the first point of the
|
|
151
|
+
exercise. The document holds one section per new article, with its title, its
|
|
152
|
+
link, its date and whatever text the pipeline could get. No AI service has been
|
|
153
|
+
contacted, no credential exists yet, and the Recipe is complete in itself: a
|
|
154
|
+
person who only ever wanted this can stop here and put it in `cron`.
|
|
155
|
+
|
|
156
|
+
**Expect `FilterFullFeed` to find nothing for these three sites, and read the
|
|
157
|
+
log to see it.** The shipped `assets/siteinfo/items_all.json` is a snapshot of
|
|
158
|
+
the LDRFullFeed database whose newest entries are from 2013, and none of its URL
|
|
159
|
+
patterns matches these sites:
|
|
160
|
+
|
|
161
|
+
```text
|
|
162
|
+
Fulltext SITEINFO not found: https://go.dev/blog/pkgsite-api
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Where no record matches, the plugin leaves the item exactly as it arrived, and
|
|
166
|
+
the run continues. That is the behaviour to rely on and also the reason the
|
|
167
|
+
document above holds a title, a link and a date per article and no body: an
|
|
168
|
+
index page read without a `description_selector` carries no description for
|
|
169
|
+
`FilterFullFeed` to have improved on.
|
|
170
|
+
|
|
171
|
+
Getting text into these items is a choice between two places, and both are
|
|
172
|
+
configuration rather than code:
|
|
173
|
+
|
|
174
|
+
- **From the index page.** Where the listing prints a summary, an
|
|
175
|
+
`item_selector` marking one article's node with a `description_selector`
|
|
176
|
+
inside it puts that summary in the item. `CustomFeedWeb` requires the first to
|
|
177
|
+
use the second, because without a node there is nothing to read the summary
|
|
178
|
+
in.
|
|
179
|
+
- **From the article.** Supply your own siteinfo file in
|
|
180
|
+
`~/.automatic/assets/siteinfo/`, with the URL patterns and XPaths of the sites
|
|
181
|
+
you actually read, and `FilterFullFeed` fetches each page and puts its body in
|
|
182
|
+
the item.
|
|
183
|
+
|
|
184
|
+
The rest of this tutorial holds either way: what follows cares that each item
|
|
185
|
+
carries text, not where the text came from. It is worth having one of the two
|
|
186
|
+
working before the AI filter is added, because a joined text of titles and URLs
|
|
187
|
+
is what a summary of nothing looks like.
|
|
188
|
+
|
|
189
|
+
Read the log rather than only the document. It names each page fetched, each
|
|
190
|
+
link stored, each siteinfo lookup that missed, and the file written. Every stage
|
|
191
|
+
below is judged the same way.
|
|
192
|
+
|
|
193
|
+
## 3. Make one text out of many items
|
|
194
|
+
|
|
195
|
+
Add `FilterJoin` between `FilterSanitize` and `PublishMarkdown`:
|
|
196
|
+
|
|
197
|
+
```yaml
|
|
198
|
+
- module: FilterJoin
|
|
199
|
+
config:
|
|
200
|
+
title: Daily Digest
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Run it again and the document changes shape. Where the previous run wrote one
|
|
204
|
+
section per article, this one writes a single section titled `Daily Digest`,
|
|
205
|
+
whose body holds every article in sequence:
|
|
206
|
+
|
|
207
|
+
```text
|
|
208
|
+
ARTICLE 1
|
|
209
|
+
Title: Enabling Polonius on nightly
|
|
210
|
+
URL: https://blog.rust-lang.org/2026/08/04/enabling-polonius-alpha-on-nightly/
|
|
211
|
+
|
|
212
|
+
The body of the first article.
|
|
213
|
+
|
|
214
|
+
ARTICLE 2
|
|
215
|
+
Title: Extending the pkgsite API
|
|
216
|
+
URL: https://go.dev/blog/pkgsite-api
|
|
217
|
+
|
|
218
|
+
The body of the second article.
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
That is the whole of what `FilterJoin` does: many items in, one item out. It
|
|
222
|
+
fetches nothing, summarizes nothing, parses no HTML and calls no service. Its
|
|
223
|
+
only setting is the title of the item it produces.
|
|
224
|
+
|
|
225
|
+
It is worth being clear about why this is its own plugin rather than part of
|
|
226
|
+
what comes next. Joining a day's release notes, log lines or notifications into
|
|
227
|
+
one document is the same operation, and it is useful with no AI service
|
|
228
|
+
anywhere in the Recipe. The plugin adds no prompt and knows nothing about what
|
|
229
|
+
reads its result; what the joined text is *for* is decided entirely by the
|
|
230
|
+
plugin the Recipe puts after it.
|
|
231
|
+
|
|
232
|
+
Note also what the joined item lost: its link. This is the stage the ordering
|
|
233
|
+
rule from section 1 is about, and it is visible in the document — the joined
|
|
234
|
+
section carries a title and no `Link` bullet.
|
|
235
|
+
|
|
236
|
+
## 4. Ask one question about that text
|
|
237
|
+
|
|
238
|
+
Now the AI filter goes between `FilterJoin` and `PublishMarkdown`:
|
|
239
|
+
|
|
240
|
+
```yaml
|
|
241
|
+
- module: FilterSakuraAI
|
|
242
|
+
config:
|
|
243
|
+
token: YOUR_SAKURA_AI_TOKEN
|
|
244
|
+
model: gpt-oss-120b
|
|
245
|
+
prompt: |
|
|
246
|
+
以下の記事群について、個別記事の要約を羅列するのではなく、
|
|
247
|
+
全体を一つのダイジェストとして日本語で要約してください。
|
|
248
|
+
retry: 2
|
|
249
|
+
interval: 2
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The pipeline holds one item, so the service is asked **once**. The `prompt` is
|
|
253
|
+
sent as the instruction and the item's description — the joined text from
|
|
254
|
+
section 3 — as the text to work on. What comes back replaces the description.
|
|
255
|
+
The title stays `Daily Digest`, the item count stays one, and `PublishMarkdown`
|
|
256
|
+
writes the answer exactly where the joined text used to be.
|
|
257
|
+
|
|
258
|
+
The whole Recipe, with the two plugins added since section 2 and the site list
|
|
259
|
+
shortened to one site:
|
|
260
|
+
|
|
261
|
+
```yaml
|
|
262
|
+
plugins:
|
|
263
|
+
- module: CustomFeedWeb
|
|
264
|
+
config:
|
|
265
|
+
retry: 2
|
|
266
|
+
interval: 2
|
|
267
|
+
sites:
|
|
268
|
+
- name: The Go Blog
|
|
269
|
+
url: https://go.dev/blog/
|
|
270
|
+
link_selector: 'a[href]'
|
|
271
|
+
include:
|
|
272
|
+
- '^https://go\.dev/blog/[^/]+$'
|
|
273
|
+
fetch_items: 20
|
|
274
|
+
|
|
275
|
+
- module: StorePermalink
|
|
276
|
+
config:
|
|
277
|
+
db: ai-digest.db
|
|
278
|
+
|
|
279
|
+
- module: FilterFullFeed
|
|
280
|
+
config:
|
|
281
|
+
siteinfo: items_all.json
|
|
282
|
+
|
|
283
|
+
- module: FilterSanitize
|
|
284
|
+
config:
|
|
285
|
+
mode: restricted
|
|
286
|
+
|
|
287
|
+
- module: FilterJoin
|
|
288
|
+
config:
|
|
289
|
+
title: Daily Digest
|
|
290
|
+
|
|
291
|
+
- module: FilterSakuraAI
|
|
292
|
+
config:
|
|
293
|
+
token: YOUR_SAKURA_AI_TOKEN
|
|
294
|
+
model: gpt-oss-120b
|
|
295
|
+
prompt: |
|
|
296
|
+
以下の記事群について、個別記事の要約を羅列するのではなく、
|
|
297
|
+
全体を一つのダイジェストとして日本語で要約してください。
|
|
298
|
+
retry: 2
|
|
299
|
+
interval: 2
|
|
300
|
+
|
|
301
|
+
- module: PublishMarkdown
|
|
302
|
+
config:
|
|
303
|
+
file: ~/.automatic/markdown/ai-digest.md
|
|
304
|
+
mode: append
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**The token is a Recipe setting, which makes the Recipe a secret file.** Put the
|
|
308
|
+
real token in the file on the machine that runs it, keep the placeholder in
|
|
309
|
+
anything you commit, and restrict the file:
|
|
310
|
+
|
|
311
|
+
```sh
|
|
312
|
+
chmod 600 ~/.automatic/config/ai-digest.yml
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
The plugin never logs the token, never puts it in an exception message and never
|
|
316
|
+
writes it into an item, and TLS certificates are verified — but a Recipe in a
|
|
317
|
+
repository or a world-readable Recipe defeats all of that. `~/.automatic/config`
|
|
318
|
+
is not a place to keep files under version control.
|
|
319
|
+
|
|
320
|
+
**`retry` and `interval` are about the network, not about the answer.** A
|
|
321
|
+
timeout, a `429` and a `5xx` are attempted again, up to `retry` times,
|
|
322
|
+
`interval` seconds apart. A refused request, an answer that is not JSON, an
|
|
323
|
+
answer whose shape is not the documented one, and a missing or wrong setting all
|
|
324
|
+
end the run instead, because the next attempt would fail the same way. A missing
|
|
325
|
+
`token`, `model` or `prompt` is refused before the first request is made.
|
|
326
|
+
|
|
327
|
+
A failure never leaves an empty description behind: the run ends rather than
|
|
328
|
+
publishing the article as a blank. That is why the stage in section 2 matters —
|
|
329
|
+
when the service is unavailable, removing one line from the Recipe returns a
|
|
330
|
+
pipeline that still writes a document.
|
|
331
|
+
|
|
332
|
+
## 5. Swap the order, and the Recipe means something else
|
|
333
|
+
|
|
334
|
+
Move the AI filter to before `FilterJoin` and change nothing else:
|
|
335
|
+
|
|
336
|
+
```yaml
|
|
337
|
+
- module: FilterSanitize
|
|
338
|
+
config:
|
|
339
|
+
mode: restricted
|
|
340
|
+
|
|
341
|
+
- module: FilterSakuraAI
|
|
342
|
+
config:
|
|
343
|
+
token: YOUR_SAKURA_AI_TOKEN
|
|
344
|
+
model: gpt-oss-120b
|
|
345
|
+
prompt: |
|
|
346
|
+
以下の記事を日本語で三行に要約してください。
|
|
347
|
+
retry: 2
|
|
348
|
+
interval: 2
|
|
349
|
+
|
|
350
|
+
- module: FilterJoin
|
|
351
|
+
config:
|
|
352
|
+
title: Daily Digest
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Every AI filter makes **one request per item**, so with three articles in the
|
|
356
|
+
pipeline the two arrangements differ:
|
|
357
|
+
|
|
358
|
+
| Order | Requests, for three articles | What the answer is |
|
|
359
|
+
| --- | --- | --- |
|
|
360
|
+
| `FilterJoin` → AI filter | 1 | One text about everything at once |
|
|
361
|
+
| AI filter → `FilterJoin` | 3 | Three separate texts, joined afterwards |
|
|
362
|
+
|
|
363
|
+
Both are legitimate, and they answer different questions. A digest that draws a
|
|
364
|
+
theme across the day's articles needs the first; a document of per-article
|
|
365
|
+
summaries needs the second. The second is also the one that grows with the feed:
|
|
366
|
+
a run that finds forty new articles makes forty requests, each of them billed
|
|
367
|
+
and rate-limited. `StorePermalink` earlier in the Recipe is the main defence,
|
|
368
|
+
because it means each article is sent once ever; `FilterOne` ahead of the filter
|
|
369
|
+
is the blunt one, while a Recipe is being written.
|
|
370
|
+
|
|
371
|
+
The point is not which order is better. It is that the difference between "one
|
|
372
|
+
digest" and "a list of summaries" is the position of one entry in a YAML list,
|
|
373
|
+
and that no plugin had to be changed or configured to express it.
|
|
374
|
+
|
|
375
|
+
## 6. Change the service by changing one line
|
|
376
|
+
|
|
377
|
+
The four AI filters — `FilterOpenAI`, `FilterClaude`, `FilterGemini` and
|
|
378
|
+
`FilterSakuraAI` — are one per service rather than one plugin with a `provider`
|
|
379
|
+
setting. Each replaces an item's description with what its service answers, so
|
|
380
|
+
in a Recipe they are interchangeable at the same position:
|
|
381
|
+
|
|
382
|
+
```yaml
|
|
383
|
+
- module: FilterOpenAI
|
|
384
|
+
config:
|
|
385
|
+
token: YOUR_OPENAI_API_KEY
|
|
386
|
+
model: gpt-5.6
|
|
387
|
+
prompt: |
|
|
388
|
+
以下の記事群を一つのダイジェストとして日本語で要約してください。
|
|
389
|
+
retry: 2
|
|
390
|
+
interval: 2
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
```yaml
|
|
394
|
+
- module: FilterClaude
|
|
395
|
+
config:
|
|
396
|
+
token: YOUR_ANTHROPIC_API_KEY
|
|
397
|
+
model: claude-opus-5
|
|
398
|
+
prompt: |
|
|
399
|
+
以下の記事群を一つのダイジェストとして日本語で要約してください。
|
|
400
|
+
max_tokens: 2048
|
|
401
|
+
retry: 2
|
|
402
|
+
interval: 2
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
```yaml
|
|
406
|
+
- module: FilterGemini
|
|
407
|
+
config:
|
|
408
|
+
token: YOUR_GEMINI_API_KEY
|
|
409
|
+
model: gemini-3.5-flash
|
|
410
|
+
prompt: |
|
|
411
|
+
以下の記事群を一つのダイジェストとして日本語で要約してください。
|
|
412
|
+
retry: 2
|
|
413
|
+
interval: 2
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Nothing before or after the swapped entry changes. `token`, `model` and `prompt`
|
|
417
|
+
are required by all four; `max_tokens` exists only for Claude, because that API
|
|
418
|
+
requires it. Model names move with the services, so take them from the provider
|
|
419
|
+
you are using rather than from this document, and see
|
|
420
|
+
[`PLUGINS.md`](PLUGINS.md) section 6.3 for each plugin's endpoint,
|
|
421
|
+
authentication and answer handling.
|
|
422
|
+
|
|
423
|
+
That a Recipe names the service on its face is the reason for four plugins. A
|
|
424
|
+
line reading `FilterClaude` says where the text is going, which is a question
|
|
425
|
+
worth being able to answer by reading the Recipe.
|
|
426
|
+
|
|
427
|
+
## 7. Change the prompt, and the Recipe does another job
|
|
428
|
+
|
|
429
|
+
None of the four filters is a summarizer. The prompt is the instruction and the
|
|
430
|
+
item's description is the text it applies to, so summarizing, translating,
|
|
431
|
+
extracting and classifying are the same plugin with different words in one
|
|
432
|
+
setting. Keeping the Recipe of section 4 and replacing only the `prompt`:
|
|
433
|
+
|
|
434
|
+
```yaml
|
|
435
|
+
prompt: |
|
|
436
|
+
以下の記事群から、セキュリティに関係する記述だけを抜き出し、
|
|
437
|
+
日本語の箇条書きにしてください。該当がなければ「該当なし」と答えてください。
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
```yaml
|
|
441
|
+
prompt: |
|
|
442
|
+
Translate the following articles into English, keeping each
|
|
443
|
+
article's heading and order.
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
```yaml
|
|
447
|
+
prompt: |
|
|
448
|
+
以下の記事群を、テーマごとに見出しを付けて分類してください。
|
|
449
|
+
本文の引用はせず、見出しと一行の説明だけを出力してください。
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
There is no default prompt: a Recipe without one is refused rather than being
|
|
453
|
+
given a purpose it did not ask for. The instruction and the article text are
|
|
454
|
+
sent as separate fields — a system instruction and a user turn — so that what an
|
|
455
|
+
article says is text to be worked on rather than an instruction to obey. It is
|
|
456
|
+
still worth remembering what the input is: pages fetched from the open web. A
|
|
457
|
+
prompt that states what to do when the text does not contain what was asked for
|
|
458
|
+
is more robust than one that assumes it does.
|
|
459
|
+
|
|
460
|
+
**Long input has a limit that belongs to the model, not to the framework.** A
|
|
461
|
+
joined text of forty full articles can exceed what a model accepts, and the
|
|
462
|
+
service answers with an error that ends the run. Fewer sites, a smaller
|
|
463
|
+
`fetch_items`, a stricter `include`, or the per-article arrangement of section 5
|
|
464
|
+
are the ways to stay under it with the plugins that ship today. Splitting one
|
|
465
|
+
long text into pieces, transforming each and asking a final question about the
|
|
466
|
+
results is the natural next arrangement conceptually, and it is **not**
|
|
467
|
+
something this repository provides: there is no chunking plugin, and a Recipe
|
|
468
|
+
cannot express it today.
|
|
469
|
+
|
|
470
|
+
## 8. Why it is built this way
|
|
471
|
+
|
|
472
|
+
Read the finished Recipe again as a shell pipeline and the design is not novel:
|
|
473
|
+
|
|
474
|
+
```text
|
|
475
|
+
discover | deduplicate | fetch | clean | join | transform | write
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Each plugin takes the pipeline, does one thing to it, and hands it on. The
|
|
479
|
+
interface between two plugins is items carrying text, which is why the AI filter
|
|
480
|
+
needed no cooperation from `FilterJoin` and why `PublishMarkdown` needed none
|
|
481
|
+
from either.
|
|
482
|
+
|
|
483
|
+
What that buys, concretely:
|
|
484
|
+
|
|
485
|
+
- **Every prefix of the Recipe is a working Recipe.** Section 2 publishes
|
|
486
|
+
articles, section 3 publishes a joined document, section 4 publishes an
|
|
487
|
+
answer. When the last stage fails, the earlier ones still say what the run
|
|
488
|
+
found.
|
|
489
|
+
- **A failure has one address.** Nothing was fetched is `CustomFeedWeb`;
|
|
490
|
+
everything is a summary is `FilterFullFeed` and its siteinfo; the document is
|
|
491
|
+
one section is `FilterJoin`; the request was refused is the AI filter. A
|
|
492
|
+
single plugin that did all of it would have one log and one place to guess in.
|
|
493
|
+
- **Changing behaviour is editing configuration, not code.** A different
|
|
494
|
+
service, a different job, one digest instead of many summaries: three edits to
|
|
495
|
+
one YAML file, each of them one line or one setting.
|
|
496
|
+
- **The parts recombine.** `FilterJoin` before a Markdown file, an AI filter
|
|
497
|
+
over a feed that needs no joining, a store plugin with neither: none of these
|
|
498
|
+
is a special case of the others.
|
|
499
|
+
|
|
500
|
+
The plugin this repository deliberately does not have is the one that would be
|
|
501
|
+
easiest to want: a `FilterDigest` that fetches the articles, joins them,
|
|
502
|
+
summarizes them and writes the file. It would be quicker to configure once, and
|
|
503
|
+
it would be the only way to do any of it. Wanting the joined text without the
|
|
504
|
+
summary, or the summary of one feed rather than of a day, or the same articles
|
|
505
|
+
sent to a different service, would each be a new setting on it, and its log
|
|
506
|
+
would tell you only that "the digest failed".
|
|
507
|
+
|
|
508
|
+
The AI service is the newest thing in the Recipe and the least special: a filter
|
|
509
|
+
that takes a text and returns a text, in a list of filters that do the same.
|
|
510
|
+
|
|
511
|
+
## Next
|
|
512
|
+
|
|
513
|
+
Every setting used above, and every plugin that could take a place in this
|
|
514
|
+
pipeline, is in [`PLUGINS.md`](PLUGINS.md) section 6 — the Recipe format in
|
|
515
|
+
section 2, the AI filters in section 6.3. Installing the optional gems,
|
|
516
|
+
scheduling a Recipe and reading its log when it fails is
|
|
517
|
+
[`DEPLOYMENT.md`](DEPLOYMENT.md). A filter of your own, in the same shape as the
|
|
518
|
+
ones used here, is [`PLUGIN_DEVELOPMENT.md`](PLUGIN_DEVELOPMENT.md).
|