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
@@ -0,0 +1,216 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Plugin::Filter::Gemini
3
+ # Description:: Replace each item's description with what the Google Gemini API answers.
4
+ # Author: id774 (More info: http://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 Gemini 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 Gemini and nothing else. Its model is named in the URL
18
+ # rather than in the body, its API key is a header of its own, and its request
19
+ # and answer are built of `contents` and `parts`. None of that is bent into
20
+ # another service's shape, and no other service's request is built here.
21
+ #
22
+ # It speaks `generateContent`, which Google states remains fully supported and
23
+ # is the single-turn text interface: a request that carries an instruction and
24
+ # a text and answers with a text, which is exactly what this plugin does.
25
+ # @see https://ai.google.dev/api/generate-content
26
+
27
+ module Automatic::Plugin
28
+ class FilterGemini
29
+ require 'json'
30
+ require 'net/http'
31
+ require 'openssl'
32
+ require 'uri'
33
+
34
+ # The model is part of the path here, unlike every other service in this
35
+ # directory, so the endpoint is built per Recipe rather than being one
36
+ # constant.
37
+ ENDPOINT_FORMAT = 'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent'.freeze
38
+
39
+ OPEN_TIMEOUT = 10
40
+
41
+ # Generous, and bounded. A model given several articles thinks for a while;
42
+ # an unattended run that waits forever is the failure this exists against.
43
+ READ_TIMEOUT = 300
44
+
45
+ # A failure that another attempt will not get past: a setting that is
46
+ # wrong, a request the service refuses, an answer this plugin cannot read.
47
+ class Error < StandardError; end
48
+
49
+ # A failure that another attempt may get past: the network, a rate limit, a
50
+ # server error.
51
+ class TemporaryError < StandardError; end
52
+
53
+ def initialize(config, pipeline = [])
54
+ @config = config || {}
55
+ @pipeline = pipeline
56
+ end
57
+
58
+ # Replaces each item's description with the answer. Nothing else about an
59
+ # item is touched, and the feeds and their items arrive and leave in the
60
+ # same order and number.
61
+ def run
62
+ validate_settings
63
+
64
+ @pipeline.each { |feeds|
65
+ next if feeds.nil?
66
+
67
+ feeds.items.each { |item| transform(item) }
68
+ }
69
+ @pipeline
70
+ end
71
+
72
+ private
73
+
74
+ # Checked before the first request, because a Recipe this plugin cannot
75
+ # carry out is the operator's mistake and will be the same mistake on every
76
+ # item. The token is never named in a message.
77
+ def validate_settings
78
+ raise ArgumentError, 'FilterGemini needs a token' if token.empty?
79
+ raise ArgumentError, 'FilterGemini needs a model' if model.empty?
80
+ raise ArgumentError, 'FilterGemini needs a prompt' if prompt.empty?
81
+
82
+ endpoint
83
+ end
84
+
85
+ def token
86
+ @config['token'].to_s
87
+ end
88
+
89
+ def model
90
+ @config['model'].to_s.strip
91
+ end
92
+
93
+ def prompt
94
+ @config['prompt'].to_s.strip
95
+ end
96
+
97
+ # A model name that cannot go in a URL is the Recipe's mistake, and is
98
+ # reported as one rather than as a failed request.
99
+ def endpoint
100
+ @endpoint ||= URI(format(ENDPOINT_FORMAT, model))
101
+ rescue URI::InvalidURIError
102
+ raise ArgumentError, "FilterGemini cannot build a request for the model #{model}"
103
+ end
104
+
105
+ def transform(item)
106
+ text = item.description.to_s
107
+ if text.strip.empty?
108
+ Automatic::Log.puts('warn', "FilterGemini: nothing to send for #{item.link}")
109
+ return
110
+ end
111
+
112
+ Automatic::Log.puts('info', "FilterGemini: asking #{model} about #{item.link}")
113
+ item.description = answer(text)
114
+ end
115
+
116
+ # The retry shape of doc/PLUGINS.md section 3.6, applied only to what
117
+ # retrying can help. A missing setting, a refused request or an answer in a
118
+ # shape this plugin cannot read is raised at once: trying again would fail
119
+ # the same way, more slowly.
120
+ def answer(text)
121
+ retries = 0
122
+ retry_max = @config['retry'].to_i
123
+ begin
124
+ generated(text)
125
+ rescue TemporaryError => e
126
+ retries += 1
127
+ Automatic::Log.puts('error', "ErrorCount: #{retries}, FilterGemini: #{e.message}")
128
+ if retries <= retry_max
129
+ sleep(@config['interval'].to_i)
130
+ retry
131
+ end
132
+ raise Error, "FilterGemini gave up after #{retries} attempts: #{e.message}"
133
+ end
134
+ end
135
+
136
+ def generated(text)
137
+ # The prompt is the system instruction and the description is the content
138
+ # it is applied to. They are separate fields, so that what an article
139
+ # says is never read as an instruction to this plugin or to the model.
140
+ body = {
141
+ 'system_instruction' => { 'parts' => [{ 'text' => prompt }] },
142
+ 'contents' => [{ 'role' => 'user', 'parts' => [{ 'text' => text }] }]
143
+ }
144
+ content(post(JSON.generate(body)))
145
+ end
146
+
147
+ def post(body)
148
+ request = Net::HTTP::Post.new(endpoint)
149
+ # The key goes in a header rather than in the query string, which is the
150
+ # way Google documents and the way that keeps a credential out of a URL.
151
+ request['x-goog-api-key'] = token
152
+ request['Content-Type'] = 'application/json'
153
+ request.body = body
154
+
155
+ # TLS with the certificate verified, which is Net::HTTP's own default and
156
+ # is named here because it is not a thing to be turned off.
157
+ Net::HTTP.start(endpoint.host, endpoint.port,
158
+ use_ssl: true,
159
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
160
+ open_timeout: OPEN_TIMEOUT,
161
+ read_timeout: READ_TIMEOUT) { |http| http.request(request) }
162
+ rescue Timeout::Error, SystemCallError, SocketError, IOError,
163
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
164
+ raise TemporaryError, "the request to Gemini failed: #{e.message}"
165
+ end
166
+
167
+ def content(response)
168
+ case response
169
+ when Net::HTTPSuccess
170
+ answer_text(parse(response.body))
171
+ when Net::HTTPTooManyRequests, Net::HTTPServerError
172
+ raise TemporaryError, "Gemini answered #{response.code}: #{reason(response)}"
173
+ else
174
+ raise Error, "Gemini answered #{response.code}: #{reason(response)}"
175
+ end
176
+ end
177
+
178
+ def parse(body)
179
+ JSON.parse(body.to_s)
180
+ rescue JSON::ParserError => e
181
+ raise Error, "Gemini answered with something that is not JSON: #{e.message}"
182
+ end
183
+
184
+ # The text of the first candidate. A response with no candidate is what a
185
+ # request stopped by a safety filter looks like, and it is an error rather
186
+ # than an empty description: a Recipe that published the empty string here
187
+ # would have thrown the article away and reported success.
188
+ def answer_text(body)
189
+ candidates = body['candidates']
190
+ unless candidates.is_a?(Array) && candidates.first.is_a?(Hash)
191
+ raise Error, 'Gemini answered without a candidate'
192
+ end
193
+
194
+ parts = candidates.first.dig('content', 'parts')
195
+ raise Error, 'Gemini answered without content parts' unless parts.is_a?(Array)
196
+
197
+ text = parts.select { |part| part.is_a?(Hash) }.map { |part| part['text'].to_s }.join.strip
198
+ raise Error, 'Gemini answered with no text' 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
@@ -1,37 +1,49 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::GithubFeed
3
- # Author:: Kohei Hasegawa <http://github.com/banyan>
4
- # 774 <http://id774.net>
5
- # Created:: Jun 6, 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::GithubFeed
3
+ # Author: Kohei Hasegawa (More info: http://github.com/banyan)
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:: Jun 6, 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 FilterGithubFeed
12
-
13
- def initialize(config, pipeline=[])
14
- @config = config
13
+ def initialize(config, pipeline = [])
14
+ @config = config || {}
15
15
  @pipeline = pipeline
