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,75 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Plugin::Filter::Present
3
+ # Description:: Keep items whose configured fields are all present.
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 FilterPresent
14
+ # The item fields presence may be checked over. `date` is not one, being
15
+ # absent from a plain summary as often as not, and `enclosure` is not
16
+ # either, being a structure rather than a value.
17
+ FIELDS = %w[title link description author comments source content_encoded].freeze
18
+
19
+ def initialize(config, pipeline = [])
20
+ @config = config || {}
21
+ @pipeline = pipeline
22
+ @fields = validated_fields
23
+ end
24
+
25
+ # Keeps only the items where every configured field is present. A feed
26
+ # that kept nothing is dropped from the output rather than passed on empty.
27
+ def run
28
+ @pipeline.each_with_object([]) do |feeds, returned|
29
+ next if feeds.nil?
30
+
31
+ survivors = feeds.items.select { |item| present?(item) }
32
+ returned << Automatic::FeedMaker.create_pipeline(survivors) unless survivors.empty?
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def validated_fields
39
+ given = @config['fields']
40
+ raise ArgumentError, 'FilterPresent takes fields as a list' unless given.is_a?(Array)
41
+
42
+ names = given.map(&:to_s)
43
+ raise ArgumentError, 'FilterPresent needs a non-empty fields list' if names.empty?
44
+
45
+ unknown = names - FIELDS
46
+ unless unknown.empty?
47
+ raise ArgumentError,
48
+ "FilterPresent cannot inspect #{unknown.join(', ')}; " \
49
+ "the fields are #{FIELDS.join(', ')}"
50
+ end
51
+
52
+ duplicated = names.tally.select { |_name, count| count > 1 }.keys
53
+ raise ArgumentError, "FilterPresent was given #{duplicated.join(', ')} twice" unless duplicated.empty?
54
+
55
+ names
56
+ end
57
+
58
+ def present?(item)
59
+ @fields.all? { |field| field_present?(item, field) }
60
+ end
61
+
62
+ # A field an item does not carry, or whose value is nil, is absent. A
63
+ # field whose value answers #content -- a parsed source or enclosure -- is
64
+ # judged on that content rather than on the element itself.
65
+ def field_present?(item, field)
66
+ return false unless item.respond_to?(field)
67
+
68
+ value = item.public_send(field)
69
+ return false if value.nil?
70
+
71
+ value = value.content if value.respond_to?(:content)
72
+ !value.to_s.strip.empty?
73
+ end
74
+ end
75
+ end
@@ -1,28 +1,27 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::Rand
3
- # Author:: soramugi <http://soramugi.net>
4
- # 774 <http://id774.net>
5
- # Created:: Mar 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::Rand
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:: Mar 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 FilterRand
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
+ # Shuffles each feed's items. Combined with FilterOne, picks one at random.
18
19
  def run
19
- @return_feeds = []
20
- @pipeline.each {|feed|
21
- unless feed.nil?
22
- @return_feeds << Automatic::FeedMaker.create_pipeline(feed.items.shuffle)
23
- end
24
- }
25
- @return_feeds
20
+ @pipeline.each_with_object([]) do |feeds, returned|
21
+ next if feeds.nil?
22
+
23
+ returned << Automatic::FeedMaker.create_pipeline(feeds.items.shuffle)
24
+ end
26
25
  end
27
26
  end
28
27
  end
