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,212 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Plugin::Store::Digest
3
+ # Description:: Drop items whose content has been seen before, by SHA-256 digest.
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
+ # Content identity, where StorePermalink is URL identity. The fields the Recipe
13
+ # names are normalized, joined into one canonical string and hashed, and the
14
+ # digest is what the database holds: an item whose digest is already there has
15
+ # been seen, whatever its link says, and an item whose link has been seen before
16
+ # under different content has not.
17
+ #
18
+ # It is exact matching, of the content the Recipe selected. Whitespace runs and
19
+ # Unicode composition differences are absorbed; nothing else is. Two articles
20
+ # that say the same thing in different words are two articles here, and making
21
+ # them one is not this plugin's work -- see doc/PLUGINS.md section 6.4.
22
+
23
+ require 'digest'
24
+ require_relative 'database'
25
+
26
+ module Automatic::Plugin
27
+ # Named for what it holds rather than as `Digest`, which is Ruby's own
28
+ # hashing module and is what computes the value stored here.
29
+ class DigestRecord < ActiveRecord::Base
30
+ end
31
+
32
+ class StoreDigest
33
+ include Automatic::Plugin::Database
34
+
35
+ # The item fields a digest may be taken over. `date` is not one: an item
36
+ # republished unchanged carries a new date often enough that including it
37
+ # would defeat the purpose. `enclosure` is not one either, being a
38
+ # structure rather than a value.
39
+ FIELDS = %w[title link description author comments source content_encoded].freeze
40
+
41
+ # What an item is, absent a Recipe saying otherwise: what it says, not
42
+ # where it is. A feed that reissues an article under a new URL is the case
43
+ # this plugin exists for.
44
+ DEFAULT_FIELDS = %w[title description].freeze
45
+
46
+ # The field name is part of what is hashed, and this separates it from its
47
+ # value and one field from the next. A byte no title, body or URL contains,
48
+ # so that no arrangement of two fields collides with another.
49
+ SEPARATOR = "\0"
50
+
51
+ def initialize(config, pipeline = [])
52
+ @config = config || {}
53
+ @pipeline = pipeline
54
+ end
55
+
56
+ def column_definition
57
+ { digest: :string, created_at: :string }
58
+ end
59
+
60
+ def model_class
61
+ Automatic::Plugin::DigestRecord
62
+ end
63
+
64
+ # Records the digest of each item's selected fields and passes on only the
65
+ # digests not already recorded.
66
+ #
67
+ # Nothing is rescued around the database. A store plugin that failed to
68
+ # write and passed the item on anyway would publish it again on the next
69
+ # run, which is the one thing this plugin is for.
70
+ def run
71
+ validate_settings
72
+ prepare_database
73
+
74
+ @pipeline.each_with_object([]) do |feeds, returned|
75
+ next if feeds.nil?
76
+
77
+ new_items = feeds.items.select { |item| new_item?(item) }
78
+ returned << Automatic::FeedMaker.create_pipeline(new_items) unless new_items.empty?
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ # The table the mixin builds, plus the constraint. Two runs of one Recipe
85
+ # overlapping -- a `cron` entry that takes longer than its interval -- would
86
+ # otherwise both read a digest as absent and both store it. The unique
87
+ # index makes the second write fail instead, which #store below reads as
88
+ # what it is.
89
+ def create_table
90
+ super
91
+ ActiveRecord::Base.connection.add_index(model_class.table_name, :digest, unique: true)
92
+ end
93
+
94
+ # Everything the Recipe has to get right, checked before the database file
95
+ # is opened. A Recipe this plugin cannot carry out is the operator's
96
+ # mistake and will be the same mistake next hour.
97
+ def validate_settings
98
+ raise ArgumentError, 'StoreDigest needs a db file name' if @config['db'].to_s.strip.empty?
99
+
100
+ fields
101
+ end
102
+
103
+ # The fields, in the order the Recipe wrote them. That order is part of the
104
+ # fingerprint and is not sorted here: a Recipe that changes it has said
105
+ # something different, and its digests are different digests.
106
+ #
107
+ # Only an absent `fields` takes the default. Every other way of getting it
108
+ # wrong is refused rather than corrected, because a Recipe's own statement
109
+ # of what makes two items the same is what this plugin has to obey.
110
+ def fields
111
+ @fields ||= validated_fields
112
+ end
113
+
114
+ def validated_fields
115
+ given = @config['fields']
116
+ return DEFAULT_FIELDS if given.nil?
117
+
118
+ unless given.is_a?(Array)
119
+ raise ArgumentError, "StoreDigest takes a list of fields, not #{given.inspect}"
120
+ end
121
+
122
+ names = given.map(&:to_s)
123
+ raise ArgumentError, 'StoreDigest was given an empty fields list' if names.empty?
124
+
125
+ unknown = names - FIELDS
126
+ unless unknown.empty?
127
+ raise ArgumentError,
128
+ "StoreDigest cannot take a digest over #{unknown.join(', ')}; " \
129
+ "the fields are #{FIELDS.join(', ')}"
130
+ end
131
+
132
+ duplicated = names.tally.select { |_name, count| count > 1 }.keys
133
+ unless duplicated.empty?
134
+ raise ArgumentError, "StoreDigest was given #{duplicated.join(', ')} twice"
135
+ end
136
+
137
+ names
138
+ end
139
+
140
+ # Whether the item goes downstream. An item whose digest was stored here is
141
+ # new, one whose digest was already stored is not, and one there is nothing
142
+ # to hash goes on unjudged.
143
+ def new_item?(item)
144
+ digest = digest_for(item)
145
+
146
+ if digest.nil?
147
+ Automatic::Log.puts('warn',
148
+ "StoreDigest: no digestable content for #{item.link}; " \
149
+ 'passing item unchanged')
150
+ return true
151
+ end
152
+
153
+ return false if stored_digest?(digest)
154
+
155
+ store(digest, item)
156
+ end
157
+
158
+ def stored_digest?(digest)
159
+ model_class.exists?(digest: digest)
160
+ end
161
+
162
+ # True when this run is the one that stored the digest. The read above
163
+ # answers this on its own for a single run; the rescue is for the run
164
+ # overlapping another, where the row appeared between the two statements.
165
+ def store(digest, item)
166
+ model_class.create!(digest: digest, created_at: Time.now.strftime('%Y/%m/%d %X'))
167
+ Automatic::Log.puts('info', "Saving Digest: #{digest} (#{item.link})")
168
+ true
169
+ rescue ActiveRecord::RecordNotUnique
170
+ false
171
+ end
172
+
173
+ # The digest of the item's selected fields, or nil where the Recipe
174
+ # selected nothing the item has.
175
+ #
176
+ # An item with nothing to hash is not stored under the digest of the empty
177
+ # string: every item with no description would then be the same item as
178
+ # every other, and the first of them would silence the rest for good.
179
+ def digest_for(item)
180
+ values = fields.map { |field| [field, normalize(value_of(item, field))] }
181
+ return nil if values.all? { |_field, value| value.empty? }
182
+
183
+ canonical = values.map { |field, value| "#{field}#{SEPARATOR}#{value}" }.join(SEPARATOR)
184
+ ::Digest::SHA256.hexdigest(canonical)
185
+ end
186
+
187
+ # A field an item does not carry reads as absent rather than raising.
188
+ # `content_encoded` is the case that matters: an item built by a filter has
189
+ # it, one straight from an RSS 2.0 feed may not.
190
+ def value_of(item, field)
191
+ item.respond_to?(field) ? item.public_send(field) : nil
192
+ end
193
+
194
+ # What two spellings of one string have to survive to hash alike: the
195
+ # encoding, the composition of accented characters, and how much whitespace
196
+ # a feed happens to have put between words. Nothing beyond that -- case,
197
+ # punctuation and markup are content here, and an item that differs in them
198
+ # is a different item.
199
+ #
200
+ # `scrub` is not redundant after `encode`: a conversion whose source and
201
+ # destination encodings are the same is skipped, invalid bytes and all, and
202
+ # `unicode_normalize` raises on what is left.
203
+ def normalize(value)
204
+ value.to_s.
205
+ encode('UTF-8', invalid: :replace, undef: :replace).
206
+ scrub.
207
+ unicode_normalize(:nfc).
208
+ gsub(/\s+/, ' ').
209
+ strip
210
+ end
211
+ end
212
+ end
@@ -1,95 +1,126 @@
1
- #!/usr/bin/env ruby
2
1
  # -*- coding: utf-8 -*-
