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
@@ -1,55 +1,96 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::CustomFeed::SVNLog
3
- # Author:: kzgs
4
- # Created:: Feb 29, 2012
5
- # Updated:: Mar 3, 2012
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::CustomFeed::SVNLog
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 29, 2012
8
+ # Updated:: Aug 15, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
8
10
 
11
+ require 'rexml/document'
9
12
  require 'rss/maker'
13
+ require 'time'
10
14
 
11
15
  module Automatic::Plugin
12
16
  class CustomFeedSVNLog
13
- require 'xmlsimple'
17
+ DEFAULT_FETCH_ITEMS = 30
14
18
 
15
- def initialize(config, pipeline=[])
16
- @config = config
19
+ def initialize(config, pipeline = [])
20
+ @config = config || {}
17
21
  @pipeline = pipeline
18
22
  end
19
23
 
20
24
  def run
21
- revisions = XmlSimple.xml_in(`svn log #{svn_log_argument}`)["logentry"]
22
- @pipeline << RSS::Maker.make("1.0") {|maker|
23
- maker.channel.title = @config["title"] || ""
24
- maker.channel.about = ""
25
- maker.channel.description = ""
26
- maker.channel.link = base_url
27
- revisions.each {|rev|
28
- item = maker.items.new_item
29
- item.title = "#{rev["msg"]} by #{rev["author"]}"
30
- item.link = base_url+"/!svn/bc/#{rev["revision"]}"
31
- item.date = Time.parse(rev["date"][0])
32
- }
33
- }
34
- return @pipeline
25
+ entries = revisions
26
+ if entries.empty?
27
+ # RSS 1.0 has no representation for a channel with no items, and a
28
+ # repository with no revisions in the window asked for is an ordinary
29
+ # answer rather than a failure.
30
+ Automatic::Log.puts('warn', "No revisions returned by svn log for #{base_url}")
31
+ return @pipeline
32
+ end
33
+
34
+ @pipeline << feed(entries)
35
+ @pipeline
35
36
  end
36
37
 
37
38
  private
38
39
 
39
- def base_url
40
- return @config['target'].gsub(/\/$/, "")
40
+ def feed(revisions)
41
+ RSS::Maker.make('1.0') do |maker|
42
+ maker.channel.title = @config['title'].to_s
43
+ maker.channel.about = ''
44
+ maker.channel.description = ''
45
+ maker.channel.link = base_url
46
+
47
+ revisions.each do |revision|
48
+ item = maker.items.new_item
49
+ item.title = "#{revision['msg']} by #{revision['author']}"
50
+ item.link = "#{base_url}/!svn/bc/#{revision['revision']}"
51
+ item.date = Time.parse(revision['date'])
52
+ end
53
+ end
54
+ end
55
+
56
+ # `svn log --xml` as REXML sees it. REXML is a runtime dependency of this
57
+ # framework already -- the OPML parser uses it -- so this plugin needs the
58
+ # svn command and no gem of its own.
59
+ def revisions
60
+ REXML::Document.new(svn_log).elements.to_a('/log/logentry').map do |entry|
61
+ {
62
+ 'revision' => entry.attributes['revision'].to_s,
63
+ 'author' => text(entry, 'author'),
64
+ 'msg' => text(entry, 'msg'),
65
+ 'date' => text(entry, 'date')
66
+ }
67
+ end
41
68
  end
42
69
 
43
- def svn_log_argument
44
- return [
45
- base_url,
46
- "--xml",
47
- "--limit=#{limit}"
48
- ].join(" ")
70
+ def text(entry, name)
71
+ element = entry.elements[name]
72
+ element.nil? ? '' : element.text.to_s
73
+ end
74
+
75
+ # The command is run as an argument vector rather than through a shell, so
76
+ # a repository URL cannot become part of a command line. Point `target` at
77
+ # a repository you control regardless: svn itself will do what the URL
78
+ # tells it to.
79
+ def svn_log
80
+ output = IO.popen(['svn', 'log', base_url, '--xml', "--limit=#{limit}"],
81
+ err: File::NULL, &:read)
82
+ raise "svn log failed for #{base_url}" unless $?.success?
83
+
84
+ output
85
+ end
86
+
87
+ def base_url
88
+ @base_url ||= @config['target'].to_s.sub(%r{/\z}, '')
49
89
  end