@@ -0,0 +1,205 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Plugin::Filter::SakuraAI
3
+ # Description:: Replace each item's description with what the Sakura AI Engine 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 Sakura AI Engine under
13
+ # the 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
+ # The Sakura AI Engine 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 set
20
+ # of models, its own limits and its own errors, and any of those may move
21
+ # without OpenAI moving. A Recipe naming FilterSakuraAI says which service the
22
+ # text is sent to, which a `provider:` setting would not.
23
+ #
24
+ # @see https://manual.sakura.ad.jp/cloud/ai-engine/
25
+
26
+ module Automatic::Plugin
27
+ class FilterSakuraAI
28
+ require 'json'
29
+ require 'net/http'
30
+ require 'openssl'
31
+ require 'uri'
32
+
33
+ # The one endpoint the service publishes for this. It is not a setting: an
34
+ # operator has no version of this plugin that talks to a different host,
35
+ # and a setting for it would be a way to send the token somewhere else.
36
+ ENDPOINT = URI('https://api.ai.sakura.ad.jp/v1/chat/completions')
37
+
38
+ OPEN_TIMEOUT = 10
39
+
40
+ # Generous, and bounded. A model given several articles thinks for a while;
41
+ # an unattended run that waits forever is the failure this exists against.
42
+ READ_TIMEOUT = 300
43
+
44
+ # A failure that another attempt will not get past: a setting that is
45
+ # wrong, a request the service refuses, an answer this plugin cannot read.
46
+ class Error < StandardError; end
47
+
48
+ # A failure that another attempt may get past: the network, a rate limit, a
49
+ # server error.
50
+ class TemporaryError < StandardError; end
51
+
52
+ def initialize(config, pipeline = [])
53
+ @config = config || {}
54
+ @pipeline = pipeline
55
+ end
56
+
57
+ # Replaces each item's description with the answer. Nothing else about an
58
+ # item is touched, and the feeds and their items arrive and leave in the
59
+ # same order and number.
60
+ def run
61
+ validate_settings
62
+
63
+ @pipeline.each { |feeds|
64
+ next if feeds.nil?
65
+
66
+ feeds.items.each { |item| transform(item) }
67
+ }
68
+ @pipeline
69
+ end
70
+
71
+ private
72
+
73
+ # Checked before the first request, because a Recipe this plugin cannot
74
+ # carry out is the operator's mistake and will be the same mistake on every
75
+ # item. The token is never named in a message.
76
+ def validate_settings
77
+ raise ArgumentError, 'FilterSakuraAI needs a token' if token.empty?
78
+ raise ArgumentError, 'FilterSakuraAI needs a model' if model.empty?
79
+ raise ArgumentError, 'FilterSakuraAI needs a prompt' if prompt.empty?
80
+ end
81
+
82
+ def token
83
+ @config['token'].to_s
84
+ end
85
+
86
+ def model
87
+ @config['model'].to_s.strip
88
+ end
89
+
90
+ def prompt
91
+ @config['prompt'].to_s.strip
92
+ end
93
+
94
+ def transform(item)
95
+ text = item.description.to_s
96
+ if text.strip.empty?
97
+ Automatic::Log.puts('warn', "FilterSakuraAI: nothing to send for #{item.link}")
98
+ return
99
+ end
100
+
101
+ Automatic::Log.puts('info', "FilterSakuraAI: asking #{model} about #{item.link}")
102
+ item.description = answer(text)
103
+ end
104
+
105
+ # The retry shape of doc/PLUGINS.md section 3.6, applied only to what
106
+ # retrying can help. A missing setting, a refused request or an answer in a
107
+ # shape this plugin cannot read is raised at once: trying again would fail
108
+ # the same way, more slowly.
109
+ def answer(text)
110
+ retries = 0
111
+ retry_max = @config['retry'].to_i
112
+ begin
113
+ completion(text)
114
+ rescue TemporaryError => e
115
+ retries += 1
116
+ Automatic::Log.puts('error', "ErrorCount: #{retries}, FilterSakuraAI: #{e.message}")
117
+ if retries <= retry_max
118
+ sleep(@config['interval'].to_i)
119
+ retry
120
+ end
121
+ raise Error, "FilterSakuraAI gave up after #{retries} attempts: #{e.message}"
122
+ end
123
+ end
124
+
125
+ def completion(text)
126
+ # The prompt is the system turn and the description is the user turn it
127
+ # is applied to. They are separate messages, so that what an article says
128
+ # is never read as an instruction to this plugin or to the model.
129
+ body = {
130
+ 'model' => model,
131
+ 'messages' => [
132
+ { 'role' => 'system', 'content' => prompt },
133
+ { 'role' => 'user', 'content' => text }
134
+ ]
135
+ }
136
+ content(post(JSON.generate(body)))
137
+ end
138
+
139
+ def post(body)
140
+ request = Net::HTTP::Post.new(ENDPOINT)
141
+ request['Authorization'] = "Bearer #{token}"
142
+ request['Content-Type'] = 'application/json'
143
+ request.body = body
144
+
145
+ # TLS with the certificate verified, which is Net::HTTP's own default and
146
+ # is named here because it is not a thing to be turned off.
147
+ Net::HTTP.start(ENDPOINT.host, ENDPOINT.port,
148
+ use_ssl: true,
149
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
150
+ open_timeout: OPEN_TIMEOUT,
151
+ read_timeout: READ_TIMEOUT) { |http| http.request(request) }
152
+ rescue Timeout::Error, SystemCallError, SocketError, IOError,
153
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
154
+ raise TemporaryError, "the request to the Sakura AI Engine failed: #{e.message}"
155
+ end
156
+
157
+ def content(response)
158
+ case response
159
+ when Net::HTTPSuccess
160
+ answer_text(parse(response.body))
161
+ when Net::HTTPTooManyRequests, Net::HTTPServerError
162
+ raise TemporaryError, "the Sakura AI Engine answered #{response.code}: #{reason(response)}"
163
+ else
164
+ raise Error, "the Sakura AI Engine answered #{response.code}: #{reason(response)}"
165
+ end
166
+ end
167
+
168
+ def parse(body)
169
+ JSON.parse(body.to_s)
170
+ rescue JSON::ParserError => e
171
+ raise Error, "the Sakura AI Engine answered with something that is not JSON: #{e.message}"
172
+ end
173
+
174
+ # The first choice's message content. An answer this plugin cannot find is
175
+ # an error and not an empty description: a Recipe that published the empty
176
+ # string here would have thrown the article away and reported success.
177
+ def answer_text(body)
178
+ choices = body['choices']
179
+ unless choices.is_a?(Array) && choices.first.is_a?(Hash)
180
+ raise Error, 'the Sakura AI Engine answered without a choice'
181
+ end
182
+
183
+ message = choices.first['message']
184
+ raise Error, 'the Sakura AI Engine answered without a message' unless message.is_a?(Hash)
185
+
186
+ text = message['content'].to_s.strip
187
+ raise Error, 'the Sakura AI Engine answered with no content' if text.empty?
188
+
189
+ text
190
+ end
191
+
192
+ # The service's own explanation where it gave one, the status line
193
+ # otherwise. Neither carries the token, and the settings are never logged
194
+ # or raised wholesale.
195
+ def reason(response)
196
+ body = JSON.parse(response.body.to_s)
197
+ error = body['error']
198
+ return error['message'].to_s if error.is_a?(Hash) && !error['message'].to_s.empty?
199
+
200
+ response.message.to_s
201
+ rescue JSON::ParserError
202
+ response.message.to_s
203
+ end
204
+ end
205
+ end
@@ -1,49 +1,44 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::Sanitize
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Jun 20, 2013
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::Sanitize
3
+ # Author: id774 (More info: https://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:: Jun 20, 2013
8
+ # Updated:: Aug 15, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
8
10
 
