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,278 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Plugin::Publish::Markdown
3
+ # Description:: Render the pipeline as a Markdown document, to a file or to standard output.
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 14, 2026
9
+ # Updated:: Aug 14, 2026
10
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
11
+
12
+ module Automatic::Plugin
13
+ class PublishMarkdown
14
+ require 'fileutils'
15
+
16
+ # Written under an item's heading, in this order. A field the item does not
17
+ # carry produces no bullet at all, so an item is not padded out with empty
18
+ # ones. See doc/PLUGINS.md section 6.7.
19
+ METADATA_FIELDS = [
20
+ ['Link', :link],
21
+ ['Date', :date],
22
+ ['Author', :author],
23
+ ['Comments', :comments],
24
+ ['Source', :source],
25
+ ['Enclosure', :enclosure]
26
+ ].freeze
27
+
28
+ # Elements that end a line of text. Everything else is unwrapped: the body
29
+ # is reduced to text rather than translated into Markdown, because doing
30
+ # the latter properly needs a library this framework declines to depend on.
31
+ BLOCK_ELEMENTS = %w[
32
+ address article aside blockquote dd div dl dt figcaption figure footer
33
+ form h1 h2 h3 h4 h5 h6 header hr li main nav ol p pre section table
34
+ tbody td th thead tr ul
35
+ ].freeze
36
+
37
+ # A body counts as HTML when it carries a tag or a character entity. One
38
+ # that carries neither is written as it stands, so that a "<" in a
39
+ # plain-text description is not parsed away.
40
+ MARKUP = %r{<[a-zA-Z/!]|&[a-zA-Z#][0-9a-zA-Z]*;}
41
+
42
+ # What the substitute reducer below matches, in the order it applies them.
43
+ COMMENT_ELEMENT = /<!--.*?-->/m
44
+ DISCARDED_ELEMENT = %r{<(script|style)\b[^>]*>.*?</\1\s*>}mi
45
+ BREAK_ELEMENT = /<br\b[^>]*>/i
46
+ BLOCK_ELEMENT = %r{</?(?:#{BLOCK_ELEMENTS.join('|')})\b[^>]*>}i
47
+ TAG = /<[^>]*>/m
48
+ ENTITY = /&(\#\d+|\#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/
49
+
50
+ # The character references a feed body actually tends to carry: the five of
51
+ # XML, and the punctuation and symbols that follow prose out of a web page.
52
+ # A reference in neither this table nor the numeric form is left as it is
53
+ # written, which a reader can still make sense of; the full HTML set is two
54
+ # thousand entries and a parser's business.
55
+ ENTITIES = {
56
+ 'amp' => '&', 'lt' => '<', 'gt' => '>', 'quot' => '"', 'apos' => "'",
57
+ 'nbsp' => "\u00A0", 'copy' => '©', 'reg' => '®', 'trade' => '™',
58
+ 'hellip' => '…', 'mdash' => '—', 'ndash' => '–', 'middot' => '·',
59
+ 'bull' => '•', 'deg' => '°', 'laquo' => '«', 'raquo' => '»',
60
+ 'lsquo' => '‘', 'rsquo' => '’', 'ldquo' => '“', 'rdquo' => '”',
61
+ 'yen' => '¥', 'pound' => '£', 'euro' => '€'
62
+ }.freeze
63
+
64
+ # The item's own date, in the zone the item carries. Nothing is converted
65
+ # to local time: the same pipeline then produces the same document
66
+ # wherever it runs.
67
+ DATE_FORMAT = '%Y-%m-%d %H:%M:%S %z'
68
+
69
+ UNTITLED = '(untitled)'
70
+
71
+ def initialize(config, pipeline=[])
72
+ @config = config || {}
73
+ @pipeline = pipeline
74
+ @output = $stdout
75
+ @file = @config['file'].nil? ? nil : File.expand_path(@config['file'].to_s)
76
+ @mode = @config['mode'] == 'overwrite' ? 'w' : 'a'
77
+ end
78
+
79
+ def run
80
+ sections = render
81
+ write(sections) unless sections.empty?
82
+ @pipeline
83
+ end
84
+
85
+ private
86
+
87
+ def render
88
+ sections = []
89
+ @pipeline.each {|feeds|
90
+ next if feeds.nil?
91
+
92
+ feeds.items.each {|feed|
93
+ sections << section(feed)
94
+ }
95
+ }
96
+ sections
97
+ end
98
+
99
+ # Heading, then the metadata list, then the body, each separated by a blank
100
+ # line and the whole followed by one. Appending a document to a document is
101
+ # then still a Markdown document.
102
+ def section(feed)
103
+ parts = ["## #{heading(feed)}\n"]
104
+ metadata = metadata_list(feed)
105
+ parts << metadata unless metadata.empty?
106
+ body = body_text(feed)
107
+ parts << body unless body.empty?
108
+ parts.join("\n") + "\n"
109
+ end
110
+
111
+ def heading(feed)
112
+ title = one_line(value(feed, :title))
113
+ return title unless title.empty?
114
+
115
+ link = one_line(value(feed, :link))
116
+ link.empty? ? UNTITLED : link
117
+ end
118
+
119
+ def metadata_list(feed)
120
+ METADATA_FIELDS.map {|label, name|
121
+ text = name == :date ? date(feed) : one_line(value(feed, name))
122
+ next if text.empty?
123
+
124
+ "- #{label}: #{url?(text) ? "<#{text}>" : text}\n"
125
+ }.compact.join
126
+ end
127
+
128
+ # content_encoded when the item has one and description otherwise: a feed
129
+ # carrying both puts the summary in the second and the article in the first.
130
+ def body_text(feed)
131
+ text = value(feed, :content_encoded)
132
+ text = value(feed, :description) if text.empty?
133
+ MARKUP.match?(text) ? normalize(html_to_text(text), true) : normalize(text, false)
134
+ end
135
+
136
+ def date(feed)
137
+ field = feed.respond_to?(:date) ? feed.date : nil
138
+ return '' if field.nil?
139
+
140
+ field.respond_to?(:strftime) ? field.strftime(DATE_FORMAT) : one_line(field.to_s)
141
+ end
142
+
143
+ # Any field may be absent, and a parsed feed answers with an element rather
144
+ # than a string where the format has one: a source carries its text in
145
+ # #content, an enclosure its URL in #url.
146
+ def value(feed, name)
147
+ return '' unless feed.respond_to?(name)
148
+
149
+ field = feed.send(name)
150
+ return '' if field.nil?
151
+
152
+ if field.respond_to?(:content)
153
+ field.content.to_s.strip
154
+ elsif field.respond_to?(:url)
155
+ field.url.to_s.strip
156
+ else
157
+ field.to_s.strip
158
+ end
159
+ end
160
+
161
+ def one_line(text)
162
+ text.gsub(/\s+/, ' ').strip
163
+ end
164
+
165
+ def url?(text)
166
+ text.match?(%r{\A[a-zA-Z][a-zA-Z0-9+.-]*://\S+\z})
167
+ end
168
+
169
+ # Reduce markup to text: script and style go with their contents, a break
170
+ # ends a line, a block element ends a paragraph, entities are decoded, and
171
+ # the tags themselves are dropped.
172
+ #
173
+ # A parser does it where one is installed, and the substitution below does
174
+ # it where none is. That is what keeps this plugin -- the one a Recipe ends
175
+ # with when the result is meant to be read -- runnable on a plain `gem
176
+ # install automatic`: nokogiri is an optional dependency, and reducing a
177
+ # feed body to text is not a good enough reason to make everyone install a
178
+ # native extension.
179
+ # The two agree on what a body is reduced to; a parser is simply better at
180
+ # markup that is malformed. See doc/PLUGINS.md section 6.7.
181
+ def html_to_text(html)
182
+ html_parser? ? parsed_text(html) : substituted_text(html)
183
+ end
184
+
185
+ # Memoized, and false rather than an exception when the gem is absent: this
186
+ # question is asked once per body.
187
+ def html_parser?
188
+ return @html_parser unless @html_parser.nil?
189
+
190
+ @html_parser =
191
+ begin
192
+ require 'nokogiri'
193
+ true
194
+ rescue LoadError
195
+ false
196
+ end
197
+ end
198
+
199
+ def substituted_text(html)
200
+ text = html.gsub(COMMENT_ELEMENT, '')
201
+ text = text.gsub(DISCARDED_ELEMENT, '')
202
+ text = text.gsub(BREAK_ELEMENT, "\n")
203
+ text = text.gsub(BLOCK_ELEMENT, "\n\n")
204
+ unescape(text.gsub(TAG, ''))
205
+ end
206
+
207
+ # One pass, so that "&amp;lt;" decodes to "&lt;" and not to "<", which is
208
+ # what a parser does with it too.
209
+ def unescape(text)
210
+ text.gsub(ENTITY) {|reference|
211
+ name = Regexp.last_match(1)
212
+ name.start_with?('#') ? character(name) || reference
213
+ : ENTITIES.fetch(name, reference)
214
+ }
215
+ end
216
+
217
+ # A numeric character reference, or nil where it names no character: a
218
+ # surrogate, a value past the last code point, or zero.
219
+ def character(name)
220
+ code = name.start_with?('#x', '#X') ? name[2..].to_i(16) : name[1..].to_i
221
+ return nil unless code.positive?
222
+
223
+ begin
224
+ code.chr(Encoding::UTF_8)
225
+ rescue RangeError
226
+ nil
227
+ end
228
+ end
229
+
230
+ def parsed_text(html)
231
+ fragment = Nokogiri::HTML.fragment(html)
232
+ fragment.css('script, style').each {|node| node.remove }
233
+ fragment.css('br').each {|node| node.replace(text_node(node, "\n")) }
234
+ fragment.css(BLOCK_ELEMENTS.join(', ')).each {|node|
235
+ node.add_previous_sibling(text_node(node, "\n"))
236
+ node.add_next_sibling(text_node(node, "\n\n"))
237
+ }
238
+ fragment.text
239
+ end
240
+
241
+ def text_node(node, text)
242
+ Nokogiri::XML::Text.new(text, node.document)
243
+ end
244
+
245
+ # Line endings become "\n", trailing whitespace goes, and a run of blank
246
+ # lines collapses to one. Text that came from markup also loses the
247
+ # source's own indentation, which is layout rather than content and which
248
+ # Markdown would otherwise read as a code block.
249
+ def normalize(text, from_markup)
250
+ lines = text.gsub(/\r\n?/, "\n").split("\n", -1).map {|line|
251
+ from_markup ? line.gsub(/[[:blank:]]+/, ' ').strip : line.rstrip
252
+ }
253
+ normalized = lines.each_with_object([]) {|line, kept|
254
+ next if line.empty? && (kept.empty? || kept.last.empty?)
255
+
256
+ kept << line
257
+ }
258
+ normalized.pop while !normalized.empty? && normalized.last.empty?
259
+ normalized.empty? ? '' : normalized.join("\n") + "\n"
260
+ end
261
+
262
+ # One line per run rather than one per item: this log shares standard
263
+ # output with the document, and the Recipe decides which of them is wanted
264
+ # there. See doc/PLUGINS.md section 6.7.
265
+ def write(sections)
266
+ document = sections.join
267
+ counted = sections.size == 1 ? '1 item' : "#{sections.size} items"
268
+ if @file.nil?
269
+ @output.print(document)
270
+ Automatic::Log.puts('info', "Publish Markdown: #{counted} to standard output")
271
+ else
272
+ FileUtils.mkdir_p(File.dirname(@file))
273
+ File.open(@file, @mode, encoding: 'UTF-8') {|out| out.print(document) }
274
+ Automatic::Log.puts('info', "Publish Markdown: #{counted} to #{@file}")
275
+ end
276
+ end
277
+ end
278
+ end
@@ -1,44 +1,50 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Publish::Memcached
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Jun 25, 2013
5
- # Updated:: Jun 25, 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::Publish::Memcached
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:: Jun 25, 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 PublishMemcached
11
- require 'dalli'
13
+ Automatic.require_optional('dalli', needed_by: 'PublishMemcached')
12
14
 
13
- def initialize(config, pipeline=[])
14
- @config = config
15
+ def initialize(config, pipeline = [])
16
+ @config = config || {}
15
17
  @pipeline = pipeline
16
- @cache = Dalli::Client.new(
17
- @config['host'] + ":" +
18
- @config['port'])
18
+ # Interpolated rather than concatenated: `port: 11211` in a Recipe is an
19
+ # Integer, and String#+ ended the run on it.
20
+ @cache = Dalli::Client.new("#{@config['host']}:#{@config['port']}")
19
21
  end
20
22
 
23
+ # Collects the whole pipeline into one hash keyed by link and stores it
24
+ # under a single key, replacing the previous value.
21
25
  def run
22
- hash = {}
23
- @pipeline.each {|feeds|
24
- unless feeds.nil?
25
- feeds.items.each {|feed|
26
- hash[feed.link] =
27
- {
28
- :title => feed.title,
29
- :description => feed.description,
30
- :content => feed.content_encoded,
31
- :created_at => Time.now.strftime("%Y/%m/%d %X")
32
- }
26
+ @cache.set(@config['key'], collect)
27
+ @pipeline
28
+ rescue StandardError => e
29
+ Automatic::Log.puts('warn', "Skip feed due to fault in put to memcached: #{e.message}")
30
+ @pipeline
31
+ end
32
+
33
+ private
34
+
35
+ def collect
36
+ @pipeline.each_with_object({}) do |feeds, hash|
37
+ next if feeds.nil?
38
+
39
+ feeds.items.each do |feed|
40
+ hash[feed.link] = {
41
+ title: feed.title,
42
+ description: feed.description,
43
+ content: feed.content_encoded,
44
+ created_at: Time.now.strftime('%Y/%m/%d %X')
33
45
  }
34
46
  end
35
- }
36
- begin
37
- @cache.set(@config['key'], hash)
38
- rescue
39
- Automatic::Log.puts("warn", "Skip feed due to fault in put to memcached.")
40
47
  end
41
- @pipeline
42
48
  end
43
49
  end
44
50
  end
@@ -1,69 +1,71 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Store::Database
3
- # Author:: kzgs
4
- # 774 <http://id774.net>
5
- # soramugi <http://soramugi.net>
6
- # Created:: Feb 27, 2012
7
- # Updated:: Oct 09, 2014
8
- # Copyright:: Copyright (c) 2012-2014 Automatic Ruby Developers.
9
- # License:: Licensed under the GNU GENERAL PUBLIC LICENSE, Version 3.0.
2
+ # Name:: Automatic::Plugin::Store::Database
3
+ # Author: kzgs
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 27, 2012
8
+ # Updated:: Aug 17, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
10
+ #
11
+ # The SQLite storage the store plugins share. ActiveRecord and sqlite3 are the
12
+ # store plugins' own dependencies, not the framework's: a Recipe that stores
13
+ # nothing runs without them. See doc/POLICY.md section 9.1.
10
14
 
11
- require 'active_record'
15
+ Automatic.require_optional('active_record',
16
+ gem_name: 'activerecord',
17
+ needed_by: 'the store plugins')
18
+ Automatic.require_optional('sqlite3',
19
+ needed_by: 'the store plugins')
12
20
 
13
21
  module Automatic::Plugin
14
22
  module Database
23
+ # Yields each item that is not already in the table, and returns a pipeline
24
+ # of those items. An item with no link is neither stored nor passed on.
15
25
  def for_each_new_feed
16
26
  prepare_database
17
- existing_records = model_class.all
18
- @return_feeds = []
19
- @pipeline.each {|feeds|
20
- unless feeds.nil?
21
- new_feeds = []
22
- feeds.items.each {|feed|
23
- unless feed.link.nil?
24
- if unique_keys.length > 1
25
- detection = existing_records.detect {|b| b.try(unique_keys[0]) == feed.link || b.try(unique_keys[1]) == feed.title }
26
- else
27
- detection = existing_records.detect {|b| b.try(unique_keys[0]) == feed.link }
28
- end
29
- unless detection
30
- yield(feed)
31
- new_feeds << feed
32
- end
33
- end
34
- }
35
- @return_feeds << Automatic::FeedMaker.create_pipeline(new_feeds) if new_feeds.length > 0
36
- end
37
- }
38
- @return_feeds
27
+
28
+ @pipeline.each_with_object([]) do |feeds, returned|
29
+ next if feeds.nil?
30
+
31
+ new_feeds = feeds.items.reject { |feed| feed.link.nil? || stored?(feed) }
32
+ new_feeds.each { |feed| yield(feed) }
33
+ returned << Automatic::FeedMaker.create_pipeline(new_feeds) unless new_feeds.empty?
34
+ end
39
35
  end
40
36
 
41
37
  private
42
38
 
39
+ # Asked of the database rather than of every row loaded into memory, which
40
+ # is what this did before: a store whose database has grown to a year of
41
+ # links reads one index entry per item now instead of the whole table per
42
+ # run.
43
+ def stored?(feed)
44
+ scope = model_class.where(unique_keys[0] => feed.link)
45
+ scope = scope.or(model_class.where(unique_keys[1] => feed.title)) if unique_keys.length > 1
46
+ scope.exists?
47
+ end
48
+
43
49
  def create_table
44
- ActiveRecord::Migration.create_table(model_class.table_name) {|t|
45
- column_definition.each_pair {|column_name, column_type|
46
- t.column column_name, column_type
47
- }
48
- }
50
+ ActiveRecord::Base.connection.create_table(model_class.table_name) do |table|
51
+ column_definition.each_pair do |name, type|
52
+ table.column name, type
53
+ end
54
+ end
49
55
  end
50
56
 
51
57
  def db_dir
52
- dir = (File.expand_path('~/.automatic/db'))
53
- if File.directory?(dir)
54
- dir
55
- else
56
- File.join(File.dirname(__FILE__), '..', '..', 'db')
57
- end
58
+ dir = File.expand_path('~/.automatic/db')
59
+ return dir if File.directory?(dir)
60
+
61
+ File.expand_path('../../db', __dir__)
58
62
  end
59
63
 
60
64
  def prepare_database
61
- db = File.join(db_dir, @config['db'])
62
- Automatic::Log.puts("info", "Using Database: #{db}")
63
- ActiveRecord::Base.establish_connection(
64
- :adapter => "sqlite3",
65
- :database => db)
66
- create_table unless model_class.table_exists?
65
+ db = File.join(db_dir, @config['db'].to_s)
66
+ Automatic::Log.puts('info', "Using Database: #{db}")
67
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: db)
68
+ create_table unless ActiveRecord::Base.connection.table_exists?(model_class.table_name)
67
69
  end
68
70
  end
69
71
  end