3
- # Name:: Automatic::Plugin::Store::File
4
- # Author:: 774 <http://id774.net>
5
- # Created:: Feb 28, 2012
6
- # Updated:: Feb 25, 2014
7
- # Copyright:: Copyright (c) 2012-2014 Automatic Ruby Developers.
8
- # License:: Licensed under the GNU GENERAL PUBLIC LICENSE, Version 3.0.
9
-
10
- require 'open-uri'
2
+ # Name:: Automatic::Plugin::Store::File
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.
10
+
11
+ require 'fileutils'
11
12
  require 'uri'
12
- require 'aws-sdk'
13
13
 
14
14
  module Automatic::Plugin
15
15
  class StoreFile
16
+ # A link with either scheme is fetched from S3 rather than over HTTP.
17
+ # `s3n` is what Recipes written for this plugin use; `s3` is the spelling
18
+ # everything else uses and is accepted as well.
19
+ S3_SCHEMES = %w[s3 s3n].freeze
16
20
 
17
- def initialize(config, pipeline=[])
18
- @config = config
21
+ def initialize(config, pipeline = [])
22
+ @config = config || {}
19
23
  @pipeline = pipeline
20
- unless @config['bucket_name'].nil?
21
- s3 = AWS::S3.new(
22
- :access_key_id => @config['access_key'],
23
- :secret_access_key => @config['secret_key']
24
- )
25
- @bucket = s3.buckets[@config['bucket_name']]
26
- end
27
- @return_feeds = []
28
24
  end
