automatic 14.12.2 → 26.08

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