9
11
  module Automatic::Plugin
10
12
  class FilterSanitize
11
- require 'sanitize'
13
+ Automatic.require_optional('sanitize', needed_by: 'FilterSanitize')
12
14
 
13
- def initialize(config, pipeline=[])
14
- @config = config
15
+ MODES = {
16
+ 'basic' => Sanitize::Config::BASIC,
17
+ 'relaxed' => Sanitize::Config::RELAXED
18
+ }.freeze
19
+
20
+ def initialize(config, pipeline = [])
21
+ @config = config || {}
15
22
  @pipeline = pipeline
16
- case @config['mode']
17
- when "basic"
18
- @mode = Sanitize::Config::BASIC
19
- when "relaxed"
20
- @mode = Sanitize::Config::RELAXED
21
- else
22
- @mode = Sanitize::Config::RESTRICTED
23
- end
23
+ @mode = MODES.fetch(@config['mode'].to_s, Sanitize::Config::RESTRICTED)
24
24
  end
25
25
 
26
+ # Strips HTML from descriptions.
26
27
  def run
27
- @return_feeds = []
28
- @pipeline.each {|feeds|
29
- unless feeds.nil?
30
- feeds.items.each {|feed|
31
- feed = sanitize(feed)
32
- }
33
- @return_feeds << feeds
34
- end
35
- }
36
- @return_feeds
28
+ @pipeline.each_with_object([]) do |feeds, returned|
29
+ next if feeds.nil?
30
+
31
+ feeds.items.each { |item| sanitize(item) }
32
+ returned << feeds
33
+ end
37
34
  end
38
35
 
39
36
  private
