automatic 14.12.2 → 26.09

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +669 -83
  3. data/VERSION +1 -1
  4. data/assets/siteinfo/items_all.json +60300 -52138
  5. data/automatic.gemspec +110 -248
  6. data/bin/automatic +20 -139
  7. data/config/feed2console.yml +10 -4
  8. data/config/feed2markdown.yml +41 -0
  9. data/doc/AI_TUTORIAL.md +542 -0
  10. data/doc/BASIC_DESIGN.md +547 -0
  11. data/doc/COPYING.LESSER +165 -0
  12. data/doc/DEPLOYMENT.md +830 -0
  13. data/doc/LICENSE.md +14 -0
  14. data/doc/PLUGINS.md +2015 -0
  15. data/doc/PLUGIN_DEVELOPMENT.md +86 -0
  16. data/doc/POLICY.md +962 -0
  17. data/doc/QUICKSTART.md +260 -0
  18. data/doc/RELEASING.md +394 -0
  19. data/doc/REQUIREMENTS.md +556 -0
  20. data/doc/VERSIONS +266 -0
  21. data/lib/automatic/cli.rb +270 -0
  22. data/lib/automatic/environment.rb +31 -5
  23. data/lib/automatic/feed_maker.rb +65 -25
  24. data/lib/automatic/feed_parser.rb +51 -35
  25. data/lib/automatic/http.rb +107 -0
  26. data/lib/automatic/log.rb +49 -18
  27. data/lib/automatic/opml.rb +3 -1
  28. data/lib/automatic/pipeline.rb +73 -32
  29. data/lib/automatic/recipe.rb +100 -17
  30. data/lib/automatic/version.rb +14 -1
  31. data/lib/automatic.rb +93 -20
  32. data/plugins/custom_feed/svn_log.rb +73 -32
  33. data/plugins/custom_feed/web.rb +348 -0
  34. data/plugins/filter/absolute_uri.rb +43 -27
  35. data/plugins/filter/accept.rb +38 -45
  36. data/plugins/filter/batch.rb +97 -0
  37. data/plugins/filter/claude.rb +217 -0
  38. data/plugins/filter/clear.rb +12 -8
  39. data/plugins/filter/description_link.rb +66 -51
  40. data/plugins/filter/full_feed.rb +170 -52
  41. data/plugins/filter/gemini.rb +216 -0
  42. data/plugins/filter/github_feed.rb +38 -26
  43. data/plugins/filter/ignore.rb +33 -44
  44. data/plugins/filter/image.rb +36 -25
  45. data/plugins/filter/image_source.rb +75 -52
  46. data/plugins/filter/join.rb +105 -0
  47. data/plugins/filter/kimi.rb +216 -0
  48. data/plugins/filter/limit.rb +57 -0
  49. data/plugins/filter/one.rb +19 -26
  50. data/plugins/filter/open_ai.rb +198 -0
  51. data/plugins/filter/present.rb +75 -0
  52. data/plugins/filter/rand.rb +16 -17
  53. data/plugins/filter/sakura_ai.rb +205 -0
  54. data/plugins/filter/sanitize.rb +29 -34
  55. data/plugins/filter/sort.rb +20 -27
  56. data/plugins/filter/tumblr_resize.rb +31 -23
  57. data/plugins/notify/ikachan.rb +86 -48
  58. data/plugins/provide/fluentd.rb +43 -24
  59. data/plugins/publish/amazon_s3.rb +79 -40
  60. data/plugins/publish/console.rb +19 -16
  61. data/plugins/publish/console_link.rb +20 -17
  62. data/plugins/publish/eject.rb +48 -26
  63. data/plugins/publish/fluentd.rb +50 -30
  64. data/plugins/publish/hatena_bookmark.rb +88 -71
  65. data/plugins/publish/instapaper.rb +69 -59
  66. data/plugins/publish/markdown.rb +278 -0
  67. data/plugins/publish/memcached.rb +35 -29
  68. data/plugins/store/database.rb +50 -48
  69. data/plugins/store/digest.rb +212 -0
  70. data/plugins/store/file.rb +107 -68
  71. data/plugins/store/full_text.rb +31 -28
  72. data/plugins/store/permalink.rb +18 -22
  73. data/plugins/subscription/feed.rb +34 -21
  74. data/plugins/subscription/link.rb +31 -32
  75. data/plugins/subscription/text.rb +40 -45
  76. data/plugins/subscription/tumblr.rb +55 -44
  77. data/plugins/subscription/xml.rb +40 -36
  78. metadata +112 -321
  79. data/Gemfile +0 -38
  80. data/Rakefile +0 -59
  81. data/doc/ChangeLog +0 -303
  82. data/doc/PLUGINS +0 -750
  83. data/doc/PLUGINS.ja +0 -753
  84. data/doc/README +0 -511
  85. data/doc/README.ja +0 -519
  86. data/plugins/filter/google_news.rb +0 -50
  87. data/plugins/publish/google_calendar.rb +0 -84
  88. data/plugins/publish/hipchat.rb +0 -46
  89. data/plugins/publish/pocket.rb +0 -45
  90. data/plugins/publish/twitter.rb +0 -58
  91. data/plugins/subscription/chan_toru.rb +0 -57
  92. data/plugins/subscription/g_guide.rb +0 -57
  93. data/plugins/subscription/pocket.rb +0 -51
  94. data/plugins/subscription/twitter.rb +0 -69
  95. data/plugins/subscription/twitter_search.rb +0 -50
  96. data/plugins/subscription/weather.rb +0 -33
  97. data/script/build +0 -84
  98. data/spec/fixtures/sampleFeeds.tsv +0 -1
  99. data/spec/fixtures/sampleFeeds2.tsv +0 -2
  100. data/spec/fixtures/sampleRecipe.yml +0 -24
  101. data/spec/lib/automatic/log_spec.rb +0 -32
  102. data/spec/lib/automatic/pipeline_spec.rb +0 -68
  103. data/spec/lib/automatic/recipe_spec.rb +0 -40
  104. data/spec/lib/automatic_spec.rb +0 -99
  105. data/spec/plugins/custom_feed/svn_log_spec.rb +0 -31
  106. data/spec/plugins/filter/absolute_uri_spec.rb +0 -61
  107. data/spec/plugins/filter/accept_spec.rb +0 -331
  108. data/spec/plugins/filter/clear_spec.rb +0 -49
  109. data/spec/plugins/filter/description_link_spec.rb +0 -138
  110. data/spec/plugins/filter/full_feed_spec.rb +0 -129
  111. data/spec/plugins/filter/github_feed_spec.rb +0 -55
  112. data/spec/plugins/filter/google_news_spec.rb +0 -69
  113. data/spec/plugins/filter/ignore_spec.rb +0 -328
  114. data/spec/plugins/filter/image_source_spec.rb +0 -89
  115. data/spec/plugins/filter/image_spec.rb +0 -65
  116. data/spec/plugins/filter/one_spec.rb +0 -71
  117. data/spec/plugins/filter/rand_spec.rb +0 -52
  118. data/spec/plugins/filter/sanitize_spec.rb +0 -153
  119. data/spec/plugins/filter/sort_spec.rb +0 -189
  120. data/spec/plugins/filter/tumblr_resize_spec.rb +0 -109
  121. data/spec/plugins/notify/ikachan_spec.rb +0 -58
  122. data/spec/plugins/provide/fluentd_spec.rb +0 -49
  123. data/spec/plugins/publish/amazon_s3_spec.rb +0 -40
  124. data/spec/plugins/publish/console_spec.rb +0 -30
  125. data/spec/plugins/publish/eject_spec.rb +0 -40
  126. data/spec/plugins/publish/fluentd_spec.rb +0 -40
  127. data/spec/plugins/publish/google_calendar_spec.rb +0 -83
  128. data/spec/plugins/publish/hatena_bookmark_spec.rb +0 -134
  129. data/spec/plugins/publish/hipchat_spec.rb +0 -69
  130. data/spec/plugins/publish/instapaper_spec.rb +0 -82
  131. data/spec/plugins/publish/memcached_spec.rb +0 -63
  132. data/spec/plugins/publish/pocket_spec.rb +0 -51
  133. data/spec/plugins/publish/twitter_spec.rb +0 -73
  134. data/spec/plugins/store/file_spec.rb +0 -58
  135. data/spec/plugins/store/full_text_spec.rb +0 -152
  136. data/spec/plugins/store/permalink_spec.rb +0 -206
  137. data/spec/plugins/subscription/chan_toru_spec.rb +0 -56
  138. data/spec/plugins/subscription/feed_spec.rb +0 -71
  139. data/spec/plugins/subscription/g_guide_spec.rb +0 -82
  140. data/spec/plugins/subscription/link_spec.rb +0 -72
  141. data/spec/plugins/subscription/pocket_spec.rb +0 -57
  142. data/spec/plugins/subscription/text_spec.rb +0 -84
  143. data/spec/plugins/subscription/tumblr_spec.rb +0 -74
  144. data/spec/plugins/subscription/twitter_search_spec.rb +0 -57
  145. data/spec/plugins/subscription/twitter_spec.rb +0 -73
  146. data/spec/plugins/subscription/weather_spec.rb +0 -44
  147. data/spec/plugins/subscription/xml_spec.rb +0 -84
  148. data/spec/spec_helper.rb +0 -106
  149. data/spec/user_dir/plugins/store/mock.rb +0 -16
  150. data/test/fixtures/sampleOPML.xml +0 -11
  151. data/test/integration/test_absoluteurl.yml +0 -25
  152. data/test/integration/test_activerecord.yml +0 -24
  153. data/test/integration/test_add_pocket.yml +0 -26
  154. data/test/integration/test_chan_toru.yml +0 -21
  155. data/test/integration/test_descriptionlink.yml +0 -21
  156. data/test/integration/test_fluentd.yml +0 -22
  157. data/test/integration/test_fulltext.yml +0 -30
  158. data/test/integration/test_google_news.yml +0 -21
  159. data/test/integration/test_googlealert.yml +0 -21
  160. data/test/integration/test_hatenabookmark.yml +0 -30
  161. data/test/integration/test_ignore.yml +0 -25
  162. data/test/integration/test_ignore2.yml +0 -22
  163. data/test/integration/test_image2local.yml +0 -33
  164. data/test/integration/test_instapaper.yml +0 -26
  165. data/test/integration/test_link2local.yml +0 -34
  166. data/test/integration/test_one.yml +0 -23
  167. data/test/integration/test_pocket.yml +0 -22
  168. data/test/integration/test_rand.yml +0 -21
  169. data/test/integration/test_sanitize.yml +0 -23
  170. data/test/integration/test_sort.yml +0 -36
  171. data/test/integration/test_svnlog.yml +0 -15
  172. data/test/integration/test_text2feed.yml +0 -36
  173. data/test/integration/test_tumblr2local.yml +0 -43
  174. data/test/integration/test_twitter_search.yml +0 -22
  175. data/test/integration/test_weather.yml +0 -19
  176. data/test/integration/test_xml2fluentd.yml +0 -21
  177. data/vendor/.gitkeep +0 -0
