automatic 14.12.2 → 26.09

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