50
90
 
51
91
  def limit
52
- return @config['fetch_items'] || 30
92
+ value = @config['fetch_items'].to_i
93
+ value.positive? ? value : DEFAULT_FETCH_ITEMS
53
94
  end
54
95
  end
55
96
  end
@@ -0,0 +1,348 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Plugin::CustomFeed::Web
3
+ # Description:: Build a feed from the article links of an HTML index page.
4
+ # Author: id774 (More info: http://id774.net)
5
+ # Source Code:: https://github.com/id774/automaticruby
6
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
7
+ # Contact:: idnanashi@gmail.com
8
+ # Created:: Aug 17, 2026
9
+ # Updated:: Aug 17, 2026
10
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
11
+ #
12
+ # One page in, one feed out. The pages named in the Recipe are fetched, their
13
+ # article links are taken with CSS selectors, and each page becomes a feed of
14
+ # what it currently lists. Nothing else is fetched: the links are not
15
+ # followed, no article body is read, and no page beyond the ones named is
16
+ # visited, so a run costs one request per site.
17
+ #
18
+ # It keeps no state of its own, deliberately. What was published last time is
19
+ # StorePermalink's question, and the Recipe that answers it puts StorePermalink
20
+ # after this plugin. See doc/PLUGINS.md section 6.2.
21
+
22
+ require 'rss/maker'
23
+ require 'set'
24
+ require 'time'
25
+ require 'uri'
26
+
27
+ module Automatic::Plugin
28
+ class CustomFeedWeb
29
+ Automatic.require_optional('nokogiri', needed_by: 'CustomFeedWeb')
30
+
31
+ DEFAULT_FETCH_ITEMS = 100
32
+
33
+ # What an article link is, where the Recipe does not say. Every mode ends
34
+ # at an `<a href>`, because a permalink is what a feed item needs.
35
+ DEFAULT_LINK_SELECTOR = 'a[href]'
36
+
37
+ # A link in a page is written by whoever wrote the page. `mailto:`,
38
+ # `javascript:` and `file:` links are ordinary in a navigation bar and are
39
+ # not articles.
40
+ SCHEMES = %w[http https].freeze
41
+
42
+ # One article as the page presents it, before it becomes an item. Internal
43
+ # to this plugin: what leaves here is the pipeline value of section 3.4.
44
+ Candidate = Struct.new(:title, :url, :description, :date, keyword_init: true)
45
+
46
+ def initialize(config, pipeline = [])
47
+ @config = config || {}
48
+ @pipeline = pipeline
49
+ @seen = Set.new
50
+ end
51
+
52
+ def run
53
+ sites.each do |site|
54
+ feed = feed_for(site)
55
+ @pipeline << feed unless feed.nil?
56
+ end
57
+ @pipeline
58
+ end
59
+
60
+ private
61
+
62
+ # Every site, checked before anything is fetched.
63
+ #
64
+ # A Recipe this plugin cannot carry out -- a site that is not a mapping, a
65
+ # URL that cannot be fetched, a pattern that is not a regular expression,
66
+ # a selector combination that names no article -- is the operator's
67
+ # mistake, and it will be the same mistake after a retry and a wait. It is
68
+ # refused here, where no request has been made yet, rather than inside the
69
+ # retry loop, which is for a host that did not answer.
70
+ def sites
71
+ @sites ||= Array(@config['sites']).each { |site| validate(site) }
72
+ end
73
+
74
+ def validate(site)
75
+ unless site.is_a?(Hash)
76
+ raise ArgumentError,
77
+ 'CustomFeedWeb takes a mapping with a url key for each site, ' \
78
+ "not #{site.inspect}"
79
+ end
80
+
81
+ source(site)
82
+ patterns(site, 'include')
83
+ patterns(site, 'exclude')
84
+ article_unit(site)
85
+ end
86
+
87
+ # The page URL as this plugin will fetch and resolve against it.
88
+ # Automatic::Http is what says which URLs those are.
89
+ def source(site)
90
+ Automatic::Http.uri(site['url'])
91
+ rescue ArgumentError, URI::InvalidURIError => e
92
+ raise ArgumentError, "CustomFeedWeb needs a url for each site: #{e.message}"
93
+ end
94
+
95
+ def patterns(site, key)
96
+ Array(site[key]).map { |pattern| Regexp.new(pattern.to_s) }
97
+ rescue RegexpError => e
98
+ raise ArgumentError,
99
+ "CustomFeedWeb was given an invalid #{key} pattern for " \
100
+ "#{site['url']}: #{e.message}"
101
+ end
102
+
103
+ # `title_selector`, `description_selector` and `date_selector` are read
104
+ # inside one article's node, and `item_selector` is what says where an
105
+ # article begins and ends. Without it there is no node to read them in,
106
+ # and a page of anchors cannot be divided into articles by guessing.
107
+ def article_unit(site)
108
+ return if presence(site['item_selector'])
109
+
110
+ named = %w[title_selector description_selector date_selector].
111
+ select { |key| presence(site[key]) }
112
+ return if named.empty?
113
+
114
+ raise ArgumentError,
115
+ "CustomFeedWeb needs item_selector to use #{named.join(', ')}: " \
116
+ "#{site['url']}"
117
+ end
118
+
119
+ def feed_for(site)
120
+ base = source(site)
121
+ document = fetch(site)
122
+ return nil if document.nil?
123
+
124
+ articles = entries(document, site, base)
125
+ if articles.empty?
126
+ # A list page with nothing on it that this Recipe recognises is an
127
+ # ordinary answer rather than a failure, and an empty feed would only
128
+ # give the rest of the pipeline something to skip.
129
+ Automatic::Log.puts('warn', "No article links found on #{base}")
130
+ return nil
131
+ end
132
+
133
+ Automatic::Log.puts('info', "Web feed: #{articles.size} items from #{base}")
134
+ feed(site, document, base, articles)
135
+ end
136
+
137
+ # The page, or nil where it could not be read after its retries. One site
138
+ # that is down does not take the rest of the Recipe with it.
139
+ #
140
+ # The parser is given the stream rather than a decoded string, so that it
141
+ # reads the document's own meta charset instead of the encoding open-uri
142
+ # settled on. See doc/PLUGINS.md section 3.8.1.
143
+ def fetch(site)
144
+ url = site['url']
145
+ retries = 0
146
+ retry_max = @config['retry'].to_i
147
+ begin
148
+ Automatic::Log.puts('info', "Parsing Web page: #{url}")
149
+ document = Automatic::Http.open(url) { |io| Nokogiri::HTML(io) }
150
+ sleep(@config['interval'].to_i)
151
+ document
152
+ rescue StandardError => e
153
+ retries += 1
154
+ Automatic::Log.puts('error',
155
+ "ErrorCount: #{retries}, Fault in fetching: #{url}, #{e.message}")
156
+ if retries > retry_max
157
+ Automatic::Log.puts('warn', "Skipping #{url}")
158
+ return nil
159
+ end
160
+
161
+ sleep(@config['interval'].to_i)
162
+ retry
163
+ end
164
+ end
165
+
166
+ # The candidates that survive, in the order the page lists them, up to
167
+ # `fetch_items`. A list page's own order is the only ordering information
168
+ # it carries, so nothing here sorts.
169
+ def entries(document, site, base)
170
+ includes = patterns(site, 'include')
171
+ excludes = patterns(site, 'exclude')
172
+ limit = fetch_items(site)
173
+
174
+ candidates(document, site).each_with_object([]) do |candidate, kept|
175
+ next if candidate.title.empty?
176
+
177
+ url = permalink(candidate.url, base, site, includes, excludes)
178
+ next if url.nil?
179
+
180
+ candidate.url = url
181
+ kept << candidate
182
+ break kept if kept.size >= limit
183
+ end
184
+ end
185
+
186
+ # The three ways a page is read, and the only three: every anchor, the
187
+ # anchors a selector names, or an article node with its parts inside it.
188
+ def candidates(document, site)
189
+ item_selector = presence(site['item_selector'])
190
+ return anchors(document, site) if item_selector.nil?
191
+
192
+ document.css(item_selector).filter_map { |node| article(node, site) }
193
+ end
194
+
195
+ def anchors(document, site)
196
+ selector = presence(site['link_selector']) || DEFAULT_LINK_SELECTOR
197
+ document.css(selector).filter_map { |node|
198
+ next if node['href'].nil?
199
+
200
+ Candidate.new(title: normalize(node.text), url: node['href'], description: '')
201
+ }
202
+ end
203
+
204
+ def article(node, site)
205
+ link = node.at_css(presence(site['link_selector']) || DEFAULT_LINK_SELECTOR)
206
+ return nil if link.nil? || link['href'].nil?
207
+
208
+ Candidate.new(
209
+ title: title(node, site, link),
210
+ url: link['href'],
211
+ description: description(node, site),
212
+ date: date(node, site)
213
+ )
214
+ end
215
+
216
+ # A `title_selector` that selects nothing in this particular article falls
217
+ # back to the link's own text rather than dropping the article: one entry
218
+ # laid out differently from the rest of a list is ordinary.
219
+ def title(node, site, link)
220
+ selector = presence(site['title_selector'])
221
+ found = selector.nil? ? nil : node.at_css(selector)
222
+ normalize((found || link).text)
223
+ end
224
+
225
+ # The summary the list page prints, as text. Not the article: this plugin
226
+ # does not fetch one.
227
+ def description(node, site)
228
+ selector = presence(site['description_selector'])
229
+ return '' if selector.nil?
230
+
231
+ found = node.at_css(selector)
232
+ found.nil? ? '' : normalize(found.text)
233
+ end
234
+
235
+ # `<time datetime="2026-08-17T09:00:00+09:00">yesterday</time>` is the
236
+ # reason the attribute is preferred: it is written for a machine, and the
237
+ # text beside it is written for a reader.
238
+ def date(node, site)
239
+ selector = presence(site['date_selector'])
240
+ return nil if selector.nil?
241
+
242
+ found = node.at_css(selector)
243
+ return nil if found.nil?
244
+
245
+ published(found.name == 'time' && found['datetime'] ? found['datetime'] : found.text)
246
+ end
247
+
248
+ # A date that cannot be read costs the article its date and not its place
249
+ # in the feed. Nothing is substituted for it: the time this ran is when
250
+ # the page was fetched, which is not when the article was published.
251
+ def published(value)
252
+ Time.parse(value.to_s)
253
+ rescue ArgumentError => e
254
+ Automatic::Log.puts('warn', "Unreadable date #{value.to_s.strip.inspect}: #{e.message}")
255
+ nil
256
+ end
257
+
258
+ # The judgements a candidate URL passes, in this order: it is resolved
259
+ # against the page it was found on, then it is one this framework fetches,
260
+ # then it is not the page itself, then the host, then include, then
261
+ # exclude, and only what has survived all of them is recorded as seen.
262
+ def permalink(href, base, site, includes, excludes)
263
+ uri = absolute(href, base)
264
+ return nil if uri.nil? || !SCHEMES.include?(uri.scheme)
265
+
266
+ url = uri.to_s
267
+ return nil if url == base.to_s
268
+ return nil if same_host?(site) && !same_host_as?(uri, base)
269
+ return nil if includes.any? && includes.none? { |pattern| pattern.match?(url) }
270
+ return nil if excludes.any? { |pattern| pattern.match?(url) }
271
+
272
+ # Within one run only. Whether a URL was published last week is
273
+ # StorePermalink's record, not this plugin's.
274
+ @seen.add?(url).nil? ? nil : url
275
+ end
276
+
277
+ # Resolved against the page it was found on, so that `/articles/42`,
278
+ # `../42` and `//example.com/42` all become the URL a reader would follow.
279
+ # The fragment goes, because two links differing only in their anchor are
280
+ # one article. The query string stays, because `?id=42` is frequently the
281
+ # whole of what identifies one, and no canonical form is guessed.
282
+ def absolute(href, base)
283
+ uri = base.merge(href.to_s.strip)
284
+ uri.fragment = nil
285
+ uri
286
+ rescue URI::Error, ArgumentError
287
+ nil
288
+ end
289
+
290
+ def same_host?(site)
291
+ value = site['same_host']
292
+ value.nil? ? true : value
293
+ end
294
+
295
+ # An exact host match, and nothing cleverer. `www.example.com` and
296
+ # `blog.example.com` are one organisation and are not one site, and which
297
+ # of them a Recipe wants is the Recipe's to say.
298
+ def same_host_as?(uri, base)
299
+ uri.host.to_s.downcase == base.host.to_s.downcase
300
+ end
301
+
302
+ def fetch_items(site)
303
+ value = site['fetch_items'].to_i
304
+ value.positive? ? value : DEFAULT_FETCH_ITEMS
305
+ end
306
+
307
+ def feed(site, document, base, entries)
308
+ RSS::Maker.make('2.0') do |maker|
309
+ maker.channel.title = channel_title(site, document, base)
310
+ maker.channel.link = base.to_s
311
+ maker.channel.description = "Web page items from #{base}"
312
+ # The page's order is kept. RSS::Maker sorts its items by date when it
313
+ # is asked to, and an article the page listed first is first for a
314
+ # reason a date does not carry.
315
+ maker.items.do_sort = false
316
+
317
+ entries.each do |entry|
318
+ item = maker.items.new_item
319
+ item.title = entry.title
320
+ item.link = entry.url
321
+ item.description = entry.description
322
+ item.date = entry.date unless entry.date.nil?
323
+ end
324
+ end
325
+ end
326
+
327
+ # `name`, then what the page calls itself, then the host it came from.
328
+ def channel_title(site, document, base)
329
+ name = presence(site['name'])
330
+ return name unless name.nil?
331
+
332
+ title = normalize(document.title.to_s)
333
+ title.empty? ? base.host.to_s : title
334
+ end
335
+
336
+ # " Ruby\n 4.0\t released " is "Ruby 4.0 released". A title in a list
337
+ # page is laid out for a browser, and the line breaks and indentation of
338
+ # the markup are not part of it.
339
+ def normalize(text)
340
+ text.to_s.gsub(/[[:space:]]+/, ' ').strip
341
+ end
342
+
343
+ def presence(value)
344
+ string = value.to_s.strip
345
+ string.empty? ? nil : string
346
+ end
347
+ end
348
+ end
@@ -1,44 +1,60 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::AbsoluteURI
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Jun 20, 2012
5
- # Updated:: Oct 29, 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::AbsoluteURI
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 20, 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 FilterAbsoluteURI
13
+ require 'uri'
11
14
 