16
16
  end
17
17
 
18
+ # Converts Atom entries -- where title, id and content are elements with a
19
+ # #content -- into the flat items the rest of the pipeline expects. Needed
20
+ # because GitHub publishes Atom, not RSS.
18
21
  def run
19
- @return_feeds = []
20
- @pipeline.each {|feeds|
21
- new_feeds = []
22
- unless feeds.nil?
23
- feeds.items.each {|feed|
24
- Automatic::Log.puts("info", "Invoked: FilterGithubFeed")
25
- hashie = Hashie::Mash.new
26
- hashie.title = feed.title.content
27
- hashie.link = feed.id.content
28
- hashie.description = feed.content.content
29
- new_feeds << hashie
30
- }
31
- end
32
- @return_feeds << Automatic::FeedMaker.create_pipeline(new_feeds)
33
- }
34
- @return_feeds
22
+ @pipeline.each_with_object([]) do |feeds, returned|
23
+ items = feeds.nil? ? [] : feeds.items.map { |item| flatten(item) }
24
+ returned << Automatic::FeedMaker.create_pipeline(items)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def flatten(item)
31
+ Automatic::Log.puts('info', 'Invoked: FilterGithubFeed')
32
+ entry = Hashie::Mash.new
33
+ entry.title = value(item, :title)
34
+ entry.link = value(item, :id)
35
+ entry.description = value(item, :content)
36
+ entry
37
+ end
38
+
39
+ # An Atom element carries its text in #content; a field that is already a
40
+ # string is used as it stands, so that a pipeline which has been through
41
+ # another filter first is not a NoMethodError.
42
+ def value(item, name)
43
+ return nil unless item.respond_to?(name)
44
+
45
+ field = item.send(name)
46
+ field.respond_to?(:content) ? field.content : field
35
47
  end