@@ -0,0 +1,105 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Plugin::Filter::Join
3
+ # Description:: Join every item in the pipeline into one item.
4
+ # Author: id774 (More info: https://id774.net)
5
+ # Source Code:: https://github.com/id774/automaticruby
6
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
7
+ # Contact:: idnanashi@gmail.com
8
+ # Created:: Aug 17, 2026
9
+ # Updated:: Aug 17, 2026
10
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
11
+ #
12
+ # Many items in, one item out. That is the whole of it: no summarizing, no
13
+ # fetching, no knowledge of what reads the result. What the joined description
14
+ # is for is decided by whatever the Recipe puts next -- an AI filter, a publish
15
+ # plugin, or nothing at all.
16
+ #
17
+ # The whole pipeline becomes one item rather than one item per feed, because
18
+ # the point of joining is to have a single text; a Recipe that wants one item
19
+ # per feed still has the feeds separate before this plugin runs.
20
+
21
+ module Automatic::Plugin
22
+ class FilterJoin
23
+ require 'rss'
24
+
25
+ # A title an operator has not set. Short and predictable, in the manner of
26
+ # PublishMarkdown's `(untitled)`; a Recipe that publishes this item names
27
+ # it in `title`.
28
+ DEFAULT_TITLE = 'Joined items'.freeze
29
+
30
+ # Where one item ends and the next begins, for whatever reads the joined
31
+ # text. Plain lines rather than markup: the descriptions being joined may
32
+ # be HTML or text, and a delimiter that survives both is one that neither
33
+ # can be mistaken for.
34
+ HEADING = 'ARTICLE'.freeze
35
+
36
+ def initialize(config, pipeline = [])
37
+ @config = config || {}
38
+ @pipeline = pipeline
39
+ end
40
+
41
+ # Returns one feed holding one item, or an empty pipeline when there was
42
+ # nothing to join. An item saying nothing is worse than no item: the
43
+ # plugins after this one would publish it.
44
+ def run
45
+ items = collect
46
+ if items.empty?
47
+ Automatic::Log.puts('warn', 'FilterJoin: no items to join')
48
+ return []
49
+ end
50
+
51
+ Automatic::Log.puts('info', "FilterJoin: joining #{items.size} items into one")
52
+ [feed(title, description(items))]
53
+ end
54
+
55
+ private
56
+
57
+ def collect
58
+ @pipeline.each_with_object([]) { |feeds, items|
59
+ next if feeds.nil?
60
+
61
+ items.concat(feeds.items)
62
+ }
63
+ end
64
+
65
+ def title
66
+ given = @config['title'].to_s
67
+ given.empty? ? DEFAULT_TITLE : given
68
+ end
69
+
70
+ def description(items)
71
+ items.each_with_index.map { |item, index| section(index + 1, item) }.join("\n\n")
72
+ end
73
+
74
+ def section(number, item)
75
+ ["#{HEADING} #{number}",
76
+ "Title: #{value(item, :title)}",
77
+ "URL: #{value(item, :link)}",
78
+ '',
79
+ value(item, :description)].join("\n")
80
+ end
81
+
82
+ # A field an item does not carry is empty rather than absent, so that every
83
+ # section has the same shape whatever the feed it came from left out.
84
+ def value(item, name)
85
+ return '' unless item.respond_to?(name)
86
+
87
+ item.public_send(name).to_s.strip
88
+ end
89
+
90
+ # This joined item deliberately has no link. It is several articles at once,
91
+ # so there is no single page it points at; using the first article's URL would
92
+ # name a source for text that is not only from it.
93
+ def feed(item_title, item_description)
94
+ RSS::Maker.make('2.0') { |maker|
95
+ maker.channel.title = 'Automatic Ruby'
96
+ maker.channel.description = 'Automatic::Plugin::FilterJoin'
97
+ maker.channel.link = 'https://github.com/id774/automaticruby'
98
+ item = maker.items.new_item
99
+ item.title = item_title
100
+ item.description = item_description
101
+ item.date = Time.now
102
+ }
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,216 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Plugin::Filter::Kimi
3
+ # Description:: Replace each item's description with what the Kimi API answers.
4
+ # Author: id774 (More info: https://id774.net)
5
+ # Source Code:: https://github.com/id774/automaticruby
6
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
7
+ # Contact:: idnanashi@gmail.com
8
+ # Created:: Sep 11, 2026
9
+ # Updated:: Sep 11, 2026
10
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
11
+ #
12
+ # One transformation: the item's description goes to the Kimi API under the
13
+ # Recipe's prompt, and the answer becomes the item's description. What that
14
+ # transformation is -- a summary, a translation, an extraction, a
15
+ # classification -- is the prompt's business, not this plugin's.
16
+ #
17
+ # Moonshot AI's Kimi offers an OpenAI-compatible chat completions interface,
18
+ # and this plugin is still its own rather than a mode of FilterOpenAI. It is a
19
+ # different service: a different endpoint, a different account, a different
20
+ # set of models, its own limits and its own errors, and any of those may move
21
+ # without OpenAI moving. A Recipe naming FilterKimi says which service the
22
+ # text is sent to, which a `provider:` setting would not.
23
+ #
24
+ # Kimi may return a `reasoning_content` alongside the answer's own `content`.
25
+ # That reasoning is never read, logged or written anywhere by this plugin --
26
+ # only `content` is the answer, and only once `finish_reason` says the model
27
+ # is done.
28
+ #
29
+ # @see https://platform.moonshot.ai/docs/api/chat
30
+
31
+ module Automatic::Plugin
32
+ class FilterKimi
33
+ require 'json'
34
+ require 'net/http'
35
+ require 'openssl'
36
+ require 'uri'
37
+
38
+ # The one endpoint the service publishes for this. It is not a setting: an
39
+ # operator has no version of this plugin that talks to a different host,
40
+ # and a setting for it would be a way to send the token somewhere else.
41
+ ENDPOINT = URI('https://api.moonshot.ai/v1/chat/completions')
42
+
43
+ OPEN_TIMEOUT = 10
44
+
45
+ # Generous, and bounded. A model given several articles thinks for a while;
46
+ # an unattended run that waits forever is the failure this exists against.
47
+ READ_TIMEOUT = 300
48
+
49
+ # A failure that another attempt will not get past: a setting that is
50
+ # wrong, a request the service refuses, an answer this plugin cannot read.
51
+ class Error < StandardError; end
52
+
53
+ # A failure that another attempt may get past: the network, a rate limit, a
54
+ # server error.
55
+ class TemporaryError < StandardError; end
56
+
57
+ def initialize(config, pipeline = [])
58
+ @config = config || {}
59
+ @pipeline = pipeline
60
+ end
61
+
62
+ # Replaces each item's description with the answer. Nothing else about an
63
+ # item is touched, and the feeds and their items arrive and leave in the
64
+ # same number and the same order.
65
+ def run
66
+ validate_settings
67
+
68
+ @pipeline.each { |feeds|
69
+ next if feeds.nil?
70
+
71
+ feeds.items.each { |item| transform(item) }
72
+ }
73
+ @pipeline
74
+ end
75
+
76
+ private
77
+
78
+ # Checked before the first request, because a Recipe this plugin cannot
79
+ # carry out is the operator's mistake and will be the same mistake on every
80
+ # item. The token is never named in a message.
81
+ def validate_settings
82
+ raise ArgumentError, 'FilterKimi needs a token' if token.empty?
83
+ raise ArgumentError, 'FilterKimi needs a model' if model.empty?
84
+ raise ArgumentError, 'FilterKimi needs a prompt' if prompt.empty?
85
+ end
86
+
87
+ def token
88
+ @config['token'].to_s
89
+ end
90
+
91
+ def model
92
+ @config['model'].to_s.strip
93
+ end
94
+
95
+ def prompt
96
+ @config['prompt'].to_s.strip
97
+ end
98
+
99
+ def transform(item)
100
+ text = item.description.to_s
101
+ if text.strip.empty?
102
+ Automatic::Log.puts('warn', "FilterKimi: nothing to send for #{item.link}")
103
+ return
104
+ end
105
+
106
+ Automatic::Log.puts('info', "FilterKimi: asking #{model} about #{item.link}")
107
+ item.description = answer(text)
108
+ end
109
+
110
+ # The retry shape of doc/PLUGINS.md section 3.6, applied only to what
111
+ # retrying can help. A missing setting, a refused request or an answer in a
112
+ # shape this plugin cannot read is raised at once: trying again would fail
113
+ # the same way, more slowly.
114
+ def answer(text)
115
+ retries = 0
116
+ retry_max = @config['retry'].to_i
117
+ begin
118
+ completion(text)
119
+ rescue TemporaryError => e
120
+ retries += 1
121
+ Automatic::Log.puts('error', "ErrorCount: #{retries}, FilterKimi: #{e.message}")
122
+ if retries <= retry_max
123
+ sleep(@config['interval'].to_i)
124
+ retry
125
+ end
126
+ raise Error, "FilterKimi gave up after #{retries} attempts: #{e.message}"
127
+ end
128
+ end
129
+
130
+ def completion(text)
131
+ # The prompt is the system turn and the description is the user turn it
132
+ # is applied to. They are separate messages, so that what an article says
133
+ # is never read as an instruction to this plugin or to the model. Nothing
134
+ # else -- no sampling or tool-use parameter -- is sent.
135
+ body = {
136
+ 'model' => model,
137
+ 'messages' => [
138
+ { 'role' => 'system', 'content' => prompt },
139
+ { 'role' => 'user', 'content' => text }
140
+ ]
141
+ }
142
+ content(post(JSON.generate(body)))
143
+ end
144
+
145
+ def post(body)
146
+ request = Net::HTTP::Post.new(ENDPOINT)
147
+ request['Authorization'] = "Bearer #{token}"
148
+ request['Content-Type'] = 'application/json'
149
+ request.body = body
150
+
151
+ # TLS with the certificate verified, which is Net::HTTP's own default and
152
+ # is named here because it is not a thing to be turned off.
153
+ Net::HTTP.start(ENDPOINT.host, ENDPOINT.port,
154
+ use_ssl: true,
155
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
156
+ open_timeout: OPEN_TIMEOUT,
157
+ read_timeout: READ_TIMEOUT) { |http| http.request(request) }
158
+ rescue Timeout::Error, SystemCallError, SocketError, IOError,
159
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
160
+ raise TemporaryError, "the request to Kimi failed: #{e.message}"
161
+ end
162
+
163
+ def content(response)
164
+ case response
165
+ when Net::HTTPSuccess
166
+ answer_text(parse(response.body))
167
+ when Net::HTTPTooManyRequests, Net::HTTPServerError
168
+ raise TemporaryError, "Kimi answered #{response.code}: #{reason(response)}"
169
+ else
170
+ raise Error, "Kimi answered #{response.code}: #{reason(response)}"
171
+ end
172
+ end
173
+
174
+ def parse(body)
175
+ JSON.parse(body.to_s)
176
+ rescue JSON::ParserError => e
177
+ raise Error, "Kimi answered with something that is not JSON: #{e.message}"
178
+ end
179
+
180
+ # The first choice's message content, once the model says it is actually
181
+ # done. `reasoning_content`, when Kimi sends one alongside `content`, is
182
+ # never read here: it is not the answer, and it never becomes one. An
183
+ # answer this plugin cannot find is an error and not an empty description:
184
+ # a Recipe that published the empty string here would have thrown the
185
+ # article away and reported success.
186
+ def answer_text(body)
187
+ choices = body['choices']
188
+ unless choices.is_a?(Array) && choices.first.is_a?(Hash)
189
+ raise Error, 'Kimi answered without a choice'
190
+ end
191
+
192
+ choice = choices.first
193
+ message = choice['message']
194
+ raise Error, 'Kimi answered without a message' unless message.is_a?(Hash)
195
+ raise Error, "Kimi did not finish: #{choice['finish_reason']}" unless choice['finish_reason'] == 'stop'
196
+
197
+ text = message['content'].to_s.strip
198
+ raise Error, 'Kimi answered with no content' if text.empty?
199
+
200
+ text
201
+ end
202
+
203
+ # The service's own explanation where it gave one, the status line
204
+ # otherwise. Neither carries the token, and the settings are never logged
205
+ # or raised wholesale.
206
+ def reason(response)
207
+ body = JSON.parse(response.body.to_s)
208
+ error = body['error']
209
+ return error['message'].to_s if error.is_a?(Hash) && !error['message'].to_s.empty?
210
+
211
+ response.message.to_s
212
+ rescue JSON::ParserError
213
+ response.message.to_s
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,57 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Plugin::Filter::Limit
3
+ # Description:: Limit the number of items passed by the whole pipeline.
4
+ # Author: id774 (More info: https://id774.net)
5
+ # Source Code:: https://github.com/id774/automaticruby
6
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
7
+ # Contact:: idnanashi@gmail.com
8
+ # Created:: Aug 24, 2026
9
+ # Updated:: Aug 24, 2026
10
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
11
+
12
+ module Automatic::Plugin
13
+ class FilterLimit
14
+ def initialize(config, pipeline = [])
15
+ @config = config || {}
16
+ @pipeline = pipeline
17
+ @max_items = validated_max_items
18
+ end
19
+
20
+ # Selects items in feed order, item order, up to max_items shared across the
21
+ # whole pipeline rather than reset per feed. A feed that contributed no item
22
+ # is dropped from the output; the walk stops as soon as the limit is met.
23
+ def run
24
+ remaining = @max_items
25
+ returned = []
26
+
27
+ @pipeline.each do |feeds|
28
+ next if feeds.nil?
29
+ break if remaining <= 0
30
+
31
+ selected = feeds.items.first(remaining)
32
+ next if selected.empty?
33
+
34
+ returned << Automatic::FeedMaker.create_pipeline(selected)
35
+ remaining -= selected.size
36
+ end
37
+
38
+ returned
39
+ end
40
+
41
+ private
42
+
43
+ def validated_max_items
44
+ value = begin
45
+ Integer(@config['max_items'].to_s, 10)
46
+ rescue ArgumentError
47
+ nil
48
+ end
49
+
50
+ if value.nil? || value < 1
51
+ raise ArgumentError, 'FilterLimit needs max_items to be a positive integer'
52
+ end
53
+
54
+ value
55
+ end
56
+ end
57
+ end
@@ -1,37 +1,30 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::One
3
- # Author:: soramugi <http://soramugi.net>
4
- # 774 <http://id774.net>
5
- # Created:: May 8, 2013
6
- # Updated:: Feb 21, 2014
7
- # Copyright:: Copyright (c) 2012-2014 Automatic Ruby Developers.
8
- # License:: Licensed under the GNU GENERAL PUBLIC LICENSE, Version 3.0.
2
+ # Name:: Automatic::Plugin::Filter::One
3
+ # Author: soramugi (More info: http://soramugi.net)
4
+ # Source Code:: https://github.com/id774/automaticruby
5
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
6
+ # Contact:: idnanashi@gmail.com
7
+ # Created:: May 8, 2013
8
+ # Updated:: Aug 15, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
9
10
 
