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
@@ -1,22 +1,23 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::FeedMaker
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Feb 21, 2014
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::FeedMaker
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 21, 2014
8
+ # Updated:: Sep 7, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
8
10
 
9
11
  module Automatic
10
12
  module FeedMaker
11
13
  require 'rss'
12
14
  require 'uri'
13
- require 'nokogiri'
14
15
 
15
16
  class FeedObject
16
17
  attr_accessor :title, :link, :description, :author, :comments
17
18
  def initialize
18
- @link = 'http://dummy'
19
- @title = 'dummy'
19
+ @link = nil
20
+ @title = nil
20
21
  @description = ''
21
22
  @author = ''
22
23
  @comments = ''
@@ -33,30 +34,36 @@ module Automatic
33
34
  feed_object
34
35
  end
35
36
 
37
+ # Plain-value standard fields, copied onto the rebuilt item as-is when the
38
+ # item being rebuilt carries them. See doc/REQUIREMENTS.md and
39
+ # doc/PLUGINS.md for the standard item field contract this preserves.
40
+ REBUILD_SIMPLE_FIELDS = %i[title link description author comments content_encoded].freeze
41
+
42
+ # source and enclosure are RSS child elements. RSS::Maker exposes each of
43
+ # them on a new item as a builder with its own sub-attributes rather than
44
+ # accepting the finished element directly, so each is rebuilt attribute by
45
+ # attribute instead of by one assignment.
46
+ REBUILD_STRUCTURED_FIELDS = {
47
+ source: %i[url content],
48
+ enclosure: %i[url length type]
49
+ }.freeze
50
+
36
51
  def self.create_pipeline(feeds = [])
37
52
  RSS::Maker.make("2.0") {|maker|
38
53
  xss = maker.xml_stylesheets.new_xml_stylesheet
39
54
  maker.channel.title = "Automatic Ruby"
40
55
  maker.channel.description = "Automatic::FeedMaker"
41
- maker.channel.link = "https://github.com/automaticruby/automaticruby"
56
+ maker.channel.link = "https://github.com/id774/automaticruby"
42
57
  maker.items.do_sort = true
43
58
 
44
59
  unless feeds.nil?
45
60
  feeds.each {|feed|
46
- unless feed.link.nil?
47
- Automatic::Log.puts("info", "Create Pipeline: #{feed.link}")
48
- item = maker.items.new_item
49
- item.title = feed.title
50
- item.link = feed.link
51
- begin
52
- item.description = feed.description
53
- item.author = feed.author
54
- item.comments = feed.comments
55
- item.date = feed.pubDate || Time.now
56
- rescue NoMethodError
57
- Automatic::Log.puts("warn", "Undefined field detected in feed.")
58
- end
59
- end
61
+ Automatic::Log.puts("info", "Create Pipeline: #{feed.link}")
62
+ item = maker.items.new_item
63
+ item.date = (feed.pubDate if feed.respond_to?(:pubDate)) || Time.now
64
+
65
+ REBUILD_SIMPLE_FIELDS.each { |field| copy_rebuild_field(feed, item, field) }
66
+ REBUILD_STRUCTURED_FIELDS.each_key { |field| copy_rebuild_structured_field(feed, item, field) }
60
67
  }
61
68
  end
62
69
  }
@@ -67,7 +74,7 @@ module Automatic
67
74
  xss = maker.xml_stylesheets.new_xml_stylesheet
68
75
  maker.channel.title = "Automatic Ruby"
69
76
  maker.channel.description = "Automatic::FeedMaker"
70
- maker.channel.link = "https://github.com/automaticruby/automaticruby"
77
+ maker.channel.link = "https://github.com/id774/automaticruby"
71
78
  maker.items.do_sort = true
72
79
  item = maker.items.new_item
73
80
  item.title = "Automatic Ruby"
@@ -76,5 +83,38 @@ module Automatic
76
83
  item.date = Time.now
77
84
  }
78
85
  end