36
48
  end
37
49
  end
@@ -1,63 +1,52 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::Ignore
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Feb 22, 2012
5
- # Updated:: Feb 21, 2014
6
- # Copyright:: Copyright (c) 2012-2014 Automatic Ruby Developers.
7
- # License:: Licensed under the GNU GENERAL PUBLIC LICENSE, Version 3.0.
2
+ # Name:: Automatic::Plugin::Filter::Ignore
3
+ # Author: id774 (More info: http://id774.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:: Feb 22, 2012
8
+ # Updated:: Aug 15, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
8
10
 
9
11
  module Automatic::Plugin
10
12
  class FilterIgnore
11
- def initialize(config, pipeline=[])
12
- @config = config
13
+ FIELDS = %i[title link description].freeze
14
+
15
+ def initialize(config, pipeline = [])
16
+ @config = config || {}
13
17
  @pipeline = pipeline
14
18
  end
15
19
 
20
+ # Drops items containing any listed keyword. Matching is a substring test,
21
+ # so an empty keyword drops everything.
16
22
  def run
17
- @return_feeds = []
18
- @pipeline.each {|feeds|
19
- new_feeds = []
20
- unless feeds.nil?
21
- feeds.items.each {|items|
22
- new_feeds << items if exclude(items) == false
23
- }
24
- end
25
- @return_feeds << Automatic::FeedMaker.create_pipeline(new_feeds) if new_feeds.length > 0
26
- }
27
- @return_feeds
23
+ @pipeline.each_with_object([]) do |feeds, returned|
24
+ kept = feeds.nil? ? [] : feeds.items.reject { |item| exclude?(item) }
25
+ returned << Automatic::FeedMaker.create_pipeline(kept) unless kept.empty?
26
+ end
28
27
  end
29
28
 
30
29
  private
31
- def detect_exclude(item, evaluation, reason)
32
- begin
33
- if item.include?(evaluation)
34
- Automatic::Log.puts("info", "Excluded by #{reason}: #{item}")
35
- return true
30
+
31
+ def exclude?(item)
32
+ FIELDS.any? do |field|
33
+ Array(@config[field.to_s]).any? do |keyword|
34
+ excluded?(item.send(field), keyword.to_s.chomp, field)
36
35
  end
37
- rescue NoMethodError
38
- Automatic::Log.puts("warn", "Invalid feed detected in ignore process with #{item}")
39
- return false
40
36
  end