29
25
 
26
+ # Downloads what each link points at and rewrites the link to a file URI,
27
+ # which is how PublishAmazonS3 later knows it has a local file.
30
28
  def run
31
- @pipeline.each {|feeds|
32
- unless feeds.nil?
33
- feeds.items.each {|feed|
34
- unless feed.link.nil?
35
- Automatic::Log.puts("info", "Downloading File: #{feed.link}")
36
- FileUtils.mkdir_p(@config['path']) unless FileTest.exist?(@config['path'])
37
- retries = 0
38
- retry_max = @config['retry'].to_i || 0
39
- begin
40
- retries += 1
41
- feed.link = get_file(feed.link)
42
- sleep ||= @config['interval'].to_i
43
- @return_feeds << feed
44
- rescue
45
- Automatic::Log.puts("error", "ErrorCount: #{retries}, Fault during file download.")
46
- sleep ||= @config['interval'].to_i
47
- retry if retries <= retry_max
48
- end
49
- end
50
- }
29
+ stored = []
30
+ @pipeline.each do |feeds|
31
+ next if feeds.nil?
32
+
33
+ feeds.items.each do |feed|
34
+ next if feed.link.nil?
35
+
36
+ stored << feed if store(feed)
51
37
  end
52
- }
38
+ end
39
+
53
40
  @pipeline = []
54
- @pipeline << Automatic::FeedMaker.create_pipeline(@return_feeds) if @return_feeds.length > 0
41
+ @pipeline << Automatic::FeedMaker.create_pipeline(stored) unless stored.empty?
55
42
  @pipeline
56
43
  end
57
44
 
58
45
  private
59
46
 
47
+ def store(feed)
48
+ Automatic::Log.puts('info', "Downloading File: #{feed.link}")
49
+ FileUtils.mkdir_p(@config['path'].to_s)
50
+
51
+ retries = 0
52
+ retry_max = @config['retry'].to_i
53
+ begin
54
+ feed.link = get_file(feed.link)
55
+ sleep(@config['interval'].to_i)
56
+ true
57
+ rescue StandardError => e
58
+ retries += 1
59
+ Automatic::Log.puts('error',
60
+ "ErrorCount: #{retries}, Fault during file download: #{e.message}")
61
+ return false if retries > retry_max
62
+
63
+ sleep(@config['interval'].to_i)
64
+ retry
65
+ end
66
+ end
67
+
60
68
  def get_file(url)
61
69
  uri = URI.parse(url)
62
- case uri.scheme
63
- when "s3n"
64
- return_path = get_aws(uri)
65
- else
66
- return_path = wget(uri, url)
67
- end
68
- Automatic::Log.puts("info", "Saved File: #{return_path}")
69
- "file://" + return_path
70
+ path = S3_SCHEMES.include?(uri.scheme) ? from_s3(uri) : download(url)
71
+ Automatic::Log.puts('info', "Saved File: #{path}")
72
+ "file://#{path}"
70
73
  end
71
74
 