86
+
87
+ # A field the item being rebuilt does not carry, or carries as nil, is
88
+ # left off the new item: that is the normal shape of an optional field,
89
+ # not a reason to skip copying anything else. See doc/POLICY.md on
90
+ # distinguishing a missing optional value from a programming error.
91
+ def self.copy_rebuild_field(feed, item, field)
92
+ return unless feed.respond_to?(field)
93
+
94
+ value = feed.public_send(field)
95
+ return if value.nil?
96
+
97
+ item.public_send("#{field}=", value)
98
+ end
99
+ private_class_method :copy_rebuild_field
100
+
101
+ # source and enclosure are rebuilt sub-attribute by sub-attribute, on the
102
+ # same missing-is-normal basis as copy_rebuild_field, so that (for
103
+ # example) an enclosure with no type recorded still keeps its url.
104
+ def self.copy_rebuild_structured_field(feed, item, field)
105
+ return unless feed.respond_to?(field)
106
+
107
+ value = feed.public_send(field)
108
+ return if value.nil?
109
+
110
+ target = item.public_send(field)
111
+ REBUILD_STRUCTURED_FIELDS.fetch(field).each do |sub_field|
112
+ next unless value.respond_to?(sub_field)
113
+
114
+ sub_value = value.public_send(sub_field)
115
+ target.public_send("#{sub_field}=", sub_value) unless sub_value.nil?
116
+ end
117
+ end
118
+ private_class_method :copy_rebuild_structured_field
79
119
  end
80
120
  end
@@ -1,51 +1,67 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::FeedParser
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Feb 19, 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::FeedParser
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 19, 2012
8
+ # Updated:: Aug 14, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
10
+ #
11
+ # Fetching and parsing, on the way into the pipeline. What leaves here has the
12
+ # shape described in doc/PLUGINS.md section 3.4.
8
13
 
9
14
  module Automatic
10
15
  module FeedParser
11
16
  require 'rss'
12
- require 'uri'
13
- require 'nokogiri'
17
+ require 'automatic/http'
14
18
 
19
+ # Fetch a URL and parse it as a feed. Validation is off, because feeds in
20
+ # the wild frequently are not valid and are still readable.
21
+ #
22
+ # Fetching goes through Automatic::Http, which is where the scheme
23
+ # allowlist, the timeouts and the redirect limit live.
15
24
  def self.get_url(url)
16
- begin
17
- unless url.nil?
18
- Automatic::Log.puts("info", "Parsing Feed: #{url}")
19
- feed = URI.parse(url).normalize
20
- open(feed) {|http|
21
- response = http.read
22
- RSS::Parser.parse(response, false)
23
- }
24
- end
25
- rescue => e
26
- raise e
27
- end
25
+ return if url.nil?
26
+
27
+ Automatic::Log.puts('info', "Parsing Feed: #{url}")
28
+ RSS::Parser.parse(Automatic::Http.read(url), false)
28
29
  end
29
30
 
31
+ # Build a feed whose items are the links of an HTML document. This is how
32
+ # a page that publishes no feed enters the pipeline.
33
+ #
34
+ # nokogiri is required here rather than at the top of the file: it is the
35
+ # only thing in the framework that wants an HTML parser, it is an optional
36
+ # dependency rather than a runtime one, and requiring `automatic` must
37
+ # neither load one nor need one installed. Only the plugins that call this
38
+ # method -- SubscriptionLink and SubscriptionTumblr -- do.
39
+ # See doc/POLICY.md sections 2.5 and 9.1.
30
40
  def self.parse_html(html)
31
- RSS::Maker.make("2.0") {|maker|
32
- xss = maker.xml_stylesheets.new_xml_stylesheet
33
- maker.channel.title = "Automatic Ruby"
34
- maker.channel.description = "Automatic::FeedParser"
35
- maker.channel.link = "https://github.com/automaticruby/automaticruby"
41
+ Automatic.require_optional(
42
+ 'nokogiri',
43
+ needed_by: 'Automatic::FeedParser.parse_html, used by SubscriptionLink ' \
44
+ 'and SubscriptionTumblr'
45
+ )
46
+
47
+ RSS::Maker.make('2.0') do |maker|
48
+ maker.xml_stylesheets.new_xml_stylesheet
49
+ maker.channel.title = 'Automatic Ruby'
50
+ maker.channel.description = 'Automatic::FeedParser'
51
+ maker.channel.link = 'https://github.com/id774/automaticruby'
36
52
  maker.items.do_sort = true
37
53
 
38
54
  doc = Nokogiri::HTML(html)
39
- (doc/:a).each {|link|
40
- unless link[:href].nil?
41
- item = maker.items.new_item
42
- item.title = "Automatic Ruby"
43
- item.link = link[:href]
44
- item.date = Time.now
45
- item.description = ""
46
- end
47
- }
48
- }
55
+ (doc / :a).each do |link|
56
+ next if link[:href].nil?
57
+
58
+ item = maker.items.new_item
59
+ item.title = 'Automatic Ruby'
60
+ item.link = link[:href]
61
+ item.date = Time.now
62
+ item.description = ''
63
+ end
64
+ end
49
65
  end