41
37
  end
42
38
 
43
- def exclude(items)
44
- detection = false
45
- unless @config['title'].nil?
46
- @config['title'].each {|e|
47
- detection = true if detect_exclude(items.title, e.chomp, 'title')
48
- }
49
- end
50
- unless @config['link'].nil?
51
- @config['link'].each {|e|
52
- detection = true if detect_exclude(items.link, e.chomp, 'link')
53
- }
54
- end
55
- unless @config['description'].nil?
56
- @config['description'].each {|e|
57
- detection = true if detect_exclude(items.description, e.chomp, 'description')
58
- }
39
+ # An item whose field is missing is kept, with a warning.
40
+ def excluded?(value, keyword, field)
41
+ unless value.respond_to?(:include?)
42
+ Automatic::Log.puts('warn', "Invalid feed detected in ignore process with #{value}")
43
+ return false
59
44
  end
60
- detection
45
+
46
+ return false unless value.include?(keyword)
47
+
48
+ Automatic::Log.puts('info', "Excluded by #{field}: #{value}")
49
+ true
61
50
  end
62
51
  end
63
52
  end
@@ -1,42 +1,53 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::Image
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Sep 18, 2012
5
- # Updated:: Apr 5, 2013
6
- # Copyright:: Copyright (c) 2012-2013 Automatic Ruby Developers.
7
- # License:: Licensed under the GNU GENERAL PUBLIC LICENSE, Version 3.0.
2
+ # Name:: Automatic::Plugin::Filter::Image
3
+ # Author: id774 (More info: http://id774.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:: Sep 18, 2012
8
+ # Updated:: Aug 15, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
8
10
 
9
11
  module Automatic::Plugin
10
12
  class FilterImage
13
+ require 'uri'
11
14
 
12
- def initialize(config, pipeline=[])
13
- @config = config
15
+ # webp and avif are here because they are what an image link on the
16
+ # current web frequently is; tif joins tiff for the same reason.
17
+ EXTENSIONS = /\.(jpe?g|gif|png|tiff?|webp|avif)\z/i
18
+
19
+ def initialize(config, pipeline = [])
20
+ @config = config || {}
14
21
  @pipeline = pipeline
15
22
  end
16
23
 
24
+ # Sets link to nil unless it names an image. Note that the items are kept:
25
+ # their links are blanked, and the plugins after this one skip an item
26
+ # whose link is nil.
17
27
  def run
18
- @return_feeds = []
19
- @pipeline.each {|feeds|
20
- return_feed_items = []
21
- unless feeds.nil?
22
- feeds.items.each {|feed|
23
- feed.link = image?(feed.link) unless feed.link.nil?
24
- }
25
- @return_feeds << feeds
28
+ @pipeline.each_with_object([]) do |feeds, returned|
29
+ next if feeds.nil?
30
+
31
+ feeds.items.each do |item|
32
+ item.link = nil unless item.link.nil? || image?(item.link)
26
33
  end
27
- }
28
- @return_feeds
34
+ returned << feeds
35
+ end
29
36
  end
30
37
 
31
38
  private
39
+
40
+ # The test is on the path, so that a link carrying a query string -- which
41
+ # is how an image is served by most of what serves images now -- is still
42
+ # recognised. A string that will not parse is tested whole, as before.
32
43
  def image?(link)
33
- case link
34
- when /\.jpe?g\Z/i then link
35
- when /\.gif\Z/i then link
36
- when /\.png\Z/i then link
37
- when /\.tiff\Z/i then link
38
- else nil
39
- end
44
+ EXTENSIONS.match?(path(link))
45
+ end
46
+
47
+ def path(link)
48
+ URI.parse(link).path.to_s
49
+ rescue URI::InvalidURIError
50
+ link
40
51
  end
41
52
  end
42
53
  end
@@ -1,71 +1,77 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::ImageSource
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Feb 28, 2012
5
- # Updated:: Feb 26, 2014
6
- # Copyright:: Copyright (c) 2012-2014 Automatic Ruby Developers.
7
- # License:: Licensed under the GNU GENERAL PUBLIC LICENSE, Version 3.0.
2
+ # Name:: Automatic::Plugin::Filter::ImageSource
3
+ # Author: id774 (More info: http://id774.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:: Feb 28, 2012
8
+ # Updated:: Aug 15, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
8
10
 