40
- def sanitize(feed)
41
- begin
42
- feed.description = Sanitize.clean(feed.description, @mode) unless feed.description.nil?
43
- rescue
44
- Automatic::Log.puts("warn", "Undefined field detected in feed.")
45
- end
46
- feed
37
+
38
+ def sanitize(item)
39
+ item.description = Sanitize.fragment(item.description, @mode) unless item.description.nil?
40
+ rescue StandardError => e
41
+ Automatic::Log.puts('warn', "Undefined field detected in feed: #{e.message}")
47
42
  end
48
43
  end
49
44
  end
@@ -1,37 +1,30 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::Sort
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Mar 23, 2012
5
- # Updated:: Jan 23, 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::Sort
3
+ # Author: id774 (More info: https://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:: Mar 23, 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 FilterSort
11
-
12
- def initialize(config, pipeline=[])
13
- @config = config
14
- @pipeline = pipeline
13
+ def initialize(config, pipeline = [])
14
+ @config = config || {}
15
+ @pipeline = pipeline
16
+ @ascending = @config['sort'].to_s == 'asc'
15
17
  end
16
18
 
19
+ # Sorts each feed's items by date. Items must carry one; a feed built from
20
+ # a source without dates fails here.
17
21
  def run
18
- @return_feeds = []
19
- @pipeline.each { |feeds|
20
- return_feed_items = []
21
- unless feeds.nil?
22
- if @config['sort'] == "asc"
23
- feeds.items.sort!{|a,b|
24
- a.date <=> b.date
25
- }
26
- else
27
- feeds.items.sort!{|a,b|
28
- - (a.date <=> b.date)
29
- }
30
- end
31
- @return_feeds << feeds
32
- end
33
- }
34
- @return_feeds
22
+ @pipeline.each_with_object([]) do |feeds, returned|
23
+ next if feeds.nil?
24
+
25
+ feeds.items.sort! { |a, b| @ascending ? a.date <=> b.date : b.date <=> a.date }
26
+ returned << feeds
27
+ end
35
28
  end
36
29
  end
37
30
  end
@@ -1,40 +1,48 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::TumblrResize
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Feb 28, 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::TumblrResize
3
+ # Author: id774 (More info: https://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 FilterTumblrResize
13
+ # Tumblr has served images under two URL schemes. The older one carries
14
+ # the size as a suffix on the file name -- tumblr_xxx_500.jpg -- and is
15
+ # what images uploaded before 2019 still use. The newer one carries it as
16
+ # a path segment -- /s540x810/ -- and is what everything since uses.
17
+ # Both are rewritten to the largest variant the scheme offers.
18
+ LEGACY_SIZE = /_(?:75sq|100|250|400|500)(?=\.[a-zA-Z0-9]+\z)/
19
+ LEGACY_LARGEST = '_1280'
11
20
 
12
- def initialize(config, pipeline=[])
13
- @config = config
21
+ PATH_SIZE = %r{/s\d+x\d+/}
22
+ PATH_LARGEST = '/s1280x1920/'
23
+
24
+ def initialize(config, pipeline = [])
25
+ @config = config || {}
14
26
  @pipeline = pipeline
15
27
  end
16
28
 
29
+ # Assumes FilterImage or FilterImageSource has already put an image URL in
30
+ # the link.
17
31
  def run
18
- @return_feeds = []
19
- @pipeline.each {|feeds|
20
- img_url = ""
32
+ @pipeline.each_with_object([]) do |feeds, returned|
21
33
  unless feeds.nil?
22
- feeds.items.each {|feed|
23
- feed.link = resize(feed.link) unless feed.link.nil?
24
- }
34
+ feeds.items.each do |item|
35
+ item.link = resize(item.link) unless item.link.nil?
36
+ end
25
37
  end
26
- @return_feeds << feeds
27
- }
28
- @return_feeds
38
+ returned << feeds
39
+ end
29
40
  end
30
41
 
31
42
  private
32
- def resize(string)
33
- string = string.gsub("_75sq\.", "_1280\.")
34
- string = string.gsub("_100\.", "_1280\.")
35
- string = string.gsub("_250\.", "_1280\.")
36
- string = string.gsub("_400\.", "_1280\.")
37
- string = string.gsub("_500\.", "_1280\.")
43
+
44
+ def resize(link)
45
+ link.sub(LEGACY_SIZE, LEGACY_LARGEST).sub(PATH_SIZE, PATH_LARGEST)
38
46
  end
39
47
  end
40
48
  end