50
66
  end
51
67
  end
@@ -0,0 +1,107 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Http
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:: Aug 15, 2026
8
+ # Updated:: Aug 21, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
10
+ #
11
+ # One way in for everything the plugins fetch over HTTP.
12
+ #
13
+ # This exists because seven plugins were each calling `URI.open` on a string,
14
+ # and the four decisions that call implies -- which schemes are allowed, how
15
+ # long to wait, how many redirects to follow, what to send as a User-Agent --
16
+ # were being made seven times, mostly by omission. They are made once here.
17
+ # It is a helper, not a client framework: one method fetches, one builds a URI,
18
+ # and a plugin that wants something else still calls Ruby directly.
19
+ # See doc/POLICY.md section 9.2 and doc/PLUGINS.md section 3.8.
20
+
21
+ require 'open-uri'
22
+ require 'uri'
23
+ require 'automatic/version'
24
+
25
+ module Automatic
26
+ module Http
27
+ # A link in a pipeline item comes from a feed, which is to say from
28
+ # outside. `URI.open` on such a string will happily read `file:///etc/passwd`
29
+ # or run an FTP session; a plugin fetching an article body wants neither.
30
+ SCHEMES = %w[http https].freeze
31
+
32
+ OPEN_TIMEOUT = 10
33
+ READ_TIMEOUT = 30
34
+
35
+ # open-uri follows redirects itself and refuses an HTTPS-to-HTTP downgrade.
36
+ # This bounds the chain so that a redirect loop ends as an error rather
37
+ # than as an unattended run that never returns.
38
+ REDIRECT_LIMIT = 5
39
+
40
+ USER_AGENT = "Automatic Ruby/#{Automatic::VERSION} " \
41
+ '(+https://github.com/id774/automaticruby)'
42
+
43
+ # The RFC 2396 parser is named directly: `URI.escape` was removed in Ruby
44
+ # 3.0, and `URI::Parser` became the RFC 3986 parser in 3.4, which reports
45
+ # #escape as obsolete. This spelling means the same thing on every
46
+ # supported Ruby.
47
+ ESCAPER = URI::RFC2396_Parser.new
48
+
49
+ class << self
50
+ # Fetch a URL and return its body as a string. Raises rather than
51
+ # returning nil: a plugin's retry handling is built around an exception,
52
+ # and a body that could not be fetched is not an empty body.
53
+ def read(url)
54
+ open(url, &:read)
55
+ end
56
+
57
+ # Fetch a URL and yield the IO, for a caller that would rather stream
58
+ # than hold the whole body.
59
+ def open(url, &block)
60
+ uri(url).open(
61
+ 'User-Agent' => USER_AGENT,
62
+ open_timeout: OPEN_TIMEOUT,
63
+ read_timeout: READ_TIMEOUT,
64
+ redirect: true,
65
+ max_redirects: REDIRECT_LIMIT,
66
+ &block
67
+ )
68
+ end
69
+
70
+ # Parse a URL into a URI this framework will fetch, or raise. A string
71
+ # carrying characters a URI may not (a space, a Japanese query term) is
72
+ # escaped and parsed again, which is what the plugins used to do for
73
+ # themselves before every call.
74
+ def uri(url)
75
+ string = url.to_s.strip
76
+ raise ArgumentError, 'no URL to fetch' if string.empty?
77
+
78
+ parsed = parse(string)
79
+ unless SCHEMES.include?(parsed.scheme)
80
+ raise ArgumentError, "not an HTTP or HTTPS URL: #{string}"
81
+ end
82
+ unless parsed.host
83
+ raise ArgumentError, "HTTP or HTTPS URL has no host: #{string}"
84
+ end
85
+
86
+ parsed.normalize
87
+ end
88
+
89
+ # Whether a string is a URL this framework will fetch. For a plugin that
90
+ # skips an item rather than failing the run on one.
91
+ def fetchable?(url)
92
+ uri(url)
93
+ true
94
+ rescue ArgumentError, URI::InvalidURIError
95
+ false
96
+ end
97
+
98
+ private
99
+
100
+ def parse(string)
101
+ URI.parse(string)
102
+ rescue URI::InvalidURIError
103
+ URI.parse(ESCAPER.escape(string))
104
+ end
105
+ end
106
+ end
107
+ end
data/lib/automatic/log.rb CHANGED
@@ -1,31 +1,62 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Log
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Feb 20, 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::Log
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 20, 2012
8
+ # Updated:: Aug 14, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
10
+ #
11
+ # One log, to standard output, behind a level filter set per Recipe by
12
+ # global.log.level. See doc/BASIC_DESIGN.md section 4.7.
8
13
 