9
11
  module Automatic::Plugin
10
12
  class FilterImageSource
11
- require 'net/http'
12
- require 'kconv'
13
+ Automatic.require_optional('nokogiri', needed_by: 'FilterImageSource')
14
+ require 'uri'
13
15
 
14
- def initialize(config, pipeline=[])
15
- @config = config
16
+ def initialize(config, pipeline = [])
17
+ @config = config || {}
16
18
  @pipeline = pipeline
17
19
  end
18
20
 
21
+ # Replaces each item with one item per image found: the images in the
22
+ # description, or, where it has none, the images on the page the link
23
+ # points at. The second case reaches the network.
19
24
  def run
20
- @return_feeds = []
21
- @pipeline.each {|feeds|
22
- new_feeds = Array.new
23
- unless feeds.nil?
24
- feeds.items.each {|feed|
25
- arr = rewrite_link(feed)
26
- if arr.length > 0
27
- arr.each {|link|
28
- Automatic::Log.puts("info", "Extract Image: #{link}")
29
- hashie = Hashie::Mash.new
30
- hashie.title = 'FilterImageSource'
31
- hashie.link = link
32
- new_feeds << hashie
33
- }
34
- end
35
- }
36
- end
37
- @return_feeds << Automatic::FeedMaker.create_pipeline(new_feeds)
38
- }
39
- @return_feeds
25
+ @pipeline.each_with_object([]) do |feeds, returned|
26
+ items = feeds.nil? ? [] : feeds.items.flat_map { |item| extract(item) }
27
+ returned << Automatic::FeedMaker.create_pipeline(items)
28
+ end
40
29
  end
41
30
 
42
31
  private
43
32
 
44
- def rewrite_link(feed)
45
- array = Array.new
46
- feed.description.scan(/<img src="(.*?)"/) {|matched|
47
- array = array | matched
48
- }
49
- if array.length === 0 && feed.link != nil
50
- array = imgs(feed.link)
33
+ def extract(item)
34
+ images(item).map do |link|
35
+ Automatic::Log.puts('info', "Extract Image: #{link}")
36
+ image = Hashie::Mash.new
37
+ image.title = 'FilterImageSource'
38
+ image.link = link
39
+ image
51
40
  end
52
- array
53
41
  end
54
42
 
55
- def imgs(link)
56
- images = Array.new
57
- html = open(link).read
58
- unless html.nil?
59
- doc = Nokogiri::HTML(html)
60
- (doc/:img).each {|img|
61
- image = img[:src]
62
- unless /^http/ =~ image
63
- image = link.sub(/\/([^\/]+)$/, image.sub(/^\./,''))
64
- end
65
- images << image
66
- }
67
- end
68
- images
43
+ def images(item)
44
+ found = sources(item.description.to_s, item.link)
45
+ return found unless found.empty?
46
+ return [] if item.link.nil?
47
+
48
+ page_images(item.link)
49
+ end
50
+
51
+ def page_images(link)
52
+ sources(Automatic::Http.read(link), link)
53
+ rescue StandardError => e
54
+ Automatic::Log.puts('warn', "Failed to read images from #{link}: #{e.message}")
55
+ []
56
+ end
57
+
58
+ # The images of an HTML fragment, as absolute URLs. This was a scan for
59
+ # `<img src="` before, which found nothing in a document quoting its
60
+ # attributes with apostrophes or writing src after another attribute; the
61
+ # parser this plugin already needs answers the question properly.
62
+ def sources(html, base)
63
+ Nokogiri::HTML.fragment(html).css('img').filter_map do |image|
64
+ source = image['src'].to_s.strip
65
+ absolute(source, base) unless source.empty?
66
+ end.uniq
67
+ end
68
+
69
+ def absolute(source, base)
70
+ return source if base.nil?
71
+
72
+ URI.join(base, source).to_s
73
+ rescue StandardError
74
+ source
69
75
  end
70
76
  end
71
77
  end