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/PLUGINS.md
ADDED
|
@@ -0,0 +1,1875 @@
|
|
|
1
|
+
# Recipes and plugins
|
|
2
|
+
|
|
3
|
+
## 1. What this document is
|
|
4
|
+
|
|
5
|
+
This is the specification of the two things outside this repository that depend
|
|
6
|
+
on it: the **Recipe** format, which operators write, and the **plugin
|
|
7
|
+
contract**, which plugin authors implement. It also catalogues the plugins
|
|
8
|
+
shipped in the gem, with what each one accepts and whether it still works.
|
|
9
|
+
|
|
10
|
+
The system these two interfaces belong to is described in
|
|
11
|
+
[`REQUIREMENTS.md`](REQUIREMENTS.md); the machinery that implements them is in
|
|
12
|
+
[`BASIC_DESIGN.md`](BASIC_DESIGN.md); the rules for changing them are in
|
|
13
|
+
[`POLICY.md`](POLICY.md).
|
|
14
|
+
|
|
15
|
+
It stands on its own. Nothing in it is completed by a document kept in another
|
|
16
|
+
repository.
|
|
17
|
+
|
|
18
|
+
Plugin authors can start with the complete user-plugin example in
|
|
19
|
+
[`PLUGIN_DEVELOPMENT.md`](PLUGIN_DEVELOPMENT.md).
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 2. The Recipe
|
|
24
|
+
|
|
25
|
+
### 2.1 What a Recipe is
|
|
26
|
+
|
|
27
|
+
A Recipe is a YAML file describing one job: which plugins run, in what order,
|
|
28
|
+
and with what settings. It is the whole of the job's definition — there is no
|
|
29
|
+
other configuration file and no environment to set.
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
automatic -c ~/.automatic/config/example/feed2console.yml
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 2.2 Structure
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
global: # optional
|
|
39
|
+
log:
|
|
40
|
+
level: info # info | warn | error | none
|
|
41
|
+
|
|
42
|
+
plugins: # required
|
|
43
|
+
- module: SubscriptionFeed # required
|
|
44
|
+
config: # optional
|
|
45
|
+
feeds:
|
|
46
|
+
- https://example.com/feed
|
|
47
|
+
|
|
48
|
+
- module: FilterIgnore
|
|
49
|
+
config:
|
|
50
|
+
link:
|
|
51
|
+
- example.net
|
|
52
|
+
|
|
53
|
+
- module: PublishConsole # a plugin needing no settings omits config
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The document is a mapping with two keys at the top level.
|
|
57
|
+
|
|
58
|
+
### 2.3 `plugins`
|
|
59
|
+
|
|
60
|
+
A sequence of plugin entries, run in the order written. Required: a Recipe with
|
|
61
|
+
no `plugins` sequence is refused with `Automatic::InvalidRecipeError`.
|
|
62
|
+
|
|
63
|
+
Each entry is a mapping:
|
|
64
|
+
|
|
65
|
+
| Key | Required | Type | Meaning |
|
|
66
|
+
| --- | --- | --- | --- |
|
|
67
|
+
| `module` | yes | string | The plugin class name, in CamelCase |
|
|
68
|
+
| `config` | no | mapping | Passed to that plugin and read by nothing else |
|
|
69
|
+
|
|
70
|
+
- `module` names a class in `Automatic::Plugin`. How the name is resolved to a
|
|
71
|
+
file is section 3.2. A name that resolves to nothing raises
|
|
72
|
+
`Automatic::NoPluginError` **before any plugin runs**, so a typo costs
|
|
73
|
+
nothing.
|
|
74
|
+
- `config` is handed to the plugin untouched. The framework does not validate
|
|
75
|
+
it, does not apply defaults to it and does not know what any key means. What
|
|
76
|
+
a given plugin accepts is section 6.
|
|
77
|
+
- An entry may name the same module more than once. Two `FilterIgnore` entries
|
|
78
|
+
with different keywords is ordinary use.
|
|
79
|
+
|
|
80
|
+
### 2.4 `global`
|
|
81
|
+
|
|
82
|
+
Optional, and almost empty on purpose. One key is read:
|
|
83
|
+
|
|
84
|
+
| Key | Values | Meaning |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| `global.log.level` | `info`, `warn`, `error`, `none` | The log threshold for this run. Default `info`. |
|
|
87
|
+
|
|
88
|
+
`global.timezone` and `global.cache` appear in the example Recipes and in
|
|
89
|
+
Recipes written years ago. **Nothing reads them.** They are inert, they are kept
|
|
90
|
+
so that existing Recipes are not edited for no reason, and they are recorded
|
|
91
|
+
here so that no one gives them a meaning by accident. A Recipe that sets them
|
|
92
|
+
behaves exactly as one that does not.
|
|
93
|
+
|
|
94
|
+
An unrecognised key anywhere in `global` is ignored.
|
|
95
|
+
|
|
96
|
+
### 2.5 How `-c` is resolved
|
|
97
|
+
|
|
98
|
+
The value of `-c` is looked for in `~/.automatic/config` first, and used as a
|
|
99
|
+
path as given if it is not there:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
automatic -c blog.yml # ~/.automatic/config/blog.yml, if it exists
|
|
103
|
+
automatic -c ./recipes/blog.yml # otherwise, exactly this path
|
|
104
|
+
automatic -c /etc/automatic/blog.yml
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A path that resolves to nothing fails with the exit status `1` and a message
|
|
108
|
+
naming the file.
|
|
109
|
+
|
|
110
|
+
### 2.6 Types
|
|
111
|
+
|
|
112
|
+
Recipe values are ordinary YAML scalars, sequences and mappings, and the
|
|
113
|
+
framework loads them **safely**: a Recipe may not name a Ruby class to
|
|
114
|
+
instantiate, and a document that tries to is refused. YAML aliases are
|
|
115
|
+
permitted, so a block of settings can be shared:
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
plugins:
|
|
119
|
+
- module: SubscriptionFeed
|
|
120
|
+
config: &retrying
|
|
121
|
+
retry: 3
|
|
122
|
+
interval: 5
|
|
123
|
+
feeds:
|
|
124
|
+
- https://example.com/feed
|
|
125
|
+
|
|
126
|
+
- module: StoreFile
|
|
127
|
+
config:
|
|
128
|
+
<<: *retrying
|
|
129
|
+
path: /var/tmp/automatic
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Note what the plugins do with types, because it is not always what YAML implies:
|
|
133
|
+
|
|
134
|
+
- `retry` and `interval` are read through `to_i`. An absent value is `0`, which
|
|
135
|
+
means one attempt and no pause. A quoted `"3"` and a bare `3` behave alike.
|
|
136
|
+
- Keyword lists (`link`, `title`, `description`) are sequences of strings, and
|
|
137
|
+
matching is a **substring** test, not a pattern and not a whole-word match.
|
|
138
|
+
An empty string therefore matches everything, which is a way to drop
|
|
139
|
+
everything and is occasionally used deliberately.
|
|
140
|
+
- Booleans are usually spelled `1` and `0` rather than `true` and `false`, and
|
|
141
|
+
the plugins that do this compare against `1` exactly. This is inherited and is
|
|
142
|
+
noted per plugin in section 6.
|
|
143
|
+
|
|
144
|
+
### 2.6.1 Setting names that collide
|
|
145
|
+
|
|
146
|
+
The Recipe is wrapped in `Hashie::Mash`, which is what lets a plugin entry answer
|
|
147
|
+
to `plugin.module` and `plugin.config`. The cost is that a setting name which is
|
|
148
|
+
also a method of `Hash` or `Enumerable` — `count`, `first`, `key`, `max`, `min`,
|
|
149
|
+
`select`, `size`, `sort`, `zip` — makes it log a warning on every run:
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
You are setting a key that conflicts with a built-in method Hashie::Mash#sort
|
|
153
|
+
defined in Enumerable. This can cause unexpected behavior when accessing the
|
|
154
|
+
key as a property. You can still access the key via the #[] method.
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The value is stored and is read correctly, because plugins read their settings
|
|
158
|
+
by string key rather than as a property. This is noise, not breakage, and a
|
|
159
|
+
Recipe using such a key needs no change.
|
|
160
|
+
|
|
161
|
+
Two shipped plugins have such a name, from before this was understood, and they
|
|
162
|
+
keep it: `FilterSort`'s `sort` and `PublishMemcached`'s `key`. Renaming them
|
|
163
|
+
would break every Recipe using them, which is not a trade worth making for a
|
|
164
|
+
warning. **A new plugin should not introduce one**: prefer `max_length` to
|
|
165
|
+
`max`, `item_count` to `count`, `cache_key` to `key`.
|
|
166
|
+
|
|
167
|
+
### 2.7 Failure
|
|
168
|
+
|
|
169
|
+
| Situation | Result |
|
|
170
|
+
| --- | --- |
|
|
171
|
+
| The file does not exist | Exit `1`, the path is named |
|
|
172
|
+
| The file is not valid YAML | Exit `1`, the parser's message is shown |
|
|
173
|
+
| The document is not a mapping | `Automatic::InvalidRecipeError`, exit `1` |
|
|
174
|
+
| No `plugins` sequence | `Automatic::InvalidRecipeError`, exit `1` |
|
|
175
|
+
| `module` names an unknown plugin | `Automatic::NoPluginError`, exit `1`, nothing has run |
|
|
176
|
+
| A plugin raises during `run` | The run ends there. Exit `1`. Earlier plugins' effects stand. |
|
|
177
|
+
|
|
178
|
+
The last row is the one to design Recipes around: there is no rollback and no
|
|
179
|
+
resume. A Recipe that must not repeat its effect on the next run puts a store
|
|
180
|
+
plugin in front of the plugin with the effect. See
|
|
181
|
+
[`REQUIREMENTS.md`](REQUIREMENTS.md) section 12.
|
|
182
|
+
|
|
183
|
+
### 2.8 Compatibility
|
|
184
|
+
|
|
185
|
+
A Recipe that worked with an earlier release keeps working. Recipes live outside
|
|
186
|
+
this repository and cannot be migrated by it, so removing a key, renaming a key,
|
|
187
|
+
changing a default so that an unchanged Recipe does something else, or changing
|
|
188
|
+
how a value is interpreted are breaking changes. They are made deliberately and
|
|
189
|
+
recorded in [`VERSIONS`](VERSIONS). Adding an optional key whose default
|
|
190
|
+
preserves current behaviour is not one.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## 3. The plugin contract
|
|
195
|
+
|
|
196
|
+
### 3.1 The whole of it
|
|
197
|
+
|
|
198
|
+
A plugin is a Ruby class in the `Automatic::Plugin` namespace that can be built
|
|
199
|
+
with two arguments and answers one method:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
module Automatic::Plugin
|
|
203
|
+
class FilterExample
|
|
204
|
+
def initialize(config, pipeline = [])
|
|
205
|
+
@config = config
|
|
206
|
+
@pipeline = pipeline
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def run
|
|
210
|
+
# ... work ...
|
|
211
|
+
@pipeline
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- `config` is the entry's `config` mapping, or `nil` when the entry had none.
|
|
218
|
+
**A plugin that can be used without settings must tolerate `nil`.**
|
|
219
|
+
- `pipeline` is the value returned by the previous plugin, or `[]` for the
|
|
220
|
+
first.
|
|
221
|
+
- `run` returns the pipeline for the next plugin. Its return value is the whole
|
|
222
|
+
of its output to the framework.
|
|
223
|
+
|
|
224
|
+
There is no `setup`, no `teardown`, no registration call, no base class and no
|
|
225
|
+
mixin to include. A class with those two methods, in a file the loader can find,
|
|
226
|
+
is a plugin.
|
|
227
|
+
|
|
228
|
+
### 3.2 Naming and location
|
|
229
|
+
|
|
230
|
+
The class name and the file path are the same fact written twice, and the loader
|
|
231
|
+
converts between them:
|
|
232
|
+
|
|
233
|
+
```text
|
|
234
|
+
Automatic::Plugin::SubscriptionFeed
|
|
235
|
+
|
|
|
236
|
+
| underscore
|
|
237
|
+
v
|
|
238
|
+
subscription_feed
|
|
239
|
+
|
|
|
240
|
+
| split on the category directory name
|
|
241
|
+
v
|
|
242
|
+
subscription / feed.rb
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
So the rules are:
|
|
246
|
+
|
|
247
|
+
- The class name is `CamelCase` and begins with its category: `Subscription`,
|
|
248
|
+
`CustomFeed`, `Filter`, `Store`, `Provide`, `Notify` or `Publish`.
|
|
249
|
+
- The file is `<category>/<rest>.rb`, where both parts are `snake_case`.
|
|
250
|
+
- The file defines exactly that class, inside `module Automatic::Plugin`.
|
|
251
|
+
|
|
252
|
+
Examples, including the ones that are easy to get wrong:
|
|
253
|
+
|
|
254
|
+
| Class | File |
|
|
255
|
+
| --- | --- |
|
|
256
|
+
| `SubscriptionFeed` | `subscription/feed.rb` |
|
|
257
|
+
| `FilterAbsoluteURI` | `filter/absolute_uri.rb` |
|
|
258
|
+
| `CustomFeedSVNLog` | `custom_feed/svn_log.rb` |
|
|
259
|
+
| `PublishHatenaBookmark` | `publish/hatena_bookmark.rb` |
|
|
260
|
+
| `FilterDescriptionLink` | `filter/description_link.rb` |
|
|
261
|
+
|
|
262
|
+
The category directory is not decoration: it is half of the lookup key. A file
|
|
263
|
+
in a directory whose name is not a prefix of the underscored class name is never
|
|
264
|
+
found.
|
|
265
|
+
|
|
266
|
+
### 3.3 Discovery and precedence
|
|
267
|
+
|
|
268
|
+
Two search roots, in this order:
|
|
269
|
+
|
|
270
|
+
1. `~/.automatic/plugins/<category>/<rest>.rb`
|
|
271
|
+
2. `<installation>/plugins/<category>/<rest>.rb`
|
|
272
|
+
|
|
273
|
+
The first match wins, so **a plugin in the user directory shadows a shipped
|
|
274
|
+
plugin of the same name.** That is the supported way to change a shipped
|
|
275
|
+
plugin's behaviour without editing the installation.
|
|
276
|
+
|
|
277
|
+
Creating a new category is creating a directory. `~/.automatic/plugins/mine/`
|
|
278
|
+
plus a class named `MineSomething` works with no change to the framework, though
|
|
279
|
+
staying inside the seven categories is preferred, because their names tell a
|
|
280
|
+
reader where in a pipeline the plugin belongs.
|
|
281
|
+
|
|
282
|
+
Loading is lazy: the loader registers an `autoload`, so the file is read when
|
|
283
|
+
the constant is first used. A syntax error in a plugin therefore surfaces when
|
|
284
|
+
that plugin's entry is reached, not when the Recipe is loaded.
|
|
285
|
+
|
|
286
|
+
### 3.4 The pipeline value
|
|
287
|
+
|
|
288
|
+
Everything a plugin receives and returns has one shape:
|
|
289
|
+
|
|
290
|
+
> an `Array` of feed objects, where a feed object answers `#items`, and an item
|
|
291
|
+
> answers `#title`, `#link`, `#description`, `#date`, `#author`, `#comments`,
|
|
292
|
+
> `#source`, `#enclosure` and `#content_encoded`.
|
|
293
|
+
|
|
294
|
+
The elements are RSS objects, from `RSS::Parser` or built by `RSS::Maker`. A
|
|
295
|
+
plugin whose source is not a feed converts it, and `Automatic::FeedMaker` is how:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
items = rows.map do |row|
|
|
299
|
+
Automatic::FeedMaker.generate_feed(
|
|
300
|
+
'title' => row[:title], 'url' => row[:url], 'description' => row[:body]
|
|
301
|
+
)
|
|
302
|
+
end
|
|
303
|
+
@pipeline << Automatic::FeedMaker.create_pipeline(items)
|
|
304
|
+
@pipeline
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
`FeedMaker.generate_feed` takes a hash with any of `title`, `url`,
|
|
308
|
+
`description`, `author`, `comments` — note `url`, not `link` — and returns one
|
|
309
|
+
item. `FeedMaker.create_pipeline` takes a list of items and returns one feed
|
|
310
|
+
object. A plugin that produces items ends with those two calls.
|
|
311
|
+
|
|
312
|
+
Rules that follow from the shape:
|
|
313
|
+
|
|
314
|
+
- **Return the shape, always.** Returning `nil`, a string or a bare array of
|
|
315
|
+
items ends the pipeline for everything after it.
|
|
316
|
+
- **`link` may be `nil`, and so may any other field.** Filters signal "not
|
|
317
|
+
applicable" by setting `link` to `nil`, so a plugin that dereferences a field
|
|
318
|
+
without checking will be handed `nil` sooner or later.
|
|
319
|
+
- **Guard the feed itself.** `@pipeline.each { |feeds| next if feeds.nil? }` is
|
|
320
|
+
the prevailing idiom, because a subscription plugin that failed may have put a
|
|
321
|
+
`nil` in the array.
|
|
322
|
+
- **A dropped item means a rebuilt feed.** RSS objects are not conveniently
|
|
323
|
+
filtered in place, so a plugin that removes items collects the survivors and
|
|
324
|
+
calls `FeedMaker.create_pipeline` on them.
|
|
325
|
+
- The field names are RSS names used for values that are not RSS. `title` may
|
|
326
|
+
hold a weather condition. This is a known cost of one shape and it is
|
|
327
|
+
accepted.
|
|
328
|
+
|
|
329
|
+
### 3.5 Settings
|
|
330
|
+
|
|
331
|
+
- Read from `@config`, by string key: `@config['interval']`.
|
|
332
|
+
- Assume nothing. `@config` itself may be `nil`, and any key may be missing.
|
|
333
|
+
- Follow the established names: `retry` for an attempt count, `interval` for
|
|
334
|
+
seconds between attempts, `db` for a database file, `path` for a directory.
|
|
335
|
+
- Do not choose a setting name that is a method of `Hash` or `Enumerable`; see
|
|
336
|
+
section 2.6.1.
|
|
337
|
+
- Do not read the environment, and do not read a file other than one named in
|
|
338
|
+
the settings. The `config` mapping is the plugin's entire input besides the
|
|
339
|
+
pipeline.
|
|
340
|
+
|
|
341
|
+
### 3.6 Errors, retrying and logging
|
|
342
|
+
|
|
343
|
+
A plugin owns its own transient failures. The shape used throughout:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
retries = 0
|
|
347
|
+
retry_max = @config['retry'].to_i
|
|
348
|
+
begin
|
|
349
|
+
# ... the attempt ...
|
|
350
|
+
rescue => e
|
|
351
|
+
retries += 1
|
|
352
|
+
Automatic::Log.puts('error', "ErrorCount: #{retries}, #{e.message}")
|
|
353
|
+
sleep @config['interval'].to_i
|
|
354
|
+
retry if retries <= retry_max
|
|
355
|
+
end
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
- **A failure is logged.** Whatever the plugin decides to do about an error, the
|
|
359
|
+
log is the only record an unattended run leaves. Swallowing an error silently
|
|
360
|
+
is a defect; so is logging it at `info`.
|
|
361
|
+
- **Raising is allowed, and it ends the run.** The framework does not catch
|
|
362
|
+
plugin exceptions. Raise when continuing would be wrong; rescue when the
|
|
363
|
+
Recipe should carry on with less data.
|
|
364
|
+
- **Log through `Automatic::Log`, not `puts`.** The exception is a plugin whose
|
|
365
|
+
purpose is to write to the terminal, which holds an output object in an
|
|
366
|
+
instance variable so that a test can substitute it.
|
|
367
|
+
|
|
368
|
+
### 3.7 Credentials
|
|
369
|
+
|
|
370
|
+
Credentials arrive as ordinary settings, which makes the Recipe holding them a
|
|
371
|
+
secret file. A plugin therefore:
|
|
372
|
+
|
|
373
|
+
- never logs a credential, and never logs `@config` wholesale;
|
|
374
|
+
- never writes one into a pipeline item, where a later publishing plugin would
|
|
375
|
+
send it somewhere;
|
|
376
|
+
- verifies TLS certificates. Disabling verification is not acceptable, whatever
|
|
377
|
+
a service's certificate is doing.
|
|
378
|
+
|
|
379
|
+
### 3.8 Dependencies
|
|
380
|
+
|
|
381
|
+
A plugin requires its own libraries at the top of its own file. A library that
|
|
382
|
+
ships with Ruby is required plainly; a gem the operator has to install is
|
|
383
|
+
required through `Automatic.require_optional`, which names the gem, the plugin
|
|
384
|
+
and the way to install it if it is absent:
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
module Automatic::Plugin
|
|
388
|
+
class PublishMemcached
|
|
389
|
+
Automatic.require_optional('dalli', needed_by: 'PublishMemcached')
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
```text
|
|
393
|
+
The `dalli` gem is not installed. It is needed by PublishMemcached. Install it
|
|
394
|
+
with `gem install dalli`, or in a source checkout add its group to the bundle;
|
|
395
|
+
see the optional plugin dependencies in doc/DEPLOYMENT.md.
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Pass `gem_name:` where the gem's name differs from the path required, as
|
|
399
|
+
`activerecord` does from `active_record`.
|
|
400
|
+
|
|
401
|
+
That is what keeps a gem needed by one plugin out of everyone else's
|
|
402
|
+
installation. A gem used by a single plugin is not added to the framework's
|
|
403
|
+
runtime dependencies; it goes in an optional group of the `Gemfile` and the
|
|
404
|
+
operator who uses the plugin installs it. See [`POLICY.md`](POLICY.md)
|
|
405
|
+
section 9.
|
|
406
|
+
|
|
407
|
+
A Recipe therefore needs the sum of what its plugins need, and each is loaded
|
|
408
|
+
when the pipeline reaches it, so a gem missing for the third plugin is reported
|
|
409
|
+
after the first two have run. [`DEPLOYMENT.md`](DEPLOYMENT.md) lists which
|
|
410
|
+
plugin needs which gem, and "Working out what a Recipe needs, in a checkout"
|
|
411
|
+
takes one Recipe through adding those up before running it.
|
|
412
|
+
|
|
413
|
+
Where a plugin has an optional capability that needs a heavier library — S3
|
|
414
|
+
support in `StoreFile`, for instance — the `require` goes inside the branch that
|
|
415
|
+
uses it, so the plugin loads and its ordinary path works without that gem
|
|
416
|
+
installed.
|
|
417
|
+
|
|
418
|
+
### 3.8.1 Fetching
|
|
419
|
+
|
|
420
|
+
A plugin that fetches over HTTP calls `Automatic::Http`:
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
body = Automatic::Http.read(url) # the body, or an exception
|
|
424
|
+
Automatic::Http.open(url) { |io| ... } # the stream, for a caller that wants it
|
|
425
|
+
Automatic::Http.uri(url) # a validated URI, or an exception
|
|
426
|
+
Automatic::Http.fetchable?(url) # for skipping an item rather than failing
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
`read` returns a string that `open-uri` has already applied an encoding to,
|
|
430
|
+
whether or not the response declared one: a page served as `text/html` with no
|
|
431
|
+
charset comes back tagged UTF-8 because that is the fallback, not because the
|
|
432
|
+
page said so. A plugin that hands the body to an HTML parser wants `open`
|
|
433
|
+
instead, because a parser given the stream reads the `meta` charset for itself
|
|
434
|
+
and a parser given the string believes the tag. `FilterFullFeed` is the worked
|
|
435
|
+
example; the difference there was a whole article in mojibake.
|
|
436
|
+
|
|
437
|
+
It is a helper and not a client: it opens the URL through `open-uri` with the
|
|
438
|
+
scheme restricted to HTTP and HTTPS, a connect and a read timeout, a bounded
|
|
439
|
+
redirect chain and this project named as the agent. A URL string carrying
|
|
440
|
+
characters a URI may not — a space, a Japanese query term — is escaped and
|
|
441
|
+
parsed again rather than raising.
|
|
442
|
+
|
|
443
|
+
The scheme restriction is the part that matters most: **a link in a pipeline
|
|
444
|
+
item comes from a feed, which is to say from outside.** `URI.open` on such a
|
|
445
|
+
string will read `file:///etc/passwd` as readily as an article.
|
|
446
|
+
|
|
447
|
+
### 3.9 Testing a plugin
|
|
448
|
+
|
|
449
|
+
Construct it, run it, assert on what came back:
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
require File.expand_path(File.dirname(__FILE__) + '../../../spec_helper')
|
|
453
|
+
require 'filter/example'
|
|
454
|
+
|
|
455
|
+
describe Automatic::Plugin::FilterExample do
|
|
456
|
+
subject do
|
|
457
|
+
described_class.new({ 'key' => 'value' },
|
|
458
|
+
AutomaticSpec.generate_pipeline do
|
|
459
|
+
feed { item 'https://example.com/a', 'A' }
|
|
460
|
+
feed { item 'https://example.com/b', 'B' }
|
|
461
|
+
end)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
its(:run) { should have(1).feeds }
|
|
465
|
+
end
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
`AutomaticSpec.generate_pipeline` builds a pipeline; `feed` opens a feed object
|
|
469
|
+
and `item url, title, description, date, author, source, enclosure` adds one
|
|
470
|
+
item to it.
|
|
471
|
+
|
|
472
|
+
A plugin test reaches no network and needs no credential. A plugin that cannot
|
|
473
|
+
be tested without one is tested for what it can be — its settings handling, its
|
|
474
|
+
message construction — and the rest is left to the integration Recipes under
|
|
475
|
+
`test/integration`, which are run by hand.
|
|
476
|
+
|
|
477
|
+
### 3.10 Where a plugin goes
|
|
478
|
+
|
|
479
|
+
| Category | It should | It should not |
|
|
480
|
+
| --- | --- | --- |
|
|
481
|
+
| `Subscription` | Acquire from outside and produce a pipeline | Publish |
|
|
482
|
+
| `CustomFeed` | Build a feed from a source that is not one | Filter |
|
|
483
|
+
| `Filter` | Select, reorder, rewrite; return a pipeline | Have side effects outside the pipeline |
|
|
484
|
+
| `Store` | Persist, and drop what has been seen before | Send anything outward |
|
|
485
|
+
| `Provide` | Emit `content_encoded` elsewhere | Alter the pipeline |
|
|
486
|
+
| `Notify` | Send a notification, return the pipeline unchanged | Alter the pipeline |
|
|
487
|
+
| `Publish` | Send the result out, print it, or write it as a document; return the pipeline | Alter the pipeline |
|
|
488
|
+
|
|
489
|
+
Nothing enforces this. It is what makes a Recipe readable, and it is what a
|
|
490
|
+
reviewer will ask about.
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## 4. Writing a plugin, end to end
|
|
495
|
+
|
|
496
|
+
```sh
|
|
497
|
+
automatic scaffold # creates ~/.automatic and its categories
|
|
498
|
+
$EDITOR ~/.automatic/plugins/filter/short_title.rb
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
```ruby
|
|
502
|
+
# -*- coding: utf-8 -*-
|
|
503
|
+
# Name:: Automatic::Plugin::Filter::ShortTitle
|
|
504
|
+
# Description:: Keep only items whose title is at most `max` characters.
|
|
505
|
+
|
|
506
|
+
module Automatic::Plugin
|
|
507
|
+
class FilterShortTitle
|
|
508
|
+
DEFAULT_MAX_LENGTH = 40
|
|
509
|
+
|
|
510
|
+
def initialize(config, pipeline = [])
|
|
511
|
+
@config = config || {}
|
|
512
|
+
@pipeline = pipeline
|
|
513
|
+
@max = (@config['max_length'] || DEFAULT_MAX_LENGTH).to_i
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def run
|
|
517
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
518
|
+
next if feeds.nil?
|
|
519
|
+
|
|
520
|
+
kept = feeds.items.select { |item| item.title.to_s.length <= @max }
|
|
521
|
+
Automatic::Log.puts('info', "ShortTitle: kept #{kept.size} of #{feeds.items.size}")
|
|
522
|
+
returned << Automatic::FeedMaker.create_pipeline(kept) unless kept.empty?
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
```yaml
|
|
530
|
+
plugins:
|
|
531
|
+
- module: SubscriptionFeed
|
|
532
|
+
config:
|
|
533
|
+
feeds:
|
|
534
|
+
- https://example.com/feed
|
|
535
|
+
- module: FilterShortTitle
|
|
536
|
+
config:
|
|
537
|
+
max_length: 30
|
|
538
|
+
- module: PublishConsole
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
```sh
|
|
542
|
+
automatic -c ~/.automatic/config/short.yml
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Nothing was registered and no framework file was touched. Naming the class
|
|
546
|
+
`FilterShortTitle` and putting it in `filter/short_title.rb` is the whole of the
|
|
547
|
+
wiring.
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
## 5. Reading the catalogue
|
|
552
|
+
|
|
553
|
+
Section 6 lists every plugin shipped in the gem. Each carries a status:
|
|
554
|
+
|
|
555
|
+
| Status | Meaning |
|
|
556
|
+
| --- | --- |
|
|
557
|
+
| **Supported** | Works on the supported Ruby versions with the current dependencies. Covered by the default test suite where it can be. |
|
|
558
|
+
| **Supported (external)** | The plugin is current, but it needs something the operator provides — a running service, an installed command, a credential, a data file. |
|
|
559
|
+
| **Needs rework** | The service and the capability still exist, but this plugin speaks an interface that has been replaced. It will not work as written, and restoring it is a self-contained piece of work. |
|
|
560
|
+
|
|
561
|
+
There is no fourth row. There used to be one, holding plugins whose service had
|
|
562
|
+
shut down, and the plugins that were in it have been removed rather than kept:
|
|
563
|
+
see section 8.
|
|
564
|
+
|
|
565
|
+
Two rules govern this table, and they are the reason it exists at all:
|
|
566
|
+
|
|
567
|
+
- **Nothing is faked.** A plugin is not stubbed, mocked or simulated to make a
|
|
568
|
+
test pass or a catalogue entry look better. Where a plugin's gem is absent
|
|
569
|
+
its specs are excluded from the default suite, and that absence is the honest
|
|
570
|
+
signal. A service that no longer answers is not given a fake endpoint to
|
|
571
|
+
answer with; the plugin goes.
|
|
572
|
+
- **Nothing is kept for being old.** A plugin ships because it has a current
|
|
573
|
+
practical use, not because it once did. Git history is where the previous
|
|
574
|
+
implementations are, and it keeps them without their being installed on
|
|
575
|
+
anyone's machine.
|
|
576
|
+
|
|
577
|
+
**Supported is not the same as covered by the required workflow.** A Supported
|
|
578
|
+
plugin whose gem is an optional plugin dependency — the store plugins, the ones
|
|
579
|
+
that read HTML, `FilterSanitize`, `FilterDescriptionLink` — works, and is
|
|
580
|
+
simply not part of what a green required build guarantees, because the default
|
|
581
|
+
bundle does not install that gem. Its entry says so, and installing the gem runs
|
|
582
|
+
its spec as part of the ordinary suite, which is also what the separate
|
|
583
|
+
`plugins` workflow does. Nothing here is classified by what CI happens to run; a
|
|
584
|
+
plugin is not demoted for needing a gem, and is not promoted by a test that CI
|
|
585
|
+
never executes.
|
|
586
|
+
|
|
587
|
+
**This classification is a snapshot taken in August 2026,** based on the
|
|
588
|
+
published status of each service and on what each plugin's code actually calls.
|
|
589
|
+
The statuses that depend on an outside service can change without any commit
|
|
590
|
+
here. Where a status was reached from published information rather than from a
|
|
591
|
+
live check, the entry says so. To verify one yourself, run its Recipe from
|
|
592
|
+
`test/integration` by hand; those are not part of CI and never will be.
|
|
593
|
+
|
|
594
|
+
Restoring the one **Needs rework** plugin is a self-contained piece of work and
|
|
595
|
+
a good first contribution.
|
|
596
|
+
|
|
597
|
+
---
|
|
598
|
+
|
|
599
|
+
## 6. The plugins
|
|
600
|
+
|
|
601
|
+
### 6.1 Subscription
|
|
602
|
+
|
|
603
|
+
Acquire from outside; produce a pipeline. Called first in a Recipe.
|
|
604
|
+
|
|
605
|
+
#### SubscriptionFeed — **Supported**
|
|
606
|
+
|
|
607
|
+
`subscription/feed.rb`. Fetches and parses feeds. The plugin most Recipes start
|
|
608
|
+
with.
|
|
609
|
+
|
|
610
|
+
```yaml
|
|
611
|
+
- module: SubscriptionFeed
|
|
612
|
+
config:
|
|
613
|
+
feeds:
|
|
614
|
+
- https://example.com/feed
|
|
615
|
+
- https://example.org/rss
|
|
616
|
+
retry: 3
|
|
617
|
+
interval: 5
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
| Key | Type | Meaning |
|
|
621
|
+
| --- | --- | --- |
|
|
622
|
+
| `feeds` | sequence | Feed URLs, fetched in order. Required. |
|
|
623
|
+
| `retry` | integer | Attempts after the first, per feed. Default `0`. |
|
|
624
|
+
| `interval` | integer | Seconds between attempts. Default `0`. |
|
|
625
|
+
|
|
626
|
+
A feed that fails after its retries is logged and skipped; the others still run.
|
|
627
|
+
|
|
628
|
+
`interval` is now waited. The line that was meant to wait it assigned to a
|
|
629
|
+
local variable named `sleep` and returned at once, in this plugin and in every
|
|
630
|
+
other that had a retry loop, so a Recipe asking to be gentle with a host was
|
|
631
|
+
not being gentle. A Recipe that set `interval` will take longer than it used
|
|
632
|
+
to and will behave as it always said it did.
|
|
633
|
+
|
|
634
|
+
#### SubscriptionLink — **Supported**
|
|
635
|
+
|
|
636
|
+
`subscription/link.rb`. Fetches pages and makes an item of every `<a href>`.
|
|
637
|
+
For sites that publish no feed. Returns only what it fetched, discarding any
|
|
638
|
+
incoming pipeline.
|
|
639
|
+
|
|
640
|
+
| Key | Type | Meaning |
|
|
641
|
+
| --- | --- | --- |
|
|
642
|
+
| `urls` | sequence | Page URLs. Required. |
|
|
643
|
+
| `retry` | integer | Attempts after the first. Default `0`. |
|
|
644
|
+
| `interval` | integer | Seconds between requests. Default `0`. |
|
|
645
|
+
|
|
646
|
+
Set `interval` when fetching several pages from one host.
|
|
647
|
+
|
|
648
|
+
Reads HTML through `FeedParser.parse_html`, so it needs `nokogiri`:
|
|
649
|
+
`gem install nokogiri`, or the `html` group in a checkout.
|
|
650
|
+
|
|
651
|
+
#### SubscriptionXml — **Supported**
|
|
652
|
+
|
|
653
|
+
`subscription/xml.rb`. `GET`s an XML endpoint, converts the document to a hash,
|
|
654
|
+
and puts it in one item's `content_encoded`. Pair with `ProvideFluentd` to move
|
|
655
|
+
an XML API into a log pipeline. Needs `activesupport`.
|
|
656
|
+
|
|
657
|
+
| Key | Type | Meaning |
|
|
658
|
+
| --- | --- | --- |
|
|
659
|
+
| `urls` | sequence | XML endpoints. Required. |
|
|
660
|
+
| `retry` | integer | Attempts after the first. Default `0`. |
|
|
661
|
+
| `interval` | integer | Seconds between requests. Default `0`. |
|
|
662
|
+
|
|
663
|
+
#### SubscriptionText — **Supported**
|
|
664
|
+
|
|
665
|
+
`subscription/text.rb`. Builds a feed from literal values or TSV files. Reaches
|
|
666
|
+
no network, which makes it the plugin to test a Recipe's later half with.
|
|
667
|
+
|
|
668
|
+
| Key | Type | Meaning |
|
|
669
|
+
| --- | --- | --- |
|
|
670
|
+
| `titles` | sequence | One item per title, no link |
|
|
671
|
+
| `urls` | sequence | One item per URL, no title |
|
|
672
|
+
| `feeds` | sequence | Mappings of `title`, `url`, `description`, `author`, `comments` |
|
|
673
|
+
| `files` | sequence | TSV paths; columns are title, url, description, author, comments |
|
|
674
|
+
|
|
675
|
+
The TSV separator is a tab, the file is read as UTF-8, and `~` is expanded. Any
|
|
676
|
+
combination of the four keys may be given.
|
|
677
|
+
|
|
678
|
+
#### SubscriptionTumblr — **Supported (external)**
|
|
679
|
+
|
|
680
|
+
`subscription/tumblr.rb`. Fetches a Tumblr blog's pages, takes the links, and
|
|
681
|
+
drops any that leave the blog's own host. `pages` walks `/page/2` and onward.
|
|
682
|
+
|
|
683
|
+
| Key | Type | Meaning |
|
|
684
|
+
| --- | --- | --- |
|
|
685
|
+
| `urls` | sequence | Blog URLs. Required. |
|
|
686
|
+
| `pages` | integer | How many pages back to walk. Default `1`. |
|
|
687
|
+
| `retry` | integer | Attempts after the first. Default `0`. |
|
|
688
|
+
| `interval` | integer | Seconds between requests. Default `0`. |
|
|
689
|
+
|
|
690
|
+
It reads HTML written for a browser, so it needs `nokogiri` — `gem install
|
|
691
|
+
nokogiri`, or the `html` group in a checkout — and it depends on the theme a
|
|
692
|
+
given blog uses and on Tumblr's page structure. Verify against the blog you mean
|
|
693
|
+
to follow before putting it in `cron`, and set `interval`.
|
|
694
|
+
|
|
695
|
+
### 6.2 CustomFeed
|
|
696
|
+
|
|
697
|
+
#### CustomFeedWeb — **Supported**
|
|
698
|
+
|
|
699
|
+
`custom_feed/web.rb`. Fetches HTML index pages and builds one feed per page
|
|
700
|
+
from the article links it lists. For a site that publishes no feed and whose
|
|
701
|
+
list page has more structure than `SubscriptionLink` reads: CSS selectors say
|
|
702
|
+
where an article is and what belongs to it, and the links are resolved,
|
|
703
|
+
filtered and deduplicated on the way into the feed.
|
|
704
|
+
|
|
705
|
+
```yaml
|
|
706
|
+
- module: CustomFeedWeb
|
|
707
|
+
config:
|
|
708
|
+
retry: 2
|
|
709
|
+
interval: 1
|
|
710
|
+
sites:
|
|
711
|
+
- url: https://example.com/news/
|
|
712
|
+
name: Example News
|
|
713
|
+
item_selector: article
|
|
714
|
+
link_selector: h2 a
|
|
715
|
+
title_selector: h2
|
|
716
|
+
description_selector: .summary
|
|
717
|
+
date_selector: time
|
|
718
|
+
same_host: true
|
|
719
|
+
include:
|
|
720
|
+
- ^https://example\.com/news/
|
|
721
|
+
exclude:
|
|
722
|
+
- /category/
|
|
723
|
+
fetch_items: 50
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
| Key | Type | Meaning |
|
|
727
|
+
| --- | --- | --- |
|
|
728
|
+
| `sites` | sequence | Page mappings, fetched in order. Required. |
|
|
729
|
+
| `retry` | integer | Attempts after the first, per page. Default `0`. |
|
|
730
|
+
| `interval` | integer | Seconds between requests. Default `0`. |
|
|
731
|
+
|
|
732
|
+
Each element of `sites` is a mapping. A bare `- https://example.com/news/` is
|
|
733
|
+
not accepted: a page's settings are what this plugin is for, and one shorthand
|
|
734
|
+
kept working forever is a second format to support.
|
|
735
|
+
|
|
736
|
+
| Key | Type | Meaning |
|
|
737
|
+
| --- | --- | --- |
|
|
738
|
+
| `url` | string | The page to fetch. Required. |
|
|
739
|
+
| `name` | string | Channel title. Default the page's `<title>`, then its host. |
|
|
740
|
+
| `item_selector` | string | The node one article occupies. |
|
|
741
|
+
| `link_selector` | string | The permalink, evaluated inside the article where there is one. Default `a[href]`. |
|
|
742
|
+
| `title_selector` | string | The title, inside the article. Default the link's own text. |
|
|
743
|
+
| `description_selector` | string | The summary the page prints, taken as text. |
|
|
744
|
+
| `date_selector` | string | The publication date, inside the article. |
|
|
745
|
+
| `same_host` | boolean | Drop a URL whose host is not the page's. Default `true`. |
|
|
746
|
+
| `include` | sequence | Regular expressions; a URL matching none of them is dropped. |
|
|
747
|
+
| `exclude` | sequence | Regular expressions; a URL matching one of them is dropped. |
|
|
748
|
+
| `fetch_items` | integer | Items per page, from the top. Default `100`; `0`, a negative value and an absent one all mean the default. |
|
|
749
|
+
|
|
750
|
+
There are three ways a page is read, and which one applies follows from the
|
|
751
|
+
selectors given:
|
|
752
|
+
|
|
753
|
+
- **Neither `item_selector` nor `link_selector`.** Every `a[href]` on the page
|
|
754
|
+
is a candidate and its text is the title. This is the mode to start with.
|
|
755
|
+
- **`link_selector` only.** Each node it selects is a candidate, and its text
|
|
756
|
+
is the title. `main h2 a` is the usual shape of it.
|
|
757
|
+
- **`item_selector`.** Each node it selects is one article, and
|
|
758
|
+
`link_selector`, `title_selector`, `description_selector` and
|
|
759
|
+
`date_selector` are evaluated inside that node. Without `link_selector` the
|
|
760
|
+
article's first `a[href]` is the permalink; without `title_selector` the
|
|
761
|
+
link's own text is the title.
|
|
762
|
+
|
|
763
|
+
`title_selector`, `description_selector` and `date_selector` are read inside an
|
|
764
|
+
article, so giving one without `item_selector` names no article to read it in
|
|
765
|
+
and is refused as a settings error.
|
|
766
|
+
|
|
767
|
+
A candidate URL is resolved against the page it was found on — `/articles/42`,
|
|
768
|
+
`../42` and `//example.com/42` all become the URL a reader would follow — and
|
|
769
|
+
then judged in this order: HTTP or HTTPS, not the page itself, `same_host`,
|
|
770
|
+
`include`, `exclude`, already seen, and finally `fetch_items`. The fragment is
|
|
771
|
+
removed, because two links differing only in their anchor are one article. The
|
|
772
|
+
query string is kept, because `?id=42` is frequently the whole of what
|
|
773
|
+
identifies one; no canonical form is guessed. `same_host` is an exact host
|
|
774
|
+
match, so `blog.example.com` is not `www.example.com`.
|
|
775
|
+
|
|
776
|
+
The page's own order is kept. A list page's order is the only ordering
|
|
777
|
+
information it carries, and nothing here sorts by date; `FilterSort` is where
|
|
778
|
+
a Recipe asks for that.
|
|
779
|
+
|
|
780
|
+
`date_selector` prefers the `datetime` attribute of a `<time>` element and
|
|
781
|
+
otherwise parses the node's text. A date that cannot be read is logged and the
|
|
782
|
+
item keeps its place without one — the time the page was fetched is not the
|
|
783
|
+
time the article was published, and is never substituted for it.
|
|
784
|
+
|
|
785
|
+
The plugin keeps no state: it fetches the page, and what the page lists now is
|
|
786
|
+
what it returns. Whether an item has been published before is the record
|
|
787
|
+
`StorePermalink` keeps, which is what the usual Recipe puts after it:
|
|
788
|
+
|
|
789
|
+
```yaml
|
|
790
|
+
plugins:
|
|
791
|
+
- module: CustomFeedWeb
|
|
792
|
+
config:
|
|
793
|
+
sites:
|
|
794
|
+
- url: https://example.com/news/
|
|
795
|
+
link_selector: main h2 a
|
|
796
|
+
|
|
797
|
+
- module: StorePermalink
|
|
798
|
+
config:
|
|
799
|
+
db: web-watch.db
|
|
800
|
+
|
|
801
|
+
- module: PublishMarkdown
|
|
802
|
+
config:
|
|
803
|
+
file: ~/.automatic/markdown/web-watch.md
|
|
804
|
+
mode: append
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
A page that could not be fetched is retried, then logged and skipped, and the
|
|
808
|
+
other pages still produce their feeds. Settings that cannot be carried out —
|
|
809
|
+
a site that is not a mapping, a missing or unfetchable `url`, an `include` or
|
|
810
|
+
`exclude` that is not a regular expression, a selector combination that names
|
|
811
|
+
no article — are refused before anything is fetched, because a second attempt
|
|
812
|
+
would fail identically.
|
|
813
|
+
|
|
814
|
+
Nothing else is fetched: no article body, no next page, no sitemap, no feed
|
|
815
|
+
autodiscovery, and no link found on the page is followed. One run makes one
|
|
816
|
+
request per site. Set `interval` when several sites are on one host.
|
|
817
|
+
|
|
818
|
+
Needs `nokogiri`, which it reads the page with: `gem install nokogiri`, or the
|
|
819
|
+
`html` group in a checkout.
|
|
820
|
+
|
|
821
|
+
#### CustomFeedSVNLog — **Supported (external)**
|
|
822
|
+
|
|
823
|
+
`custom_feed/svn_log.rb`. Runs `svn log --xml` against a repository and makes a
|
|
824
|
+
feed of the revisions. Needs the `svn` command, which is the operator's to
|
|
825
|
+
install, and **no gem of its own**: it reads the document with REXML, which is
|
|
826
|
+
a runtime dependency of the framework already. It used to need `xml-simple`,
|
|
827
|
+
whose last release was in 2021.
|
|
828
|
+
|
|
829
|
+
| Key | Type | Meaning |
|
|
830
|
+
| --- | --- | --- |
|
|
831
|
+
| `target` | string | Repository URL. Required. |
|
|
832
|
+
| `fetch_items` | integer | Revisions to fetch. Default `30`. |
|
|
833
|
+
| `title` | string | Channel title. Default empty. |
|
|
834
|
+
|
|
835
|
+
The command is run as an argument vector rather than through a shell, so a
|
|
836
|
+
repository URL cannot become part of a command line. Point `target` at a
|
|
837
|
+
repository you control regardless: `svn` itself will do what the URL tells it
|
|
838
|
+
to.
|
|
839
|
+
|
|
840
|
+
A repository with no revisions in the window asked for returns the pipeline
|
|
841
|
+
unchanged, with a warning. RSS 1.0 has no representation for a channel with no
|
|
842
|
+
items, and this used to end the run with a parser error.
|
|
843
|
+
|
|
844
|
+
### 6.3 Filter
|
|
845
|
+
|
|
846
|
+
Select, reorder or rewrite. No side effects outside the pipeline.
|
|
847
|
+
|
|
848
|
+
#### FilterIgnore — **Supported**
|
|
849
|
+
|
|
850
|
+
`filter/ignore.rb`. Drops items containing any listed keyword. Matching is a
|
|
851
|
+
substring test, so an empty string drops everything.
|
|
852
|
+
|
|
853
|
+
| Key | Type | Meaning |
|
|
854
|
+
| --- | --- | --- |
|
|
855
|
+
| `title` | sequence | Drop when the title contains any of these |
|
|
856
|
+
| `link` | sequence | Drop when the link contains any of these |
|
|
857
|
+
| `description` | sequence | Drop when the description contains any of these |
|
|
858
|
+
|
|
859
|
+
All three are optional and combine as "or". An item whose field is missing is
|
|
860
|
+
kept, with a warning.
|
|
861
|
+
|
|
862
|
+
#### FilterAccept — **Supported**
|
|
863
|
+
|
|
864
|
+
`filter/accept.rb`. The complement of `FilterIgnore`: keeps only items that
|
|
865
|
+
match. Same three keys, same substring rule. An item whose field is missing is
|
|
866
|
+
not matched, and says so; it used to end the run with a `NoMethodError`, which
|
|
867
|
+
is not what its complement does with the same item.
|
|
868
|
+
|
|
869
|
+
#### FilterSort — **Supported**
|
|
870
|
+
|
|
871
|
+
`filter/sort.rb`. Sorts each feed's items by date.
|
|
872
|
+
|
|
873
|
+
| Key | Type | Meaning |
|
|
874
|
+
| --- | --- | --- |
|
|
875
|
+
| `sort` | string | `asc` sorts oldest first. Anything else, including absent, sorts newest first. |
|
|
876
|
+
|
|
877
|
+
Items must carry a date; a feed built from a source without one will fail here.
|
|
878
|
+
|
|
879
|
+
`sort` collides with a `Hashie::Mash` built-in and logs a warning per run. The
|
|
880
|
+
setting works; see section 2.6.1.
|
|
881
|
+
|
|
882
|
+
#### FilterOne — **Supported**
|
|
883
|
+
|
|
884
|
+
`filter/one.rb`. Reduces each feed to a single item.
|
|
885
|
+
|
|
886
|
+
| Key | Type | Meaning |
|
|
887
|
+
| --- | --- | --- |
|
|
888
|
+
| `pick` | string | `last` takes the last item. Anything else, including absent, takes the first. |
|
|
889
|
+
|
|
890
|
+
#### FilterRand — **Supported**
|
|
891
|
+
|
|
892
|
+
`filter/rand.rb`. Shuffles each feed's items. Combined with `FilterOne`, picks
|
|
893
|
+
one at random. No settings.
|
|
894
|
+
|
|
895
|
+
#### FilterClear — **Supported**
|
|
896
|
+
|
|
897
|
+
`filter/clear.rb`. Returns an empty pipeline. Used to end a Recipe after a store
|
|
898
|
+
plugin has done the work, so that later plugins publish nothing. No settings.
|
|
899
|
+
|
|
900
|
+
#### FilterImage — **Supported**
|
|
901
|
+
|
|
902
|
+
`filter/image.rb`. Sets `link` to `nil` unless it names an image. Note that it
|
|
903
|
+
does not remove the items — it blanks their links, and the plugins after it
|
|
904
|
+
skip items whose link is `nil`. No settings.
|
|
905
|
+
|
|
906
|
+
The extensions are `.jpg`, `.jpeg`, `.gif`, `.png`, `.tif`, `.tiff`, `.webp`
|
|
907
|
+
and `.avif`, and the test is on the URL's **path**. Both of those changed:
|
|
908
|
+
`.webp` and `.avif` are what an image link on the current web frequently is,
|
|
909
|
+
and testing the whole URL meant that `photo.jpg?w=1280` — which is how most of
|
|
910
|
+
what serves images now serves them — was not recognised as one. A Recipe using
|
|
911
|
+
this filter will therefore keep links it used to blank.
|
|
912
|
+
|
|
913
|
+
#### FilterImageSource — **Supported**
|
|
914
|
+
|
|
915
|
+
`filter/image_source.rb`. Replaces each item with one item per image found: the
|
|
916
|
+
images in the description, or, if there are none, the images on the page the
|
|
917
|
+
link points at. Fetching pages means network access. No settings.
|
|
918
|
+
|
|
919
|
+
Needs `nokogiri`: `gem install nokogiri`, or the `html` group in a checkout.
|
|
920
|
+
|
|
921
|
+
The description is read with that parser rather than scanned for the literal
|
|
922
|
+
text `<img src="`, so a document quoting its attributes with apostrophes or
|
|
923
|
+
writing `src` after another attribute is no longer invisible to it, and a
|
|
924
|
+
relative `src` is resolved against the item's own link. A page that cannot be
|
|
925
|
+
read is a warning and no images, rather than the end of the run.
|
|
926
|
+
|
|
927
|
+
#### FilterAbsoluteURI — **Supported**
|
|
928
|
+
|
|
929
|
+
`filter/absolute_uri.rb`. Rewrites relative links to absolute ones.
|
|
930
|
+
|
|
931
|
+
| Key | Type | Meaning |
|
|
932
|
+
| --- | --- | --- |
|
|
933
|
+
| `url` | string | The base. A trailing slash is added if absent. Required. |
|
|
934
|
+
|
|
935
|
+
A link that already carries a scheme is left alone. That test matched `http://`
|
|
936
|
+
only, so an `https://` link was treated as relative and had the base prepended
|
|
937
|
+
to it; a Recipe that combined this filter with an HTTPS source was producing
|
|
938
|
+
links that went nowhere.
|
|
939
|
+
|
|
940
|
+
#### FilterSanitize — **Supported**
|
|
941
|
+
|
|
942
|
+
`filter/sanitize.rb`. Strips HTML from descriptions, using the `sanitize` gem.
|
|
943
|
+
|
|
944
|
+
| Key | Type | Meaning |
|
|
945
|
+
| --- | --- | --- |
|
|
946
|
+
| `mode` | string | `basic`, `relaxed`, or `restricted`. Default `restricted`. |
|
|
947
|
+
|
|
948
|
+
Needs the `sanitize` gem, which is an optional plugin dependency and is not
|
|
949
|
+
installed with the framework. Its spec is therefore outside the default suite
|
|
950
|
+
and outside CI; installing the gem brings the spec back into the ordinary run.
|
|
951
|
+
See [`DEPLOYMENT.md`](DEPLOYMENT.md).
|
|
952
|
+
|
|
953
|
+
#### FilterTumblrResize — **Supported**
|
|
954
|
+
|
|
955
|
+
`filter/tumblr_resize.rb`. Rewrites a Tumblr image link to the largest variant.
|
|
956
|
+
Assumes `FilterImage` or `FilterImageSource` has already put an image URL in
|
|
957
|
+
the link. No settings.
|
|
958
|
+
|
|
959
|
+
Tumblr has served images under two URL schemes, and both are rewritten. The
|
|
960
|
+
older one carries the size as a suffix on the file name — `tumblr_xxx_500.jpg`
|
|
961
|
+
becomes `tumblr_xxx_1280.jpg` — and is what images uploaded before 2019 still
|
|
962
|
+
use. The newer one carries it as a path segment — `/s540x810/` becomes
|
|
963
|
+
`/s1280x1920/` — and is what everything since uses. Only the first was handled,
|
|
964
|
+
which is why this filter appeared to do nothing on a blog whose posts are
|
|
965
|
+
recent.
|
|
966
|
+
|
|
967
|
+
#### FilterDescriptionLink — **Supported**
|
|
968
|
+
|
|
969
|
+
`filter/description_link.rb`. Takes the last HTTP or HTTPS URL out of the
|
|
970
|
+
description and makes it the link. For feeds that carry the real destination in
|
|
971
|
+
the body.
|
|
972
|
+
|
|
973
|
+
| Key | Type | Meaning |
|
|
974
|
+
| --- | --- | --- |
|
|
975
|
+
| `clear_description` | `1` | Empty the description afterwards. Any other value leaves it. |
|
|
976
|
+
| `get_title` | `1` | Fetch the new link and use its `<title>`. Any other value skips it. |
|
|
977
|
+
|
|
978
|
+
`get_title` makes one request per item; use `FilterOne` or a store plugin before
|
|
979
|
+
it on a large feed.
|
|
980
|
+
|
|
981
|
+
**Both settings were being ignored in every real run.** The test that guarded
|
|
982
|
+
them asked whether the settings mapping was a `Hash`, and the framework hands a
|
|
983
|
+
plugin a `Hashie::Mash`, which is a subclass and so is not that class. A Recipe
|
|
984
|
+
setting `clear_description` or `get_title` will now do what it asked for.
|
|
985
|
+
|
|
986
|
+
Needs `nokogiri`, which it reads the fetched page with — `gem install
|
|
987
|
+
nokogiri`, or the `html` group in a checkout. It no longer needs `nkf`: the
|
|
988
|
+
parser detects a page's encoding itself, which is one optional dependency
|
|
989
|
+
fewer, and `nkf` had left the standard library after Ruby 3.3. This plugin's
|
|
990
|
+
spec is outside the default suite and outside the required workflow because
|
|
991
|
+
`nokogiri` is an optional dependency. See [`DEPLOYMENT.md`](DEPLOYMENT.md).
|
|
992
|
+
|
|
993
|
+
#### FilterFullFeed — **Supported (external)**
|
|
994
|
+
|
|
995
|
+
`filter/full_feed.rb`. Replaces a summary with the article body, by matching the
|
|
996
|
+
link against a "siteinfo" database of URL patterns and XPaths and fetching the
|
|
997
|
+
page.
|
|
998
|
+
|
|
999
|
+
| Key | Type | Meaning |
|
|
1000
|
+
| --- | --- | --- |
|
|
1001
|
+
| `siteinfo` | string | File name under the assets directory. Required. |
|
|
1002
|
+
|
|
1003
|
+
Needs `nokogiri`: `gem install nokogiri`, or the `html` group in a checkout.
|
|
1004
|
+
|
|
1005
|
+
The shipped `assets/siteinfo/items_all.json` is a snapshot of the LDRFullFeed
|
|
1006
|
+
database taken from `wedata.net`, which no longer operates, so the file cannot
|
|
1007
|
+
be refreshed from its origin and its newest entries are from 2013. The plugin
|
|
1008
|
+
works; how well it works depends on whether the sites you read are in that
|
|
1009
|
+
snapshot and still laid out the same way. Supplying your own file in
|
|
1010
|
+
`~/.automatic/assets/siteinfo/` is the way to keep it useful.
|
|
1011
|
+
|
|
1012
|
+
Three things follow from the database being that old, and the plugin now
|
|
1013
|
+
accounts for each:
|
|
1014
|
+
|
|
1015
|
+
- **A link matches under either scheme.** 3,448 of the 3,504 usable records
|
|
1016
|
+
anchor on a scheme and all but twenty of those say `^http://`. The sites they
|
|
1017
|
+
name have since moved to HTTPS, which is what a feed hands over, so matching
|
|
1018
|
+
the link as it stands matched almost nothing and the filter quietly did
|
|
1019
|
+
nothing at all. A record describes a site's layout, not how it is
|
|
1020
|
+
transported, so the link is tried under both. Only the match is rewritten;
|
|
1021
|
+
the page is fetched from the link the feed gave.
|
|
1022
|
+
- **A record that selects nothing leaves the summary alone.** A site redesigned
|
|
1023
|
+
since its XPath was written selects no nodes, and putting that empty result
|
|
1024
|
+
into the item replaced a perfectly good summary with an empty description.
|
|
1025
|
+
The item keeps what it arrived with, and the miss is logged at `warn` with
|
|
1026
|
+
the XPath that missed.
|
|
1027
|
+
- **The page's own encoding is believed before the record's.** The page is
|
|
1028
|
+
parsed from the stream, so a charset in a `meta` tag is read even when the
|
|
1029
|
+
response declared none. A record's `enc` is the fallback for a page that
|
|
1030
|
+
declares nothing anywhere — 1,186 records carry one, mostly EUC-JP and
|
|
1031
|
+
Shift_JIS — and an `enc` naming an encoding Ruby does not have is ignored
|
|
1032
|
+
rather than raised. What comes out is UTF-8 either way.
|
|
1033
|
+
|
|
1034
|
+
A record with no URL pattern, no XPath, or a pattern that is not a regular
|
|
1035
|
+
expression is dropped when the file is loaded rather than being allowed to fail
|
|
1036
|
+
a match later; an empty pattern would otherwise match every link in the feed.
|
|
1037
|
+
The remaining patterns are compiled once, not once per item.
|
|
1038
|
+
|
|
1039
|
+
#### FilterGithubFeed — **Supported**
|
|
1040
|
+
|
|
1041
|
+
`filter/github_feed.rb`. Converts Atom entries — where `title`, `id` and
|
|
1042
|
+
`content` are elements with a `.content` — into the flat items the rest of the
|
|
1043
|
+
pipeline expects. Needed because GitHub publishes Atom, not RSS. No settings.
|
|
1044
|
+
|
|
1045
|
+
A field that is already a string is taken as it stands, so a pipeline that has
|
|
1046
|
+
been through another filter first is no longer a `NoMethodError`.
|
|
1047
|
+
|
|
1048
|
+
#### FilterJoin — **Supported**
|
|
1049
|
+
|
|
1050
|
+
`filter/join.rb`. Joins every item in the pipeline into one item. Many items
|
|
1051
|
+
in, one item out, and that is the whole of it: it fetches nothing, summarizes
|
|
1052
|
+
nothing, and knows nothing about what reads the result.
|
|
1053
|
+
|
|
1054
|
+
| Key | Type | Meaning |
|
|
1055
|
+
| --- | --- | --- |
|
|
1056
|
+
| `title` | string | The title of the joined item. Default `Joined items`. |
|
|
1057
|
+
|
|
1058
|
+
**In**: the pipeline as it stands — any number of feeds, any number of items; a
|
|
1059
|
+
feed that is `nil` is passed over. **Out**: one feed holding one item. The whole
|
|
1060
|
+
pipeline becomes one item rather than one item per feed, because the point of
|
|
1061
|
+
joining is to have a single text; a Recipe that wants one item per feed still
|
|
1062
|
+
has its feeds separate before this plugin runs.
|
|
1063
|
+
|
|
1064
|
+
The description is plain text, with a numbered heading per item so that
|
|
1065
|
+
whatever reads it can tell one article from the next:
|
|
1066
|
+
|
|
1067
|
+
```text
|
|
1068
|
+
ARTICLE 1
|
|
1069
|
+
Title: Ruby 4.1 released
|
|
1070
|
+
URL: https://example.com/a
|
|
1071
|
+
|
|
1072
|
+
The body of the first article.
|
|
1073
|
+
|
|
1074
|
+
ARTICLE 2
|
|
1075
|
+
Title: PostgreSQL 19 released
|
|
1076
|
+
URL: https://example.com/b
|
|
1077
|
+
|
|
1078
|
+
The body of the second article.
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
A title, link or description an item does not carry is written as empty, so
|
|
1082
|
+
every section has the same shape. **An input with no items produces an empty
|
|
1083
|
+
pipeline**, not an item that says nothing.
|
|
1084
|
+
|
|
1085
|
+
**The joined item has no link.** It is several articles at once, so there is no
|
|
1086
|
+
page it points at, and putting the first article's URL there would name a
|
|
1087
|
+
source for text that is not only from it. That has one consequence for a
|
|
1088
|
+
Recipe: the store plugins are keyed on the link and drop an item without one,
|
|
1089
|
+
so `StorePermalink`, `StoreFullText` and `StoreDigest` belong **before** this
|
|
1090
|
+
plugin, where there is still one item per article to record. `PublishMarkdown`
|
|
1091
|
+
heads the joined item with its title and writes no `Link` bullet.
|
|
1092
|
+
|
|
1093
|
+
Nothing here is about AI. Joining a day's log lines, notifications or release
|
|
1094
|
+
notes into one document is the same operation, and this plugin adds no prompt
|
|
1095
|
+
of its own — what the joined text is for is decided by whatever the Recipe puts
|
|
1096
|
+
next.
|
|
1097
|
+
|
|
1098
|
+
```yaml
|
|
1099
|
+
- module: FilterJoin
|
|
1100
|
+
config:
|
|
1101
|
+
title: Daily Digest
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
**The four AI filters.** The plugins that follow each send an item's
|
|
1105
|
+
description to one AI service and put the answer back in its place. They are
|
|
1106
|
+
four plugins rather than one with a `provider` setting, and that is the design
|
|
1107
|
+
rather than an accident: the services differ in endpoint, authentication,
|
|
1108
|
+
request body, answer shape, error format and available models; each of those
|
|
1109
|
+
moves without asking the others; and a Recipe naming `FilterClaude` says on its
|
|
1110
|
+
face where the text is being sent.
|
|
1111
|
+
Changing service is changing that one line.
|
|
1112
|
+
|
|
1113
|
+
**None of them is a summarizer.** The Recipe's `prompt` is the instruction and
|
|
1114
|
+
the item's description is the text it applies to, so summarizing, translating,
|
|
1115
|
+
extracting, reformatting and classifying are the same plugin with a different
|
|
1116
|
+
prompt. There is no default prompt: a Recipe without one is refused with an
|
|
1117
|
+
`ArgumentError` rather than being given a purpose it did not ask for. The two
|
|
1118
|
+
are sent as separate fields — a system instruction and a user turn — so that
|
|
1119
|
+
what an article says is text to be worked on, never an instruction to obey.
|
|
1120
|
+
|
|
1121
|
+
What the four have in common:
|
|
1122
|
+
|
|
1123
|
+
| Point | What it is |
|
|
1124
|
+
| --- | --- |
|
|
1125
|
+
| Required settings | `token`, `model` and `prompt`. A Recipe missing one is an `ArgumentError` before the first request. |
|
|
1126
|
+
| `retry`, `interval` | Attempts after a failure, and seconds to wait between them. Both default to `0`. |
|
|
1127
|
+
| What is retried | The network, a `429`, a `5xx`. |
|
|
1128
|
+
| What is not | A refused request, an answer that is not JSON, an answer whose shape is not the one the service documents, and a setting that is missing or wrong. These raise and end the run, because the next attempt would fail the same way. |
|
|
1129
|
+
| Input and output | The pipeline's feeds and items, in the same number and the same order. Only `description` is replaced; `title`, `link`, `date` and the rest are untouched. |
|
|
1130
|
+
| An item with no description | Logged and passed over. Nothing is sent, and nothing is emptied. |
|
|
1131
|
+
| A failure | Never leaves an empty description behind. A run that could not transform an item ends rather than publishing the article as a blank. |
|
|
1132
|
+
| The credential | A Recipe setting, which makes the Recipe a secret file. It is never logged, never in an exception message, and never written into an item. TLS certificates are verified. |
|
|
1133
|
+
|
|
1134
|
+
Each of them makes **one request per item**, which is what makes the order of a
|
|
1135
|
+
Recipe worth thinking about:
|
|
1136
|
+
|
|
1137
|
+
- `FilterJoin` → an AI filter: the articles become one text and the service is
|
|
1138
|
+
asked about it **once**. This is the digest arrangement — one answer over
|
|
1139
|
+
everything, which is not the same as a list of separate summaries.
|
|
1140
|
+
- An AI filter → `FilterJoin`: each article is transformed **on its own**, and
|
|
1141
|
+
the answers are joined afterwards. Use `FilterOne` or a store plugin ahead of
|
|
1142
|
+
it on a large feed; each item is a billed request.
|
|
1143
|
+
|
|
1144
|
+
#### FilterOpenAI — **Supported (external)**
|
|
1145
|
+
|
|
1146
|
+
`filter/open_ai.rb`. Sends each item's description to the OpenAI API and
|
|
1147
|
+
replaces it with the answer. It speaks the Responses API,
|
|
1148
|
+
`https://api.openai.com/v1/responses`, which is the interface OpenAI recommends
|
|
1149
|
+
for new integrations, and authenticates with the token as a bearer token.
|
|
1150
|
+
|
|
1151
|
+
| Key | Type | Meaning |
|
|
1152
|
+
| --- | --- | --- |
|
|
1153
|
+
| `token` | string | OpenAI API key. Required. |
|
|
1154
|
+
| `model` | string | Model name, as OpenAI names it. Required. |
|
|
1155
|
+
| `prompt` | string | The instruction, sent as the request's `instructions`. Required. |
|
|
1156
|
+
| `retry` | integer | Attempts after a failure. Default `0`. |
|
|
1157
|
+
| `interval` | integer | Seconds between attempts. Default `0`. |
|
|
1158
|
+
|
|
1159
|
+
The endpoint is not a setting: there is one, an operator has no version of this
|
|
1160
|
+
plugin that talks to a different host, and a setting for it would be a way to
|
|
1161
|
+
send the token somewhere else. The answer is read out of the typed `output`
|
|
1162
|
+
array, from the `output_text` of the assistant's message.
|
|
1163
|
+
|
|
1164
|
+
```yaml
|
|
1165
|
+
- module: FilterOpenAI
|
|
1166
|
+
config:
|
|
1167
|
+
token: sk-...
|
|
1168
|
+
model: gpt-5.6
|
|
1169
|
+
prompt: |
|
|
1170
|
+
Summarize the following articles as one digest, in Japanese.
|
|
1171
|
+
retry: 2
|
|
1172
|
+
interval: 2
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
#### FilterClaude — **Supported (external)**
|
|
1176
|
+
|
|
1177
|
+
`filter/claude.rb`. Sends each item's description to the Anthropic Messages
|
|
1178
|
+
API, `https://api.anthropic.com/v1/messages`, and replaces it with the answer.
|
|
1179
|
+
Anthropic authenticates with an `x-api-key` header rather than a bearer token,
|
|
1180
|
+
requires an API version header, and requires a `max_tokens` — so this plugin
|
|
1181
|
+
sends all three, and has one setting the others do not.
|
|
1182
|
+
|
|
1183
|
+
| Key | Type | Meaning |
|
|
1184
|
+
| --- | --- | --- |
|
|
1185
|
+
| `token` | string | Anthropic API key, sent as `x-api-key`. Required. |
|
|
1186
|
+
| `model` | string | Model name, as Anthropic names it. Required. |
|
|
1187
|
+
| `prompt` | string | The instruction, sent as the request's `system`. Required. |
|
|
1188
|
+
| `max_tokens` | integer | The longest answer to allow, which this API requires. Default `4096`. |
|
|
1189
|
+
| `retry` | integer | Attempts after a failure. Default `0`. |
|
|
1190
|
+
| `interval` | integer | Seconds between attempts. Default `0`. |
|
|
1191
|
+
|
|
1192
|
+
The `anthropic-version` header is a constant, not a setting: it is the version
|
|
1193
|
+
of the HTTP interface rather than of a model, and changing it is a change to
|
|
1194
|
+
this plugin. The answer is the `text` of the content blocks the API returns;
|
|
1195
|
+
blocks of other kinds are passed over.
|
|
1196
|
+
|
|
1197
|
+
```yaml
|
|
1198
|
+
- module: FilterClaude
|
|
1199
|
+
config:
|
|
1200
|
+
token: sk-ant-...
|
|
1201
|
+
model: claude-opus-5
|
|
1202
|
+
prompt: |
|
|
1203
|
+
Summarize the following articles as one digest, in Japanese.
|
|
1204
|
+
max_tokens: 2048
|
|
1205
|
+
retry: 2
|
|
1206
|
+
interval: 2
|
|
1207
|
+
```
|
|
1208
|
+
|
|
1209
|
+
#### FilterGemini — **Supported (external)**
|
|
1210
|
+
|
|
1211
|
+
`filter/gemini.rb`. Sends each item's description to the Google Gemini API and
|
|
1212
|
+
replaces it with the answer. Gemini names the model in the URL rather than in
|
|
1213
|
+
the body, so the endpoint is
|
|
1214
|
+
`https://generativelanguage.googleapis.com/v1beta/models/<model>:generateContent`,
|
|
1215
|
+
built from the Recipe's `model`. The API key goes in an `x-goog-api-key`
|
|
1216
|
+
header, which is how Google documents it and what keeps a credential out of a
|
|
1217
|
+
URL and out of anything that logs one.
|
|
1218
|
+
|
|
1219
|
+
| Key | Type | Meaning |
|
|
1220
|
+
| --- | --- | --- |
|
|
1221
|
+
| `token` | string | Gemini API key, sent as `x-goog-api-key`. Required. |
|
|
1222
|
+
| `model` | string | Model name, bare — `gemini-3.5-flash`, not `models/gemini-3.5-flash`. Required. |
|
|
1223
|
+
| `prompt` | string | The instruction, sent as `system_instruction`. Required. |
|
|
1224
|
+
| `retry` | integer | Attempts after a failure. Default `0`. |
|
|
1225
|
+
| `interval` | integer | Seconds between attempts. Default `0`. |
|
|
1226
|
+
|
|
1227
|
+
The request is built of `contents` and `parts` as this API defines them, and is
|
|
1228
|
+
not bent into another service's shape. The answer is the text of the first
|
|
1229
|
+
candidate's parts; an answer carrying no candidate — which is what a request
|
|
1230
|
+
stopped by a safety filter looks like — is an error rather than an empty
|
|
1231
|
+
description.
|
|
1232
|
+
|
|
1233
|
+
```yaml
|
|
1234
|
+
- module: FilterGemini
|
|
1235
|
+
config:
|
|
1236
|
+
token: AIza...
|
|
1237
|
+
model: gemini-3.5-flash
|
|
1238
|
+
prompt: |
|
|
1239
|
+
Summarize the following articles as one digest, in Japanese.
|
|
1240
|
+
retry: 2
|
|
1241
|
+
interval: 2
|
|
1242
|
+
```
|
|
1243
|
+
|
|
1244
|
+
#### FilterSakuraAI — **Supported (external)**
|
|
1245
|
+
|
|
1246
|
+
`filter/sakura_ai.rb`. Sends each item's description to the Sakura AI Engine,
|
|
1247
|
+
`https://api.ai.sakura.ad.jp/v1/chat/completions`, and replaces it with the
|
|
1248
|
+
answer. The token is a bearer token, and the request is the chat completions
|
|
1249
|
+
form: the prompt as a `system` message, the description as a `user` message.
|
|
1250
|
+
|
|
1251
|
+
| Key | Type | Meaning |
|
|
1252
|
+
| --- | --- | --- |
|
|
1253
|
+
| `token` | string | Sakura AI Engine token. Required. |
|
|
1254
|
+
| `model` | string | Model name, as the service's control panel lists it. Required. |
|
|
1255
|
+
| `prompt` | string | The instruction, sent as the `system` message. Required. |
|
|
1256
|
+
| `retry` | integer | Attempts after a failure. Default `0`. |
|
|
1257
|
+
| `interval` | integer | Seconds between attempts. Default `0`. |
|
|
1258
|
+
|
|
1259
|
+
**This interface is OpenAI-compatible, and this is still its own plugin.** It is
|
|
1260
|
+
a different service: a different endpoint, a different account, a different set
|
|
1261
|
+
of models, its own limits and its own errors, any of which may move without
|
|
1262
|
+
OpenAI moving. Folding it into `FilterOpenAI` behind a setting would trade a
|
|
1263
|
+
Recipe that says where the text goes for a Recipe that does not.
|
|
1264
|
+
|
|
1265
|
+
```yaml
|
|
1266
|
+
- module: FilterSakuraAI
|
|
1267
|
+
config:
|
|
1268
|
+
token: ...
|
|
1269
|
+
model: gpt-oss-120b
|
|
1270
|
+
prompt: |
|
|
1271
|
+
以下の記事群について、個別記事の要約を羅列するのではなく、
|
|
1272
|
+
全体を一つのダイジェストとして日本語で要約してください。
|
|
1273
|
+
retry: 2
|
|
1274
|
+
interval: 2
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
A digest, end to end: find the articles, drop the ones already seen, fetch
|
|
1278
|
+
their bodies, strip the markup, join them, ask once, write the answer out.
|
|
1279
|
+
|
|
1280
|
+
```yaml
|
|
1281
|
+
plugins:
|
|
1282
|
+
- module: CustomFeedWeb
|
|
1283
|
+
config:
|
|
1284
|
+
sites:
|
|
1285
|
+
- url: https://example.com/news/
|
|
1286
|
+
|
|
1287
|
+
- module: StorePermalink
|
|
1288
|
+
config:
|
|
1289
|
+
db: digest.db
|
|
1290
|
+
|
|
1291
|
+
- module: FilterFullFeed
|
|
1292
|
+
config:
|
|
1293
|
+
siteinfo: items_all.json
|
|
1294
|
+
|
|
1295
|
+
- module: FilterSanitize
|
|
1296
|
+
|
|
1297
|
+
- module: FilterJoin
|
|
1298
|
+
config:
|
|
1299
|
+
title: Daily Digest
|
|
1300
|
+
|
|
1301
|
+
- module: FilterSakuraAI
|
|
1302
|
+
config:
|
|
1303
|
+
token: ...
|
|
1304
|
+
model: gpt-oss-120b
|
|
1305
|
+
prompt: |
|
|
1306
|
+
以下の記事群について、個別記事の要約を羅列するのではなく、
|
|
1307
|
+
全体を一つのダイジェストとして日本語で要約してください。
|
|
1308
|
+
retry: 2
|
|
1309
|
+
interval: 2
|
|
1310
|
+
|
|
1311
|
+
- module: PublishMarkdown
|
|
1312
|
+
config:
|
|
1313
|
+
file: ~/.automatic/markdown/digest.md
|
|
1314
|
+
mode: append
|
|
1315
|
+
```
|
|
1316
|
+
|
|
1317
|
+
Changing service is changing the one entry: `FilterSakuraAI` for
|
|
1318
|
+
`FilterOpenAI`, `FilterClaude` or `FilterGemini`, with that plugin's own
|
|
1319
|
+
settings. Nothing before or after it changes.
|
|
1320
|
+
|
|
1321
|
+
### 6.4 Store
|
|
1322
|
+
|
|
1323
|
+
Persist, and drop what has already been seen. A store plugin is what makes a
|
|
1324
|
+
Recipe safe to run repeatedly.
|
|
1325
|
+
|
|
1326
|
+
`StorePermalink`, `StoreFullText` and `StoreDigest` keep their records in SQLite
|
|
1327
|
+
through ActiveRecord. Both gems are these plugins' own optional dependencies
|
|
1328
|
+
rather than the framework's: `gem install activerecord sqlite3`, or the `store`
|
|
1329
|
+
group in a checkout. A Recipe that stores nothing needs neither.
|
|
1330
|
+
See [`DEPLOYMENT.md`](DEPLOYMENT.md).
|
|
1331
|
+
|
|
1332
|
+
They answer different questions, and a Recipe may ask more than one of them:
|
|
1333
|
+
`StorePermalink` whether this **link** has been seen, `StoreFullText` whether
|
|
1334
|
+
this link or title has been stored with its body, `StoreDigest` whether this
|
|
1335
|
+
**content** has been seen, whatever it was published under.
|
|
1336
|
+
|
|
1337
|
+
#### StorePermalink — **Supported**
|
|
1338
|
+
|
|
1339
|
+
`store/permalink.rb`. Records each item's link in SQLite and passes on only the
|
|
1340
|
+
links not already recorded. The usual guard against publishing the same item
|
|
1341
|
+
twice.
|
|
1342
|
+
|
|
1343
|
+
| Key | Type | Meaning |
|
|
1344
|
+
| --- | --- | --- |
|
|
1345
|
+
| `db` | string | Database file name, under `~/.automatic/db`. Required. |
|
|
1346
|
+
|
|
1347
|
+
The file is created on first use, as is the table. Deleting it makes everything
|
|
1348
|
+
look new again.
|
|
1349
|
+
|
|
1350
|
+
#### StoreFullText — **Supported**
|
|
1351
|
+
|
|
1352
|
+
`store/full_text.rb`. Records title, link, description and `content_encoded`,
|
|
1353
|
+
and passes on only what is new. Deduplicates on link **or** title, so a
|
|
1354
|
+
republished article with a new URL is not stored twice. Pair with
|
|
1355
|
+
`FilterFullFeed` to archive article bodies.
|
|
1356
|
+
|
|
1357
|
+
| Key | Type | Meaning |
|
|
1358
|
+
| --- | --- | --- |
|
|
1359
|
+
| `db` | string | Database file name, under `~/.automatic/db`. Required. |
|
|
1360
|
+
|
|
1361
|
+
#### StoreDigest — **Supported**
|
|
1362
|
+
|
|
1363
|
+
`store/digest.rb`. Takes the SHA-256 digest of the item fields the Recipe names,
|
|
1364
|
+
records it in SQLite, and passes on only the items whose digest was not recorded
|
|
1365
|
+
already. Content identity, where `StorePermalink` is URL identity: a page that
|
|
1366
|
+
reissues one article under a new URL is one item here, and one URL whose content
|
|
1367
|
+
changed is a new item — the opposite of what `StorePermalink` decides in both
|
|
1368
|
+
cases. Pair it with `CustomFeedWeb`, whose items are whatever an index page
|
|
1369
|
+
currently lists.
|
|
1370
|
+
|
|
1371
|
+
| Key | Type | Meaning |
|
|
1372
|
+
| --- | --- | --- |
|
|
1373
|
+
| `db` | string | Database file name, under `~/.automatic/db`. Required. |
|
|
1374
|
+
| `fields` | list | The fields the digest is taken over, in the order written. Default: `title`, `description`. |
|
|
1375
|
+
|
|
1376
|
+
```yaml
|
|
1377
|
+
plugins:
|
|
1378
|
+
- module: CustomFeedWeb
|
|
1379
|
+
config:
|
|
1380
|
+
sites:
|
|
1381
|
+
- url: https://example.com/news/
|
|
1382
|
+
|
|
1383
|
+
- module: StoreDigest
|
|
1384
|
+
config:
|
|
1385
|
+
db: web-digest.db
|
|
1386
|
+
|
|
1387
|
+
- module: PublishMarkdown
|
|
1388
|
+
config:
|
|
1389
|
+
file: ~/.automatic/markdown/web-watch.md
|
|
1390
|
+
mode: append
|
|
1391
|
+
```
|
|
1392
|
+
|
|
1393
|
+
The first run passes on everything the page listed and records a digest for
|
|
1394
|
+
each. The second passes on nothing, because the page still lists the same
|
|
1395
|
+
articles. A run in which one article has been added passes on that one.
|
|
1396
|
+
|
|
1397
|
+
**`fields`** names any of `title`, `link`, `description`, `author`, `comments`,
|
|
1398
|
+
`source` and `content_encoded`. `date` is not among them — an item republished
|
|
1399
|
+
unchanged carries a new date often enough to defeat the purpose — and neither is
|
|
1400
|
+
`enclosure`, which is a structure rather than a value.
|
|
1401
|
+
|
|
1402
|
+
- The order is part of the fingerprint. `[title, description]` and
|
|
1403
|
+
`[description, title]` are two different specifications and produce different
|
|
1404
|
+
digests; nothing is sorted behind the Recipe's back.
|
|
1405
|
+
- **The fields named are the fields used.** A Recipe that asks for
|
|
1406
|
+
`content_encoded` gets `content_encoded`, and an item whose body is empty is
|
|
1407
|
+
not quietly judged on its title instead. What the Recipe says two identical
|
|
1408
|
+
items are is what this plugin obeys.
|
|
1409
|
+
- Anything but an absent `fields` is taken as written: an empty list, a name
|
|
1410
|
+
that is not a field, a name given twice and a value that is not a list are
|
|
1411
|
+
each refused with an `ArgumentError` before the database is opened, rather
|
|
1412
|
+
than corrected into something the Recipe did not ask for.
|
|
1413
|
+
- `db` is required, and an empty name is refused the same way.
|
|
1414
|
+
|
|
1415
|
+
**What "the same content" means here.** Each value is read as UTF-8, with
|
|
1416
|
+
invalid and undefined characters replaced, normalized to Unicode NFC, its runs
|
|
1417
|
+
of whitespace collapsed to one space and its ends trimmed. The values are joined
|
|
1418
|
+
with their field names into one canonical string — `title`, NUL, the title, NUL,
|
|
1419
|
+
`description`, NUL, the description — and that string is hashed with SHA-256.
|
|
1420
|
+
The algorithm is fixed; there is no setting for it and no column recording it.
|
|
1421
|
+
|
|
1422
|
+
Two items are therefore the same item when their selected fields are **exactly**
|
|
1423
|
+
equal after that normalization, and not otherwise. A difference of case, of
|
|
1424
|
+
punctuation or of markup is a difference of content. This is not similarity
|
|
1425
|
+
matching: there is no fuzzy comparison, no edit distance, no embedding and no
|
|
1426
|
+
semantic judgement anywhere in it, and two articles that report one event in
|
|
1427
|
+
different words are two items.
|
|
1428
|
+
|
|
1429
|
+
**An item with nothing to hash is passed on, not stored.** Where every field the
|
|
1430
|
+
Recipe named is empty after normalization — `fields: [description]` on an item
|
|
1431
|
+
that has no description — there is nothing to identify the item by. Hashing the
|
|
1432
|
+
empty string would make every such item the same item and silence all but the
|
|
1433
|
+
first of them for good, so instead the plugin logs a warning naming the item's
|
|
1434
|
+
link and passes it on unjudged. An item is never lost for having too little
|
|
1435
|
+
content. A field that is empty while another is not takes part in the digest as
|
|
1436
|
+
an empty value, so an item with a title and no description differs from the same
|
|
1437
|
+
item with both.
|
|
1438
|
+
|
|
1439
|
+
**A database failure ends the run.** A failed read or a failed write is not
|
|
1440
|
+
rescued here, which is deliberate and is a difference from `StoreFullText`: an
|
|
1441
|
+
item passed on after its digest failed to store would be published again on the
|
|
1442
|
+
next run, and de-duplication that quietly stops de-duplicating is worse than a
|
|
1443
|
+
run that stops. The digest column carries a unique index, so two runs of one
|
|
1444
|
+
Recipe overlapping cannot both store one digest; the second write is rejected
|
|
1445
|
+
and its item is treated as seen.
|
|
1446
|
+
|
|
1447
|
+
The digest is all that is stored — no title, no body, no URL. Recording what an
|
|
1448
|
+
item said is `StoreFullText`'s work, and pairing the two is how a Recipe gets
|
|
1449
|
+
both.
|
|
1450
|
+
|
|
1451
|
+
`StorePermalink` and `StoreDigest` may stand in one Recipe, each with its own
|
|
1452
|
+
database, and the pair is worth having: the first drops what has been seen at
|
|
1453
|
+
that URL, the second drops what has been seen under any URL.
|
|
1454
|
+
|
|
1455
|
+
```yaml
|
|
1456
|
+
plugins:
|
|
1457
|
+
- module: CustomFeedWeb
|
|
1458
|
+
config:
|
|
1459
|
+
sites:
|
|
1460
|
+
- url: https://example.com/news/
|
|
1461
|
+
|
|
1462
|
+
- module: FilterFullFeed
|
|
1463
|
+
|
|
1464
|
+
- module: StoreDigest
|
|
1465
|
+
config:
|
|
1466
|
+
db: fulltext-digest.db
|
|
1467
|
+
fields:
|
|
1468
|
+
- content_encoded
|
|
1469
|
+
|
|
1470
|
+
- module: PublishMarkdown
|
|
1471
|
+
config:
|
|
1472
|
+
file: ~/.automatic/markdown/web-watch.md
|
|
1473
|
+
mode: append
|
|
1474
|
+
```
|
|
1475
|
+
|
|
1476
|
+
With the body fetched first, the digest is taken over the article itself, so a
|
|
1477
|
+
headline edited between runs no longer republishes the article.
|
|
1478
|
+
|
|
1479
|
+
#### StoreFile — **Supported**
|
|
1480
|
+
|
|
1481
|
+
`store/file.rb`. Downloads what each link points at and rewrites the link to a
|
|
1482
|
+
`file://` URI, which is how `PublishAmazonS3` later knows it has a local file.
|
|
1483
|
+
|
|
1484
|
+
| Key | Type | Meaning |
|
|
1485
|
+
| --- | --- | --- |
|
|
1486
|
+
| `path` | string | Directory to save into; created if absent. Required. |
|
|
1487
|
+
| `retry` | integer | Attempts after the first. Default `0`. |
|
|
1488
|
+
| `interval` | integer | Seconds between downloads. Default `0`. |
|
|
1489
|
+
| `access_key` | string | S3 only. Omit to use the SDK's own credential chain. |
|
|
1490
|
+
| `secret_key` | string | S3 only. |
|
|
1491
|
+
| `bucket_name` | string | S3 only. The link's own host is used where this is absent. |
|
|
1492
|
+
| `region` | string | S3 only. Omit to use the SDK's own resolution. |
|
|
1493
|
+
|
|
1494
|
+
Only HTTP and HTTPS links are downloaded: a link comes from a feed, and a store
|
|
1495
|
+
plugin that would read `file://` on being asked to is a store plugin that can
|
|
1496
|
+
be asked to read anything.
|
|
1497
|
+
|
|
1498
|
+
A link whose scheme is `s3://` or `s3n://` is fetched from a bucket instead,
|
|
1499
|
+
through **AWS SDK for Ruby version 3** — `aws-sdk-s3`, the SDK AWS publishes
|
|
1500
|
+
today. This was written against version 1's `AWS::S3`, which no published gem
|
|
1501
|
+
provides any more. `s3n://` is what Recipes written for this plugin use and is
|
|
1502
|
+
still accepted; `s3://` is the spelling everything else uses and is accepted as
|
|
1503
|
+
well. The gem is required inside that branch, so the ordinary download path
|
|
1504
|
+
works without it installed: `gem install aws-sdk-s3`, or the `s3` group in a
|
|
1505
|
+
checkout.
|
|
1506
|
+
|
|
1507
|
+
Set `interval` when downloading a series from one host.
|
|
1508
|
+
|
|
1509
|
+
### 6.5 Provide
|
|
1510
|
+
|
|
1511
|
+
#### ProvideFluentd — **Supported (external)**
|
|
1512
|
+
|
|
1513
|
+
`provide/fluentd.rb`. Posts each item's `content_encoded` to Fluentd. Distinct
|
|
1514
|
+
from `PublishFluentd`, which posts the item's fields. Pair with
|
|
1515
|
+
`SubscriptionXml` to move an XML API into Fluentd. Needs the `fluent-logger`
|
|
1516
|
+
gem and a Fluentd instance.
|
|
1517
|
+
|
|
1518
|
+
| Key | Type | Meaning |
|
|
1519
|
+
| --- | --- | --- |
|
|
1520
|
+
| `host` | string | Fluentd host |
|
|
1521
|
+
| `port` | integer | Fluentd port |
|
|
1522
|
+
| `tag` | string | Tag, for example `automatic.feed` |
|
|
1523
|
+
| `mode` | string | `test` builds no connection and sends nothing |
|
|
1524
|
+
|
|
1525
|
+
`content_encoded` must be something Fluentd accepts as a record; a plain string
|
|
1526
|
+
is logged as an error and skipped.
|
|
1527
|
+
|
|
1528
|
+
### 6.6 Notify
|
|
1529
|
+
|
|
1530
|
+
#### NotifyIkachan — **Supported (external)**
|
|
1531
|
+
|
|
1532
|
+
`notify/ikachan.rb`. Posts each item to an IRC channel through an `ikachan`
|
|
1533
|
+
HTTP-to-IRC gateway, joining the channel first. The gateway is software the
|
|
1534
|
+
operator runs; there is no service to be shut down.
|
|
1535
|
+
|
|
1536
|
+
| Key | Type | Meaning |
|
|
1537
|
+
| --- | --- | --- |
|
|
1538
|
+
| `url` | string | Gateway base URL. Required. |
|
|
1539
|
+
| `port` | integer | Gateway port. Default `4979`. |
|
|
1540
|
+
| `channels` | string | Comma-separated; a leading `#` is added if absent. Required. |
|
|
1541
|
+
| `command` | string | `notice` or `privmsg`. Default `notice`. |
|
|
1542
|
+
| `interval` | integer | Seconds between posts. Default `0`. |
|
|
1543
|
+
|
|
1544
|
+
Honours a `PROXY` environment variable, on port 8080. A gateway reached over
|
|
1545
|
+
`https` is spoken to over `https`; the connection used to be plain whatever the
|
|
1546
|
+
URL said. The channel and the message are form-encoded rather than interpolated
|
|
1547
|
+
into the request body, so an item whose title carries an `&` or a space reaches
|
|
1548
|
+
the channel whole instead of splitting the request.
|
|
1549
|
+
|
|
1550
|
+
### 6.7 Publish
|
|
1551
|
+
|
|
1552
|
+
Send the result outward, or print it. Normally last in a Recipe. This is where
|
|
1553
|
+
the pipeline is written out in a form that is not the pipeline's own, so each of
|
|
1554
|
+
these plugins is a serializer as much as a destination.
|
|
1555
|
+
|
|
1556
|
+
#### PublishMarkdown — **Supported**
|
|
1557
|
+
|
|
1558
|
+
`publish/markdown.rb`. Renders the pipeline as a Markdown document and writes it
|
|
1559
|
+
to standard output or to a file. It needs no service, no account and no
|
|
1560
|
+
credential, which is what makes it the plugin a Recipe can end with on any
|
|
1561
|
+
machine, and the one to reach for when the result is meant to be read later —
|
|
1562
|
+
by a person, by `grep`, by whatever is given the file next.
|
|
1563
|
+
|
|
1564
|
+
```yaml
|
|
1565
|
+
- module: PublishMarkdown
|
|
1566
|
+
config:
|
|
1567
|
+
file: ~/notes/feeds.md
|
|
1568
|
+
mode: append
|
|
1569
|
+
```
|
|
1570
|
+
|
|
1571
|
+
| Key | Type | Meaning |
|
|
1572
|
+
| --- | --- | --- |
|
|
1573
|
+
| `file` | string | Path of the file to write. `~` is expanded and a missing parent directory is created. Absent: standard output. |
|
|
1574
|
+
| `mode` | string | `append`, the default, or `overwrite`. Read only when `file` is set. |
|
|
1575
|
+
|
|
1576
|
+
Both are optional: `PublishMarkdown` with no `config` writes the document to
|
|
1577
|
+
standard output.
|
|
1578
|
+
|
|
1579
|
+
##### What it writes
|
|
1580
|
+
|
|
1581
|
+
One section per item, in pipeline order — the feeds in the order they arrive,
|
|
1582
|
+
the items in the order their feed carries them. Nothing sorts, groups or
|
|
1583
|
+
de-duplicates here; `FilterSort` and the store plugins are where that belongs.
|
|
1584
|
+
|
|
1585
|
+
```markdown
|
|
1586
|
+
## An item's title
|
|
1587
|
+
|
|
1588
|
+
- Link: <https://example.com/a>
|
|
1589
|
+
- Date: 2026-08-14 10:00:00 +0900
|
|
1590
|
+
- Author: someone@example.com
|
|
1591
|
+
|
|
1592
|
+
The body of the item, as text.
|
|
1593
|
+
```
|
|
1594
|
+
|
|
1595
|
+
- **The title is a level-2 ATX heading**, and that heading is the item boundary:
|
|
1596
|
+
a section starts at `## ` and runs to the next one. An item with no title uses
|
|
1597
|
+
its link as the heading, and an item with neither is headed `(untitled)`.
|
|
1598
|
+
Level 2 rather than level 1, so that a document these sections are collected
|
|
1599
|
+
into can carry a title of its own.
|
|
1600
|
+
- **The metadata list** follows the heading: one `- Field: value` bullet per
|
|
1601
|
+
field the item carries, in the fixed order `Link`, `Date`, `Author`,
|
|
1602
|
+
`Comments`, `Source`, `Enclosure`. A field that is `nil` or empty produces no
|
|
1603
|
+
bullet, and an item carrying none produces no list. URLs are written as
|
|
1604
|
+
autolinks — `<https://example.com/a>` — so that a renderer makes them links
|
|
1605
|
+
and `grep` still sees the URL.
|
|
1606
|
+
- **The body** is `content_encoded` when the item has one, and `description`
|
|
1607
|
+
otherwise, on the ground that a feed carrying both puts the summary in the
|
|
1608
|
+
second and the article in the first. An item with neither has no body, and its
|
|
1609
|
+
section is the heading and the metadata list.
|
|
1610
|
+
- **The date** is formatted `%Y-%m-%d %H:%M:%S %z` from the item's own date, in
|
|
1611
|
+
the zone that date carries. Nothing is converted to local time, because the
|
|
1612
|
+
same input then produces the same output on any machine.
|
|
1613
|
+
- **`source` and `enclosure` are elements rather than strings** in a parsed
|
|
1614
|
+
feed. The source's text is used, and the enclosure's URL; where either is
|
|
1615
|
+
already a plain string it is used as it stands.
|
|
1616
|
+
- **A section is followed by a blank line.** Appending a document to a document
|
|
1617
|
+
therefore stays valid Markdown, and a file always ends with a newline.
|
|
1618
|
+
- **Nothing else is emitted.** No YAML front matter, no run header, no
|
|
1619
|
+
timestamp of the run, no horizontal rules — nothing that is not in the
|
|
1620
|
+
pipeline. The output for a given pipeline is byte-for-byte the same on every
|
|
1621
|
+
run, which is what makes it worth committing and diffing. A format is easy to
|
|
1622
|
+
add later and impossible to take away, so this one starts as ordinary
|
|
1623
|
+
Markdown and no more.
|
|
1624
|
+
- **An empty pipeline writes nothing at all**: no file is created, and an
|
|
1625
|
+
existing file is neither appended to nor truncated. A run in which the store
|
|
1626
|
+
plugin found nothing new leaves the document exactly as it was.
|
|
1627
|
+
|
|
1628
|
+
##### HTML in a body
|
|
1629
|
+
|
|
1630
|
+
`description` and `content_encoded` carry HTML in most real feeds, and a
|
|
1631
|
+
document that dumped it unchanged would be HTML in a file named `.md` rather
|
|
1632
|
+
than Markdown. What the plugin does instead:
|
|
1633
|
+
|
|
1634
|
+
- A body with no markup in it is passed through as it stands.
|
|
1635
|
+
- A body containing markup is **reduced to text**: `script` and `style` are
|
|
1636
|
+
dropped with their contents, `<br>` becomes a line break, block elements
|
|
1637
|
+
become paragraph breaks, character entities are decoded, and the tags
|
|
1638
|
+
themselves are discarded.
|
|
1639
|
+
- In both cases the whitespace is normalized: line endings become `\n`,
|
|
1640
|
+
trailing whitespace goes, and a run of blank lines collapses to one. Text
|
|
1641
|
+
that came from markup also loses the source document's own indentation, which
|
|
1642
|
+
is layout rather than content and which Markdown would otherwise read as a
|
|
1643
|
+
code block.
|
|
1644
|
+
|
|
1645
|
+
This is deliberately **not** an HTML-to-Markdown translation. Rendering
|
|
1646
|
+
arbitrary markup back into equivalent Markdown — tables, nested lists, inline
|
|
1647
|
+
links, images — is a large job with a large library behind it, and a library
|
|
1648
|
+
that size does not become a dependency for one plugin
|
|
1649
|
+
([`POLICY.md`](POLICY.md) section 9.1). The result is defined by its two ends —
|
|
1650
|
+
the text survives, the markup does not — which is what both a reader and a
|
|
1651
|
+
program reading the file want from it. A link inside a body becomes its own
|
|
1652
|
+
text; the item's own link is in the metadata list, where nothing loses it.
|
|
1653
|
+
|
|
1654
|
+
**This plugin needs no gem of its own.** It uses `nokogiri` to reduce a body
|
|
1655
|
+
where `nokogiri` is installed, and reduces it with its own substitution where it
|
|
1656
|
+
is not, so that a Recipe ending here runs on a plain `gem install automatic` and
|
|
1657
|
+
no Recipe pays for an HTML parser it did not ask for. The two produce the same
|
|
1658
|
+
document for the bodies a feed carries; a parser is simply better at markup that
|
|
1659
|
+
is badly malformed, which is the reason to install `nokogiri` if you publish
|
|
1660
|
+
from feeds that produce it. The specs hold both to the same output.
|
|
1661
|
+
|
|
1662
|
+
Where a different treatment is wanted, the pipeline already has the means:
|
|
1663
|
+
`FilterSanitize` before this plugin decides what markup survives into the
|
|
1664
|
+
description, and this plugin then reduces what is left.
|
|
1665
|
+
|
|
1666
|
+
The body is otherwise written as it is: Markdown is **not** escaped, so a body
|
|
1667
|
+
line beginning with `#` or `-` renders as a heading or a list item. Escaping it
|
|
1668
|
+
would make the text worse to read in exchange for a rendering nothing depends
|
|
1669
|
+
on.
|
|
1670
|
+
|
|
1671
|
+
##### Where the output goes
|
|
1672
|
+
|
|
1673
|
+
With `file`, the document is written there, appended by default so that a
|
|
1674
|
+
Recipe in `cron` builds up one growing document, and `mode: overwrite` replaces
|
|
1675
|
+
the file when what is wanted is the current state rather than a history. The
|
|
1676
|
+
plugin writes only that file, and only what its `config` names.
|
|
1677
|
+
|
|
1678
|
+
With no `file`, the document goes to standard output, which is what a pipe or a
|
|
1679
|
+
shell redirect wants:
|
|
1680
|
+
|
|
1681
|
+
```sh
|
|
1682
|
+
automatic -c feeds.yml > today.md
|
|
1683
|
+
```
|
|
1684
|
+
|
|
1685
|
+
Standard output is also where `Automatic::Log` writes
|
|
1686
|
+
([`REQUIREMENTS.md`](REQUIREMENTS.md) section 14), so a redirect collects the
|
|
1687
|
+
log lines into the document as well. Set the log level for that Recipe, which
|
|
1688
|
+
is the setting that already exists for it:
|
|
1689
|
+
|
|
1690
|
+
```yaml
|
|
1691
|
+
global:
|
|
1692
|
+
log:
|
|
1693
|
+
level: none
|
|
1694
|
+
|
|
1695
|
+
plugins:
|
|
1696
|
+
# ...
|
|
1697
|
+
- module: PublishMarkdown
|
|
1698
|
+
```
|
|
1699
|
+
|
|
1700
|
+
`file` avoids the question entirely: the document goes to the file and the log
|
|
1701
|
+
keeps standard output. Which to use is an operational choice and
|
|
1702
|
+
[`DEPLOYMENT.md`](DEPLOYMENT.md) says more about it.
|
|
1703
|
+
|
|
1704
|
+
The plugin logs one line per run at `info`, naming the destination and the
|
|
1705
|
+
number of items written; it does not log a line per item, because that log is
|
|
1706
|
+
what would be interleaved with the document.
|
|
1707
|
+
|
|
1708
|
+
#### PublishConsole — **Supported**
|
|
1709
|
+
|
|
1710
|
+
`publish/console.rb`. Prints each item with `pretty_inspect`. The plugin to end
|
|
1711
|
+
a Recipe with while writing it. No settings.
|
|
1712
|
+
|
|
1713
|
+
#### PublishConsoleLink — **Supported**
|
|
1714
|
+
|
|
1715
|
+
`publish/console_link.rb`. Prints each item's link, one per line, and nothing
|
|
1716
|
+
else. Useful in a pipe. No settings.
|
|
1717
|
+
|
|
1718
|
+
#### PublishEject — **Supported (external)**
|
|
1719
|
+
|
|
1720
|
+
`publish/eject.rb`. Opens and closes the optical drive once per item, using
|
|
1721
|
+
`eject` on GNU/Linux or `drutil` on macOS. A physical notification. Needs the
|
|
1722
|
+
command to exist.
|
|
1723
|
+
|
|
1724
|
+
| Key | Type | Meaning |
|
|
1725
|
+
| --- | --- | --- |
|
|
1726
|
+
| `interval` | integer | Seconds between items. Default `0`. |
|
|
1727
|
+
|
|
1728
|
+
#### PublishMemcached — **Supported (external)**
|
|
1729
|
+
|
|
1730
|
+
`publish/memcached.rb`. Collects the whole pipeline into one hash keyed by link
|
|
1731
|
+
and stores it under a single key. Needs the `dalli` gem and a memcached server.
|
|
1732
|
+
|
|
1733
|
+
| Key | Type | Meaning |
|
|
1734
|
+
| --- | --- | --- |
|
|
1735
|
+
| `host` | string | memcached host. Required. |
|
|
1736
|
+
| `port` | string or integer | memcached port. Required. |
|
|
1737
|
+
| `key` | string | The key to store under. Required. |
|
|
1738
|
+
|
|
1739
|
+
`port: 11211` written as a number now works. The server address was built by
|
|
1740
|
+
concatenating strings, so a Recipe that did not quote the port ended the run
|
|
1741
|
+
with a `TypeError`.
|
|
1742
|
+
|
|
1743
|
+
It writes one key per run, replacing the previous value. `key` collides with a
|
|
1744
|
+
`Hashie::Mash` built-in and logs a warning per run; the setting works, see
|
|
1745
|
+
section 2.6.1.
|
|
1746
|
+
|
|
1747
|
+
#### PublishFluentd — **Supported (external)**
|
|
1748
|
+
|
|
1749
|
+
`publish/fluentd.rb`. Posts each item's title, link, description,
|
|
1750
|
+
`content_encoded` and a timestamp to Fluentd. Needs the `fluent-logger` gem and
|
|
1751
|
+
a Fluentd instance.
|
|
1752
|
+
|
|
1753
|
+
| Key | Type | Meaning |
|
|
1754
|
+
| --- | --- | --- |
|
|
1755
|
+
| `host` | string | Fluentd host |
|
|
1756
|
+
| `port` | integer | Fluentd port |
|
|
1757
|
+
| `tag` | string | Tag, for example `automatic.feed` |
|
|
1758
|
+
| `mode` | string | `test` builds no connection and sends nothing |
|
|
1759
|
+
|
|
1760
|
+
#### PublishInstapaper — **Supported (external)**
|
|
1761
|
+
|
|
1762
|
+
`publish/instapaper.rb`. Adds each item to Instapaper through its Simple API,
|
|
1763
|
+
with HTTP basic authentication over TLS.
|
|
1764
|
+
|
|
1765
|
+
| Key | Type | Meaning |
|
|
1766
|
+
| --- | --- | --- |
|
|
1767
|
+
| `email` | string | Account. Required. |
|
|
1768
|
+
| `password` | string | Password; may be empty for an account without one. |
|
|
1769
|
+
| `retry` | integer | Attempts after the first. Default `0`. |
|
|
1770
|
+
| `interval` | integer | Seconds between posts. Default `0`. |
|
|
1771
|
+
|
|
1772
|
+
The Simple API is still published at `www.instapaper.com/api/simple` and takes
|
|
1773
|
+
the same three parameters this sends. The status rests on that published
|
|
1774
|
+
documentation rather than on a live call with an account, so run
|
|
1775
|
+
`test/integration/test_instapaper.yml` once before putting it in `cron`.
|
|
1776
|
+
|
|
1777
|
+
Constructing the plugin authenticates, so a wrong credential fails the run
|
|
1778
|
+
before any item is posted rather than once per item. Nothing logs the account
|
|
1779
|
+
or the password. This plugin used to disable TLS certificate verification,
|
|
1780
|
+
which was corrected in the previous release; the connection now also has a
|
|
1781
|
+
timeout, so an unanswered request ends rather than hanging a `cron` job.
|
|
1782
|
+
|
|
1783
|
+
#### PublishAmazonS3 — **Supported (external)**
|
|
1784
|
+
|
|
1785
|
+
`publish/amazon_s3.rb`. Uploads files whose link is a `file://` URI to S3,
|
|
1786
|
+
normally after `StoreFile`.
|
|
1787
|
+
|
|
1788
|
+
| Key | Type | Meaning |
|
|
1789
|
+
| --- | --- | --- |
|
|
1790
|
+
| `access_key` | string | Access key ID. Omit to use the SDK's own credential chain. |
|
|
1791
|
+
| `secret_key` | string | Secret access key. |
|
|
1792
|
+
| `bucket_name` | string | Bucket. |
|
|
1793
|
+
| `target_path` | string | Prefix within the bucket. |
|
|
1794
|
+
| `region` | string | Omit to use the SDK's own resolution. |
|
|
1795
|
+
| `mode` | string | `test` logs the upload without performing it. |
|
|
1796
|
+
|
|
1797
|
+
Migrated from `AWS::S3`, which only AWS SDK for Ruby version 1 provided, to
|
|
1798
|
+
`Aws::S3::Client` from **version 3** — the `aws-sdk-s3` gem, which is the SDK
|
|
1799
|
+
AWS publishes and maintains. The Recipe keys are unchanged; `region` is new and
|
|
1800
|
+
optional. The gem is an optional plugin dependency and is required only when an
|
|
1801
|
+
upload is actually made, so `mode: test` needs neither the gem nor an account:
|
|
1802
|
+
`gem install aws-sdk-s3`, or the `s3` group in a checkout.
|
|
1803
|
+
|
|
1804
|
+
Leaving `access_key` and `secret_key` out is now the better way to run this. The
|
|
1805
|
+
SDK then resolves credentials from the environment, a shared profile or an
|
|
1806
|
+
instance role, which keeps a long-lived secret out of the Recipe file.
|
|
1807
|
+
|
|
1808
|
+
#### PublishHatenaBookmark — **Needs rework**
|
|
1809
|
+
|
|
1810
|
+
`publish/hatena_bookmark.rb`. Bookmarks each link to Hatena Bookmark by posting
|
|
1811
|
+
an Atom entry with WSSE authentication to `b.hatena.ne.jp/atom/post`.
|
|
1812
|
+
|
|
1813
|
+
Hatena Bookmark is operating and has a current bookmarking API; the WSSE
|
|
1814
|
+
AtomPub interface this speaks has been superseded by an OAuth one, and Hatena's
|
|
1815
|
+
own documentation no longer describes WSSE for this API. Restoring the plugin
|
|
1816
|
+
means the current endpoint and the current authentication, which is a
|
|
1817
|
+
credential format this Recipe cannot express: consumer key and secret plus an
|
|
1818
|
+
access token and secret, obtained through an authorization flow, in place of an
|
|
1819
|
+
ID and a password. That is a self-contained piece of work and it is why this is
|
|
1820
|
+
not classified any higher.
|
|
1821
|
+
|
|
1822
|
+
The transport was corrected in the meantime: the request goes over HTTPS, so an
|
|
1823
|
+
operator who runs it does not put a password digest on the wire in the clear,
|
|
1824
|
+
and the nonce is drawn from a random source rather than from the clock. Neither
|
|
1825
|
+
is a claim that the plugin works.
|
|
1826
|
+
|
|
1827
|
+
| Key | Type | Meaning |
|
|
1828
|
+
| --- | --- | --- |
|
|
1829
|
+
| `username` | string | Hatena ID |
|
|
1830
|
+
| `password` | string | Password |
|
|
1831
|
+
| `interval` | integer | Seconds between posts. Default `0`. |
|
|
1832
|
+
|
|
1833
|
+
---
|
|
1834
|
+
|
|
1835
|
+
## 7. Summary
|
|
1836
|
+
|
|
1837
|
+
| Status | Count | Plugins |
|
|
1838
|
+
| --- | --- | --- |
|
|
1839
|
+
| Supported | 26 | `SubscriptionFeed`, `SubscriptionLink`, `SubscriptionXml`, `SubscriptionText`, `CustomFeedWeb`, `FilterIgnore`, `FilterAccept`, `FilterSort`, `FilterOne`, `FilterRand`, `FilterClear`, `FilterImage`, `FilterImageSource`, `FilterAbsoluteURI`, `FilterSanitize`, `FilterTumblrResize`, `FilterDescriptionLink`, `FilterGithubFeed`, `FilterJoin`, `StorePermalink`, `StoreFullText`, `StoreDigest`, `StoreFile`, `PublishMarkdown`, `PublishConsole`, `PublishConsoleLink` |
|
|
1840
|
+
| Supported (external) | 14 | `SubscriptionTumblr`, `CustomFeedSVNLog`, `FilterFullFeed`, `FilterOpenAI`, `FilterClaude`, `FilterGemini`, `FilterSakuraAI`, `ProvideFluentd`, `NotifyIkachan`, `PublishEject`, `PublishMemcached`, `PublishFluentd`, `PublishInstapaper`, `PublishAmazonS3` |
|
|
1841
|
+
| Needs rework | 1 | `PublishHatenaBookmark` |
|
|
1842
|
+
|
|
1843
|
+
Forty-one plugins. Every one of them either runs, or names the one thing it
|
|
1844
|
+
needs from the operator; the single exception says what is wrong with it and
|
|
1845
|
+
what fixing it would take.
|
|
1846
|
+
|
|
1847
|
+
`spec/doc/plugins_catalogue_spec.rb` holds this table to the files in
|
|
1848
|
+
`plugins/`: an entry with no file, a file with no entry, and a count that has
|
|
1849
|
+
been left behind by an edit are all failures of the ordinary test suite.
|
|
1850
|
+
|
|
1851
|
+
## 8. Plugins that were removed
|
|
1852
|
+
|
|
1853
|
+
Eleven plugins were removed in v26.08 rather than kept as history. Each one
|
|
1854
|
+
talked to a service that has shut down, or through an API that has been
|
|
1855
|
+
withdrawn with no replacement that a plugin this size can reach:
|
|
1856
|
+
|
|
1857
|
+
| Removed | Why |
|
|
1858
|
+
| --- | --- |
|
|
1859
|
+
| `SubscriptionTwitter`, `SubscriptionTwitterSearch`, `PublishTwitter` | Written against the `twitter` gem's version 4 interface and against `twitter.com` markup from 2014. The site is X, the gem's classes are gone, and the current API has no free tier — posting and searching are billed per call. |
|
|
1860
|
+
| `SubscriptionPocket`, `PublishPocket` | Pocket was shut down by Mozilla on 8 July 2025 and its API with it. |
|
|
1861
|
+
| `PublishHipchat` | Atlassian discontinued HipChat and shut the service down in February 2019; there is no endpoint. |
|
|
1862
|
+
| `PublishGoogleCalendar` | Speaks the Calendar GData API version 2 with ClientLogin, shut down in November 2014 and April 2015, through a gem last published in 2009. |
|
|
1863
|
+
| `SubscriptionWeather` | livedoor Weather Hacks ended on 31 July 2020, through a gem last published in 2013. |
|
|
1864
|
+
| `SubscriptionGGuide`, `SubscriptionChanToru` | So-net's "Gガイド.テレビ王国 Chan-Toru" ended on 31 July 2020 and the business was transferred; the RSS endpoint is gone. |
|
|
1865
|
+
| `FilterGoogleNews` | Unwrapped a Google News link by reading whatever followed `&url=`. Google News now emits opaque `/rss/articles/…` links whose destination is only obtainable from an undocumented internal endpoint. |
|
|
1866
|
+
|
|
1867
|
+
The reason for removing rather than marking them is in section 5: a plugin ships
|
|
1868
|
+
because it has a current use. Git history holds the implementations, and a
|
|
1869
|
+
Recipe naming one of these now fails at load with `Automatic::NoPluginError`
|
|
1870
|
+
before anything runs — which is a clearer answer than a plugin that runs and
|
|
1871
|
+
does nothing.
|
|
1872
|
+
|
|
1873
|
+
Their integration Recipes, specs and optional dependencies went with them. The
|
|
1874
|
+
`xml-simple` and `nkf` dependencies also went, from plugins that were kept: see
|
|
1875
|
+
sections 6.2 and 6.3.
|