9
14
  require 'logger'
10
15
 
11
16
  module Automatic
12
17
  module Log
13
- LOG_LEVELS = ['info', 'warn', 'error', 'none']
18
+ # In increasing order of severity. 'none' is the threshold that admits
19
+ # nothing; it is never used as the level of a message.
20
+ LOG_LEVELS = %w[info warn error none].freeze
14
21
 
15
- def self.level(level)
16
- @level = level
17
- end
22
+ DEFAULT_LEVEL = 'info'
18
23
 
19
- def self.logger
20
- @logger ||= Logger.new(STDOUT)
21
- end
24
+ class << self
25
+ # Set the threshold. An unrecognised name, including nil, means the
26
+ # default: an unattended run should not be silenced by a typo in a
27
+ # Recipe, nor should it raise in the middle of a job.
28
+ def level(level = nil)
29
+ @level = normalize(level, DEFAULT_LEVEL)
30
+ end
22
31
 
23
- def self.puts(level = :info, message)
24
- if LOG_LEVELS.index(@level).to_i > LOG_LEVELS.index(level).to_i
25
- return
32
+ def logger
33
+ @logger ||= Logger.new($stdout)
26
34
  end
27
- logger.send(level, message)
28
- end
29
35
 
36
+ # Replace the logger. Used by the specs; a plugin has no business
37
+ # calling this.
38
+ attr_writer :logger
39
+
40
+ # Emit a message at the given level, if the threshold admits it.
41
+ # Both 'info' and :info are accepted, because plugins pass both.
42
+ def puts(level, message)
43
+ name = normalize(level, DEFAULT_LEVEL)
44
+ return if name == 'none'
45
+ return if LOG_LEVELS.index(name) < LOG_LEVELS.index(current_level)
46
+
47
+ logger.public_send(name, message)
48
+ end
49
+
50
+ private
51
+
52
+ def current_level
53
+ @level ||= DEFAULT_LEVEL
54
+ end
55
+
56
+ def normalize(level, fallback)
57
+ name = level.to_s
58
+ LOG_LEVELS.include?(name) ? name : fallback
59
+ end
60
+ end
30
61
  end
31
62
  end
@@ -220,7 +220,9 @@ module Automatic
220
220
  end # each_outline
221
221
 
222
222
  def read_text
223
- text = ""
223
+ # +'' rather than "": this string is appended to below, and a literal
224
+ # is on its way to being frozen.
225
+ text = +''
224
226
  while event = @p.pull
225
227
  case event.event_type
226
228
  when :end_element