72
- def wget(uri, url)
73
- filename = File.basename(uri.path)
74
- filepath = File.join(@config['path'], filename)
75
- open(url) {|source|
76
- open(filepath, "w+b") { |o|
77
- o.print(source.read)
78
- }
79
- }
80
- filepath
75
+ # Only HTTP and HTTPS are fetched: a link arrives from a feed, which is to
76
+ # say from outside, and `file://` is not something a store plugin should
77
+ # be talked into reading.
78
+ def download(url)
79
+ path = local_path(Automatic::Http.uri(url).path)
80
+ File.binwrite(path, Automatic::Http.read(url))
81
+ path
81
82
  end
82
83
 
83
- def get_aws(uri)
84
- filename = File.basename(uri.path)
85
- filepath = File.join(@config['path'], filename)
86
- object = @bucket.objects[uri.path]
87
- File.open(filepath, 'wb') do |file|
88
- object.read do |chunk|
89
- file.write(chunk)
90
- end
84
+ # AWS SDK for Ruby v3, one gem for one service. The bucket is the Recipe's
85
+ # `bucket_name` where it has one, so that an existing Recipe keeps its
86
+ # meaning, and the link's own host otherwise. Credentials are the Recipe's
87
+ # where it carries them and the SDK's default chain -- environment,
88
+ # profile, instance role -- where it does not, which is the way to run
89
+ # this without a secret in a file.
90
+ def from_s3(uri)
91
+ path = local_path(uri.path)
92
+ s3.get_object(bucket: bucket(uri), key: uri.path.sub(%r{\A/}, ''),
93
+ response_target: path)
94
+ path
95
+ end
96
+
97
+ def s3
98
+ @s3 ||= begin
99
+ Automatic.require_optional('aws-sdk-s3', needed_by: 'the S3 path of StoreFile')
100
+ Aws::S3::Client.new(**client_options)
101
+ end
102
+ end
103
+
104
+ def client_options
105
+ options = {}
106
+ options[:region] = @config['region'].to_s unless @config['region'].nil?
107
+ unless @config['access_key'].nil?
108
+ options[:access_key_id] = @config['access_key'].to_s
109
+ options[:secret_access_key] = @config['secret_key'].to_s
91
110
  end
92
- filepath
111
+ options
112
+ end
113
+
114
+ def bucket(uri)
115
+ name = @config['bucket_name'].to_s
116
+ name.empty? ? uri.host.to_s : name
117
+ end
118
+
119
+ def local_path(remote_path)
120
+ name = File.basename(remote_path.to_s)
121
+ raise ArgumentError, "no file name in #{remote_path}" if name.empty? || name == '/'
122
+
123
+ File.join(@config['path'].to_s, name)
93
124
  end
94
125
  end
95
126
  end
@@ -1,12 +1,14 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Store::FullText
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Feb 26, 2012
5
- # Updated:: Oct 09, 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::Store::FullText
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 26, 2012
8
+ # Updated:: Aug 15, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
8
10
 
9
- require 'plugins/store/database'
11
+ require_relative 'database'
10
12
 
11
13
  module Automatic::Plugin
12
14
  class Blog < ActiveRecord::Base
@@ -15,43 +17,48 @@ module Automatic::Plugin
15
17
  class StoreFullText
16
18
  include Automatic::Plugin::Database
17
19
 
18
- def initialize(config, pipeline=[])
19
- @config = config
20
+ def initialize(config, pipeline = [])
21
+ @config = config || {}
20
22
  @pipeline = pipeline
21
23
  end
22
24
 
23
25
  def column_definition
24
26
  {
25
- :title => :string,
26
- :link => :string,
27
- :description => :string,
28
- :content => :string,
29
- :created_at => :string,
27
+ title: :string,
28
+ link: :string,
29
+ description: :string,
30
+ content: :string,
31
+ created_at: :string
30
32
  }
31
33
  end
32
34
 
35
+ # Link or title, so that a republished article with a new URL is not
36
+ # stored twice.
33
37
  def unique_keys
34
- [:link, :title]
38
+ %i[link title]
35
39
  end
36
40
 
37
41
  def model_class
38
42
  Automatic::Plugin::Blog
39
43
  end
40
44
 
45
+ # Records title, link, description and content_encoded, and passes on only
46
+ # what is new.
41
47
  def run