12
- def initialize(config, pipeline=[])
13
- @config = config
15
+ # Anything already carrying a scheme is left alone. The earlier spelling
16
+ # of this test matched `http://` only, so an https link was treated as
17
+ # relative and had the base prepended to it.
18
+ ABSOLUTE = %r{\A[a-zA-Z][a-zA-Z0-9+.\-]*://}
19
+
20
+ # URI::Parser became the RFC 3986 parser in Ruby 3.4, which reports #escape
21
+ # as obsolete. The RFC 2396 parser is what this was always reaching and is
22
+ # spelled the same way on every supported Ruby.
23
+ ESCAPER = URI::RFC2396_Parser.new
24
+
25
+ def initialize(config, pipeline = [])
26
+ @config = config || {}
14
27
  @pipeline = pipeline
28
+ @base = base_url
15
29
  end
16
30
 
17
31
  def run
18
- @return_feeds = []
19
- @pipeline.each {|feeds|
20
- unless feeds.nil?
21
- feeds.items.each {|feed|
22
- feed.link = rewrite(feed.link) unless feed.link.nil?
23
- }
24
- @return_feeds << feeds
32
+ @pipeline.each_with_object([]) do |feeds, returned|
33
+ next if feeds.nil?
34
+
35
+ feeds.items.each do |item|
36
+ item.link = rewrite(item.link) unless item.link.nil?
25
37
  end
26
- }
27
- @return_feeds
38
+ returned << feeds
39
+ end
28
40
  end
29
41
 
30
42
  private
31
- def rewrite(string)
32
- if /^http:\/\/.*$/ =~ string
33
- return string
34
- end
35
43
 
36
- if /[^\/]$/ =~ @config['url']
37
- @config['url'] = @config['url'] + '/'
38
- end
39
- string = @config['url'] + string.sub(/^\./,'').sub(/^\//,'')
40
- string = URI::Parser.new.escape(string)
41
- return string
44
+ # Read once, in the constructor: the earlier version appended the trailing
45
+ # slash to the Recipe's own config mapping, which is the plugin's input
46
+ # rather than its state.
47
+ def base_url
48
+ url = @config['url'].to_s
49
+ return url if url.empty? || url.end_with?('/')
50
+
51
+ "#{url}/"
52
+ end
53
+
54
+ def rewrite(link)
55
+ return link if ABSOLUTE.match?(link)
56
+
57
+ ESCAPER.escape(@base + link.sub(/\A\./, '').sub(%r{\A/}, ''))
42
58
  end
43
59
  end
44
60
  end
@@ -1,62 +1,55 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::Accept
3
- # Author:: soramugi <http://soramugi.net>
4
- # 774 <http://id774.net>
5
- # Created:: Jun 4, 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::Accept
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:: Jun 4, 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 FilterAccept
12
- def initialize(config, pipeline=[])
13
- @config = config
13
+ FIELDS = %i[title link description].freeze
14
+
15
+ def initialize(config, pipeline = [])
16
+ @config = config || {}
14
17
  @pipeline = pipeline
15
18
  end
16
19
 
20
+ # The complement of FilterIgnore: keeps only the items that match. Matching
21
+ # is a substring test, so an empty keyword matches everything.
17
22
  def run
18
- @return_feeds = []
19
- @pipeline.each {|feeds|
20
- new_feeds = []
21
- unless feeds.nil?
22
- feeds.items.each {|items|
23
- new_feeds << items if contain(items) == true
24
- }
25
- end
26
- @return_feeds << Automatic::FeedMaker.create_pipeline(new_feeds) if new_feeds.length > 0
27
- }
28
- @return_feeds
23
+ @pipeline.each_with_object([]) do |feeds, returned|
24
+ kept = feeds.nil? ? [] : feeds.items.select { |item| contain?(item) }
25
+ returned << Automatic::FeedMaker.create_pipeline(kept) unless kept.empty?
26
+ end
29
27
  end
30
28
 
31
29
  private
32
30
 
33
- def contain(items)
34
- detection = false
35
- unless @config['title'].nil?
36
- @config['title'].each {|e|
37
- if items.title.include?(e.chomp)
38
- detection = true
39
- Automatic::Log.puts("info", "Contain by title: #{items.link}")
40
- end
41
- }
42
- end
43
- unless @config['link'].nil?
44
- @config['link'].each {|e|
45
- if items.link.include?(e.chomp)
46
- detection = true
47
- Automatic::Log.puts("info", "Contain by link: #{items.link}")
48
- end
49
- }
31
+ def contain?(item)
32
+ FIELDS.any? do |field|
33
+ Array(@config[field.to_s]).any? do |keyword|
34
+ matched?(item, field, keyword.to_s.chomp)
35
+ end
50
36
  end
51
- unless @config['description'].nil?
52
- @config['description'].each {|e|
53
- if items.description.include?(e.chomp)
54
- detection = true
55
- Automatic::Log.puts("info", "Contain by description: #{items.link}")
56
- end
57
- }
37
+ end
38
+
39
+ # An item whose field is missing is not matched, and says so. The earlier
40
+ # version called #include? on it and ended the run with a NoMethodError,
41
+ # which is not what the complementary filter does with the same item.
42
+ def matched?(item, field, keyword)
43
+ value = item.send(field)
44
+ unless value.respond_to?(:include?)
45
+ Automatic::Log.puts('warn', "Invalid feed detected in accept process with #{field}")
46
+ return false
58
47
  end
59
- detection
48
+
49
+ return false unless value.include?(keyword)
50
+
51
+ Automatic::Log.puts('info', "Contain by #{field}: #{item.link}")
52
+ true
60
53
  end
61
54
  end
62
55
  end