@@ -1,43 +1,84 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Pipeline
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Feb 22, 2012
5
- # Updated:: Mar 16, 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::Pipeline
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 22, 2012
8
+ # Updated:: Sep 6, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
10
+ #
11
+ # The core: resolve a plugin's class name to a file, then run the Recipe's
12
+ # plugins in order, each receiving the previous one's output. See
13
+ # doc/BASIC_DESIGN.md section 4.6 and doc/PLUGINS.md section 3.
14
+ #
15
+ # Every module the Recipe names is discovered before any plugin runs, so that
16
+ # an unknown module later in the Recipe is refused before an earlier plugin's
17
+ # side effect, not after it. Discovery only registers an autoload; it does not
18
+ # read a plugin's own source, which stays lazy until that plugin's turn to
19
+ # run. See doc/PLUGINS.md on unknown plugin being refused before any plugin
20
+ # runs.
8
21
 
9
- require 'active_support'
10
- require 'active_support/core_ext'
11
- require 'active_support/deprecation'
22
+ require 'active_support/core_ext/string/inflections'
12
23
 
13
24
  module Automatic
14
- module Plugin end
15
- class NoPluginError < StandardError; end
16
- class NoRecipeError < StandardError; end
25
+ # The namespace every plugin class lives in. Plugin files are registered
26
+ # here with autoload, so a file is read when its constant is first used.
27
+ module Plugin; end
17
28
 
18
29
  module Pipeline
19
- def self.load_plugin(module_name)
20
- Dir[Automatic.user_plugins_dir + "/*",
21
- Automatic.plugins_dir + "/*"].each {|dir|
22
- subdir = File.basename dir
23
- if /#{subdir}_(.*)$/ =~ module_name.underscore
24
- path = dir + "/#{$1}.rb"
25
- return Automatic::Plugin.autoload module_name.to_sym,
26
- path.to_s if File.exists? path
30
+ class << self
31
+ # Resolve a module name such as SubscriptionFeed to
32
+ # <search root>/subscription/feed.rb and register it for autoloading.
33
+ #
34
+ # The user directory is searched before the installation, so a plugin in
35
+ # ~/.automatic/plugins shadows a shipped plugin of the same name.
36
+ def load_plugin(module_name)
37
+ underscored = module_name.underscore
38
+
39
+ search_roots.each do |dir|
40
+ category = File.basename(dir)
41
+ next unless /\A#{Regexp.escape(category)}_(.*)\z/ =~ underscored
42
+
43
+ path = File.join(dir, "#{Regexp.last_match(1)}.rb")
44
+ next unless File.exist?(path)
45
+
46
+ return Automatic::Plugin.autoload(module_name.to_sym, path)
27
47
  end
28
- }
29
- raise NoPluginError, "unknown plugin named #{module_name}"
30
- end
31
48
 
32
- def self.run(recipe)
33
- raise NoRecipeError if recipe.nil?
34
- pipeline = []
35
- recipe.each_plugin {|plugin|
36
- mod = plugin.module
37
- load_plugin(mod)
38
- klass = Automatic::Plugin.const_get(mod)
39
- pipeline = klass.new(plugin.config, pipeline).run
40
- }
49
+ raise NoPluginError, "unknown plugin named #{module_name}"
50
+ end
51
+
52
+ # Run a Recipe: each plugin's return value is the next plugin's input.
53
+ #
54
+ # Nothing here rescues a plugin's exception. A Recipe is a sequence in
55
+ # which each step consumes the previous one's output, so continuing past
56
+ # a failed step would run the rest on a value their author never
57
+ # intended. See doc/REQUIREMENTS.md section 12.
58
+ def run(recipe)
59
+ raise NoRecipeError, 'no recipe given' if recipe.nil?
60
+
61
+ entries = []
62
+ recipe.each_plugin { |plugin| entries << [plugin, plugin.module] }
63
+
64
+ entries.each { |_plugin, mod| load_plugin(mod) }
65
+
66
+ pipeline = []
67
+ entries.each do |plugin, mod|
68
+ klass = Automatic::Plugin.const_get(mod)
69
+ pipeline = klass.new(plugin.config, pipeline).run
70
+ end
71
+ pipeline
72
+ end
73
+
74
+ private
75
+
76
+ def search_roots
77
+ Dir[File.join(Automatic.user_plugins_dir, '*'),
78
+ File.join(Automatic.plugins_dir, '*')].select { |path|
79
+ File.directory?(path)
80
+ }
81
+ end
41
82
  end
42
83
  end
43
84
  end