10
11
  module Automatic::Plugin
11
12
  class FilterOne
12
-
13
- def initialize(config, pipeline=[])
14
- @config = config
13
+ def initialize(config, pipeline = [])
14
+ @config = config || {}
15
15
  @pipeline = pipeline
16
+ @last = @config['pick'].to_s == 'last'
16
17
  end
17
18
 
19
+ # Reduces each feed to a single item. A feed that has none is dropped
20
+ # rather than reduced to one nil.
18
21
  def run
19
- @return_feeds = []
20
- @pipeline.each {|feed|
21
- unless feed.nil?
22
- item = []
23
- unless @config.nil? or @config['pick'].nil?
24
- if @config['pick'] == 'last'
25
- item << feed.items.pop
26
- end
27
- end
28
- if item.count == 0
29
- item << feed.items.shift
30
- end
31
- @return_feeds << Automatic::FeedMaker.create_pipeline(item)
32
- end
33
- }
34
- @return_feeds
22
+ @pipeline.each_with_object([]) do |feeds, returned|
23
+ next if feeds.nil? || feeds.items.empty?
24
+
25
+ item = @last ? feeds.items.last : feeds.items.first
26
+ returned << Automatic::FeedMaker.create_pipeline([item])
27
+ end
35
28
  end
36
29
  end