42
- for_each_new_feed {|feed|
43
- Automatic::Log.puts("info", "Saving FullText: #{feed.link}")
48
+ for_each_new_feed do |feed|
49
+ Automatic::Log.puts('info', "Saving FullText: #{feed.link}")
44
50
  begin
45
51
  Blog.create(
46
- :title => feed.title,
47
- :link => feed.link,
48
- :description => feed.description,
49
- :content => feed.content_encoded,
50
- :created_at => Time.now.strftime("%Y/%m/%d %X"))
51
- rescue
52
- Automatic::Log.puts("warn", "Skip feed due to fault in save.")
52
+ title: feed.title,
53
+ link: feed.link,
54
+ description: feed.description,
55
+ content: feed.content_encoded,
56
+ created_at: Time.now.strftime('%Y/%m/%d %X')
57
+ )
58
+ rescue StandardError => e
59
+ Automatic::Log.puts('warn', "Skip feed due to fault in save: #{e.message}")
53
60
  end
54
- }
61
+ end
55
62
  end
56
63
  end
57
64
  end
@@ -1,13 +1,14 @@
1
- #!/usr/bin/env ruby
2
1
  # -*- coding: utf-8 -*-
3
- # Name:: Automatic::Plugin::Store::Permalink
4
- # Author:: 774 <http://id774.net>
5
- # Created:: Feb 22, 2012
6
- # Updated:: Oct 09, 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::Store::Permalink
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.
9
10
 
10
- require 'plugins/store/database'
11
+ require_relative 'database'
11
12
 
12
13
  module Automatic::Plugin
13
14
  class Permalink < ActiveRecord::Base
@@ -16,16 +17,13 @@ module Automatic::Plugin
16
17
  class StorePermalink
17
18
  include Automatic::Plugin::Database
18
19
 
19
- def initialize(config, pipeline=[])
20
- @config = config
20
+ def initialize(config, pipeline = [])
21
+ @config = config || {}
21
22
  @pipeline = pipeline
22
23
  end
23
24
 
24
25
  def column_definition
25
- {
26
- :url => :string,
27
- :created_at => :string
28
- }
26
+ { url: :string, created_at: :string }
29
27
  end
30
28
 
31
29
  def unique_keys
@@ -36,15 +34,13 @@ module Automatic::Plugin
36
34
  Automatic::Plugin::Permalink
37
35
  end
38
36
 
37
+ # Records each item's link and passes on only the links not already
38
+ # recorded. The usual guard against publishing the same item twice.
39
39
  def run
40
- for_each_new_feed {|feed|
41
- unless feed.link.nil?
42
- Permalink.create(
43
- :url => feed.link,
44
- :created_at => Time.now.strftime("%Y/%m/%d %X"))
45
- Automatic::Log.puts("info", "Saving Permalink: #{feed.link}")
46
- end
47
- }
40
+ for_each_new_feed do |feed|
41
+ Permalink.create(url: feed.link, created_at: Time.now.strftime('%Y/%m/%d %X'))
42
+ Automatic::Log.puts('info', "Saving Permalink: #{feed.link}")
43
+ end
48
44
  end
49
45
  end
50
46
  end
@@ -1,33 +1,46 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::SubscriptionFeed
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::SubscriptionFeed
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 SubscriptionFeed
11
- def initialize(config, pipeline=[])
12
- @config = config
13
+ def initialize(config, pipeline = [])
14
+ @config = config || {}
13
15
  @pipeline = pipeline
14
16
  end
15
17
 
16
18
  def run
17
- @config['feeds'].each {|feed|
18
- retries = 0
19
- retry_max = @config['retry'].to_i || 0
20
- begin
21
- rss = Automatic::FeedParser.get_url(feed)
22
- @pipeline << rss
23
- rescue
24
- retries += 1
25
- Automatic::Log.puts("error", "ErrorCount: #{retries}, Fault in parsing: #{feed}")
26
- sleep ||= @config['interval'].to_i
27
- retry if retries <= retry_max
28
- end
29
- }
19
+ Array(@config['feeds']).each do |url|
20
+ feed = fetch(url)
21
+ @pipeline << feed unless feed.nil?
22
+ end
30
23
  @pipeline
31
24
  end
25
+
26
+ private
27
+
28
+ # A feed that fails after its retries is logged and skipped; the others
29
+ # still run.
30
+ def fetch(url)
31
+ retries = 0
32
+ retry_max = @config['retry'].to_i
33
+ begin
34
+ Automatic::FeedParser.get_url(url)
35
+ rescue StandardError => e
36
+ retries += 1
37
+ Automatic::Log.puts('error',
38
+ "ErrorCount: #{retries}, Fault in parsing: #{url}, #{e.message}")
39
+ return nil if retries > retry_max
40
+
41
+ sleep(@config['interval'].to_i)
42
+ retry
43
+ end
44
+ end
32
45
  end
33
46
  end