37
30
  end
@@ -0,0 +1,198 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Plugin::Filter::OpenAI
3
+ # Description:: Replace each item's description with what the OpenAI API answers.
4
+ # Author: id774 (More info: https://id774.net)
5
+ # Source Code:: https://github.com/id774/automaticruby
6
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
7
+ # Contact:: idnanashi@gmail.com
8
+ # Created:: Aug 17, 2026
9
+ # Updated:: Aug 17, 2026
10
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
11
+ #
12
+ # One transformation: the item's description goes to the OpenAI API under the
13
+ # Recipe's prompt, and the answer becomes the item's description. What that
14
+ # transformation is -- a summary, a translation, an extraction, a
15
+ # classification -- is the prompt's business, not this plugin's.
16
+ #
17
+ # This plugin knows OpenAI and nothing else. Anthropic, Google and Sakura have
18
+ # their own plugins, because their endpoints, authentication, request bodies,
19
+ # responses and errors are their own and will keep diverging. A Recipe changes
20
+ # service by naming a different plugin.
21
+ #
22
+ # It speaks the Responses API, which is the interface OpenAI recommends for new
23
+ # integrations. @see https://platform.openai.com/docs/api-reference/responses
24
+
25
+ module Automatic::Plugin
26
+ class FilterOpenAI
27
+ require 'json'
28
+ require 'net/http'
29
+ require 'openssl'
30
+ require 'uri'
31
+
32
+ ENDPOINT = URI('https://api.openai.com/v1/responses')
33
+
34
+ OPEN_TIMEOUT = 10
35
+
36
+ # Generous, and bounded. A model given several articles thinks for a while;
37
+ # an unattended run that waits forever is the failure this exists against.
38
+ READ_TIMEOUT = 300
39
+
40
+ # A failure that another attempt will not get past: a setting that is
41
+ # wrong, a request the service refuses, an answer this plugin cannot read.
42
+ class Error < StandardError; end
43
+
44
+ # A failure that another attempt may get past: the network, a rate limit, a
45
+ # server error.
46
+ class TemporaryError < StandardError; end
47
+
48
+ def initialize(config, pipeline = [])
49
+ @config = config || {}
50
+ @pipeline = pipeline
51
+ end
52
+
53
+ # Replaces each item's description with the answer. Nothing else about an
54
+ # item is touched, and the feeds and their items arrive and leave in the
55
+ # same order and number.
56
+ def run
57
+ validate_settings
58
+
59
+ @pipeline.each { |feeds|
60
+ next if feeds.nil?
61
+
62
+ feeds.items.each { |item| transform(item) }
63
+ }
64
+ @pipeline
65
+ end
66
+
67
+ private
68
+
69
+ # Checked before the first request, because a Recipe this plugin cannot
70
+ # carry out is the operator's mistake and will be the same mistake on every
71
+ # item. The token is never named in a message.
72
+ def validate_settings
73
+ raise ArgumentError, 'FilterOpenAI needs a token' if token.empty?
74
+ raise ArgumentError, 'FilterOpenAI needs a model' if model.empty?
75
+ raise ArgumentError, 'FilterOpenAI needs a prompt' if prompt.empty?
76
+ end
77
+
78
+ def token
79
+ @config['token'].to_s
80
+ end
81
+
82
+ def model
83
+ @config['model'].to_s.strip
84
+ end
85
+
86
+ def prompt
87
+ @config['prompt'].to_s.strip
88
+ end
89
+
90
+ def transform(item)
91
+ text = item.description.to_s
92
+ if text.strip.empty?
93
+ Automatic::Log.puts('warn', "FilterOpenAI: nothing to send for #{item.link}")
94
+ return
95
+ end
96
+
97
+ Automatic::Log.puts('info', "FilterOpenAI: asking #{model} about #{item.link}")
98
+ item.description = answer(text)
99
+ end
100
+
101
+ # The retry shape of doc/PLUGINS.md section 3.6, applied only to what
102
+ # retrying can help. A missing setting, a refused request or an answer in a
103
+ # shape this plugin cannot read is raised at once: trying again would fail
104
+ # the same way, more slowly.
105
+ def answer(text)
106
+ retries = 0
107
+ retry_max = @config['retry'].to_i
108
+ begin
109
+ completion(text)
110
+ rescue TemporaryError => e
111
+ retries += 1
112
+ Automatic::Log.puts('error', "ErrorCount: #{retries}, FilterOpenAI: #{e.message}")
113
+ if retries <= retry_max
114
+ sleep(@config['interval'].to_i)
115
+ retry
116
+ end
117
+ raise Error, "FilterOpenAI gave up after #{retries} attempts: #{e.message}"
118
+ end
119
+ end
120
+
121
+ def completion(text)
122
+ # The prompt is the instruction and the description is the text it is
123
+ # applied to. They are separate fields, so that what an article says is
124
+ # never read as an instruction to this plugin or to the model.
125
+ body = {
126
+ 'model' => model,
127
+ 'instructions' => prompt,
128
+ 'input' => text
129
+ }
130
+ content(post(JSON.generate(body)))
131
+ end
132
+
133
+ def post(body)
134
+ request = Net::HTTP::Post.new(ENDPOINT)
135
+ request['Authorization'] = "Bearer #{token}"
136
+ request['Content-Type'] = 'application/json'
137
+ request.body = body
138
+
139
+ # TLS with the certificate verified, which is Net::HTTP's own default and
140
+ # is named here because it is not a thing to be turned off.
141
+ Net::HTTP.start(ENDPOINT.host, ENDPOINT.port,
142
+ use_ssl: true,
143
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
144
+ open_timeout: OPEN_TIMEOUT,
145
+ read_timeout: READ_TIMEOUT) { |http| http.request(request) }
146
+ rescue Timeout::Error, SystemCallError, SocketError, IOError,
147
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
148
+ raise TemporaryError, "the request to OpenAI failed: #{e.message}"
149
+ end
150
+
151
+ def content(response)
152
+ case response
153
+ when Net::HTTPSuccess
154
+ answer_text(parse(response.body))
155
+ when Net::HTTPTooManyRequests, Net::HTTPServerError
156
+ raise TemporaryError, "OpenAI answered #{response.code}: #{reason(response)}"
157
+ else
158
+ raise Error, "OpenAI answered #{response.code}: #{reason(response)}"
159
+ end
160
+ end
161
+
162
+ def parse(body)
163
+ JSON.parse(body.to_s)
164
+ rescue JSON::ParserError => e
165
+ raise Error, "OpenAI answered with something that is not JSON: #{e.message}"
166
+ end
167
+
168
+ # The text of the assistant's message, out of the typed output array the
169
+ # Responses API returns. An answer this plugin cannot find is an error and
170
+ # not an empty description: a Recipe that published the empty string here
171
+ # would have thrown the article away and reported success.
172
+ def answer_text(body)
173
+ output = body['output']
174
+ raise Error, 'OpenAI answered without an output array' unless output.is_a?(Array)
175
+
176
+ text = output.select { |item| item.is_a?(Hash) && item['type'] == 'message' }.
177
+ flat_map { |item| Array(item['content']) }.
178
+ select { |part| part.is_a?(Hash) && part['type'] == 'output_text' }.
179
+ map { |part| part['text'].to_s }.join.strip
180
+ raise Error, 'OpenAI answered with no output text' if text.empty?
181
+
182
+ text
183
+ end
184
+
185
+ # The service's own explanation where it gave one, the status line
186
+ # otherwise. Neither carries the token, and the settings are never logged
187
+ # or raised wholesale.
188
+ def reason(response)
189
+ body = JSON.parse(response.body.to_s)
190
+ error = body['error']
191
+ return error['message'].to_s if error.is_a?(Hash) && !error['message'].to_s.empty?
192
+
193
+ response.message.to_s
194
+ rescue JSON::ParserError
195
+ response.message.to_s
196
+ end
197
+ end
198
+ end