automatic 14.12.2 → 26.09

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +669 -83
  3. data/VERSION +1 -1
  4. data/assets/siteinfo/items_all.json +60300 -52138
  5. data/automatic.gemspec +110 -248
  6. data/bin/automatic +20 -139
  7. data/config/feed2console.yml +10 -4
  8. data/config/feed2markdown.yml +41 -0
  9. data/doc/AI_TUTORIAL.md +542 -0
  10. data/doc/BASIC_DESIGN.md +547 -0
  11. data/doc/COPYING.LESSER +165 -0
  12. data/doc/DEPLOYMENT.md +830 -0
  13. data/doc/LICENSE.md +14 -0
  14. data/doc/PLUGINS.md +2015 -0
  15. data/doc/PLUGIN_DEVELOPMENT.md +86 -0
  16. data/doc/POLICY.md +962 -0
  17. data/doc/QUICKSTART.md +260 -0
  18. data/doc/RELEASING.md +394 -0
  19. data/doc/REQUIREMENTS.md +556 -0
  20. data/doc/VERSIONS +266 -0
  21. data/lib/automatic/cli.rb +270 -0
  22. data/lib/automatic/environment.rb +31 -5
  23. data/lib/automatic/feed_maker.rb +65 -25
  24. data/lib/automatic/feed_parser.rb +51 -35
  25. data/lib/automatic/http.rb +107 -0
  26. data/lib/automatic/log.rb +49 -18
  27. data/lib/automatic/opml.rb +3 -1
  28. data/lib/automatic/pipeline.rb +73 -32
  29. data/lib/automatic/recipe.rb +100 -17
  30. data/lib/automatic/version.rb +14 -1
  31. data/lib/automatic.rb +93 -20
  32. data/plugins/custom_feed/svn_log.rb +73 -32
  33. data/plugins/custom_feed/web.rb +348 -0
  34. data/plugins/filter/absolute_uri.rb +43 -27
  35. data/plugins/filter/accept.rb +38 -45
  36. data/plugins/filter/batch.rb +97 -0
  37. data/plugins/filter/claude.rb +217 -0
  38. data/plugins/filter/clear.rb +12 -8
  39. data/plugins/filter/description_link.rb +66 -51
  40. data/plugins/filter/full_feed.rb +170 -52
  41. data/plugins/filter/gemini.rb +216 -0
  42. data/plugins/filter/github_feed.rb +38 -26
  43. data/plugins/filter/ignore.rb +33 -44
  44. data/plugins/filter/image.rb +36 -25
  45. data/plugins/filter/image_source.rb +75 -52
  46. data/plugins/filter/join.rb +105 -0
  47. data/plugins/filter/kimi.rb +216 -0
  48. data/plugins/filter/limit.rb +57 -0
  49. data/plugins/filter/one.rb +19 -26
  50. data/plugins/filter/open_ai.rb +198 -0
  51. data/plugins/filter/present.rb +75 -0
  52. data/plugins/filter/rand.rb +16 -17
  53. data/plugins/filter/sakura_ai.rb +205 -0
  54. data/plugins/filter/sanitize.rb +29 -34
  55. data/plugins/filter/sort.rb +20 -27
  56. data/plugins/filter/tumblr_resize.rb +31 -23
  57. data/plugins/notify/ikachan.rb +86 -48
  58. data/plugins/provide/fluentd.rb +43 -24
  59. data/plugins/publish/amazon_s3.rb +79 -40
  60. data/plugins/publish/console.rb +19 -16
  61. data/plugins/publish/console_link.rb +20 -17
  62. data/plugins/publish/eject.rb +48 -26
  63. data/plugins/publish/fluentd.rb +50 -30
  64. data/plugins/publish/hatena_bookmark.rb +88 -71
  65. data/plugins/publish/instapaper.rb +69 -59
  66. data/plugins/publish/markdown.rb +278 -0
  67. data/plugins/publish/memcached.rb +35 -29
  68. data/plugins/store/database.rb +50 -48
  69. data/plugins/store/digest.rb +212 -0
  70. data/plugins/store/file.rb +107 -68
  71. data/plugins/store/full_text.rb +31 -28
  72. data/plugins/store/permalink.rb +18 -22
  73. data/plugins/subscription/feed.rb +34 -21
  74. data/plugins/subscription/link.rb +31 -32
  75. data/plugins/subscription/text.rb +40 -45
  76. data/plugins/subscription/tumblr.rb +55 -44
  77. data/plugins/subscription/xml.rb +40 -36
  78. metadata +112 -321
  79. data/Gemfile +0 -38
  80. data/Rakefile +0 -59
  81. data/doc/ChangeLog +0 -303
  82. data/doc/PLUGINS +0 -750
  83. data/doc/PLUGINS.ja +0 -753
  84. data/doc/README +0 -511
  85. data/doc/README.ja +0 -519
  86. data/plugins/filter/google_news.rb +0 -50
  87. data/plugins/publish/google_calendar.rb +0 -84
  88. data/plugins/publish/hipchat.rb +0 -46
  89. data/plugins/publish/pocket.rb +0 -45
  90. data/plugins/publish/twitter.rb +0 -58
  91. data/plugins/subscription/chan_toru.rb +0 -57
  92. data/plugins/subscription/g_guide.rb +0 -57
  93. data/plugins/subscription/pocket.rb +0 -51
  94. data/plugins/subscription/twitter.rb +0 -69
  95. data/plugins/subscription/twitter_search.rb +0 -50
  96. data/plugins/subscription/weather.rb +0 -33
  97. data/script/build +0 -84
  98. data/spec/fixtures/sampleFeeds.tsv +0 -1
  99. data/spec/fixtures/sampleFeeds2.tsv +0 -2
  100. data/spec/fixtures/sampleRecipe.yml +0 -24
  101. data/spec/lib/automatic/log_spec.rb +0 -32
  102. data/spec/lib/automatic/pipeline_spec.rb +0 -68
  103. data/spec/lib/automatic/recipe_spec.rb +0 -40
  104. data/spec/lib/automatic_spec.rb +0 -99
  105. data/spec/plugins/custom_feed/svn_log_spec.rb +0 -31
  106. data/spec/plugins/filter/absolute_uri_spec.rb +0 -61
  107. data/spec/plugins/filter/accept_spec.rb +0 -331
  108. data/spec/plugins/filter/clear_spec.rb +0 -49
  109. data/spec/plugins/filter/description_link_spec.rb +0 -138
  110. data/spec/plugins/filter/full_feed_spec.rb +0 -129
  111. data/spec/plugins/filter/github_feed_spec.rb +0 -55
  112. data/spec/plugins/filter/google_news_spec.rb +0 -69
  113. data/spec/plugins/filter/ignore_spec.rb +0 -328
  114. data/spec/plugins/filter/image_source_spec.rb +0 -89
  115. data/spec/plugins/filter/image_spec.rb +0 -65
  116. data/spec/plugins/filter/one_spec.rb +0 -71
  117. data/spec/plugins/filter/rand_spec.rb +0 -52
  118. data/spec/plugins/filter/sanitize_spec.rb +0 -153
  119. data/spec/plugins/filter/sort_spec.rb +0 -189
  120. data/spec/plugins/filter/tumblr_resize_spec.rb +0 -109
  121. data/spec/plugins/notify/ikachan_spec.rb +0 -58
  122. data/spec/plugins/provide/fluentd_spec.rb +0 -49
  123. data/spec/plugins/publish/amazon_s3_spec.rb +0 -40
  124. data/spec/plugins/publish/console_spec.rb +0 -30
  125. data/spec/plugins/publish/eject_spec.rb +0 -40
  126. data/spec/plugins/publish/fluentd_spec.rb +0 -40
  127. data/spec/plugins/publish/google_calendar_spec.rb +0 -83
  128. data/spec/plugins/publish/hatena_bookmark_spec.rb +0 -134
  129. data/spec/plugins/publish/hipchat_spec.rb +0 -69
  130. data/spec/plugins/publish/instapaper_spec.rb +0 -82
  131. data/spec/plugins/publish/memcached_spec.rb +0 -63
  132. data/spec/plugins/publish/pocket_spec.rb +0 -51
  133. data/spec/plugins/publish/twitter_spec.rb +0 -73
  134. data/spec/plugins/store/file_spec.rb +0 -58
  135. data/spec/plugins/store/full_text_spec.rb +0 -152
  136. data/spec/plugins/store/permalink_spec.rb +0 -206
  137. data/spec/plugins/subscription/chan_toru_spec.rb +0 -56
  138. data/spec/plugins/subscription/feed_spec.rb +0 -71
  139. data/spec/plugins/subscription/g_guide_spec.rb +0 -82
  140. data/spec/plugins/subscription/link_spec.rb +0 -72
  141. data/spec/plugins/subscription/pocket_spec.rb +0 -57
  142. data/spec/plugins/subscription/text_spec.rb +0 -84
  143. data/spec/plugins/subscription/tumblr_spec.rb +0 -74
  144. data/spec/plugins/subscription/twitter_search_spec.rb +0 -57
  145. data/spec/plugins/subscription/twitter_spec.rb +0 -73
  146. data/spec/plugins/subscription/weather_spec.rb +0 -44
  147. data/spec/plugins/subscription/xml_spec.rb +0 -84
  148. data/spec/spec_helper.rb +0 -106
  149. data/spec/user_dir/plugins/store/mock.rb +0 -16
  150. data/test/fixtures/sampleOPML.xml +0 -11
  151. data/test/integration/test_absoluteurl.yml +0 -25
  152. data/test/integration/test_activerecord.yml +0 -24
  153. data/test/integration/test_add_pocket.yml +0 -26
  154. data/test/integration/test_chan_toru.yml +0 -21
  155. data/test/integration/test_descriptionlink.yml +0 -21
  156. data/test/integration/test_fluentd.yml +0 -22
  157. data/test/integration/test_fulltext.yml +0 -30
  158. data/test/integration/test_google_news.yml +0 -21
  159. data/test/integration/test_googlealert.yml +0 -21
  160. data/test/integration/test_hatenabookmark.yml +0 -30
  161. data/test/integration/test_ignore.yml +0 -25
  162. data/test/integration/test_ignore2.yml +0 -22
  163. data/test/integration/test_image2local.yml +0 -33
  164. data/test/integration/test_instapaper.yml +0 -26
  165. data/test/integration/test_link2local.yml +0 -34
  166. data/test/integration/test_one.yml +0 -23
  167. data/test/integration/test_pocket.yml +0 -22
  168. data/test/integration/test_rand.yml +0 -21
  169. data/test/integration/test_sanitize.yml +0 -23
  170. data/test/integration/test_sort.yml +0 -36
  171. data/test/integration/test_svnlog.yml +0 -15
  172. data/test/integration/test_text2feed.yml +0 -36
  173. data/test/integration/test_tumblr2local.yml +0 -43
  174. data/test/integration/test_twitter_search.yml +0 -22
  175. data/test/integration/test_weather.yml +0 -19
  176. data/test/integration/test_xml2fluentd.yml +0 -21
  177. data/vendor/.gitkeep +0 -0
@@ -0,0 +1,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: https://id774.net)
5
+ # Source Code:: https://github.com/id774/automaticruby
6
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
7
+ # Contact:: idnanashi@gmail.com
8
+ # Created:: Aug 17, 2026
9
+ # Updated:: Aug 17, 2026
10
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
11
+ #
12
+ # One 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: https://id774.net)
4
+ # Source Code:: https://github.com/id774/automaticruby
5
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
6
+ # Contact:: idnanashi@gmail.com
7
+ # Created:: Jun 20, 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
@@ -0,0 +1,97 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Plugin::Filter::Batch
3
+ # Description:: Group the whole pipeline into fixed-size item batches.
4
+ # Author: id774 (More info: https://id774.net)
5
+ # Source Code:: https://github.com/id774/automaticruby
6
+ # License:: The GPL version 3, or LGPL version 3 (Dual License).
7
+ # Contact:: idnanashi@gmail.com
8
+ # Created:: Aug 24, 2026
9
+ # Updated:: Aug 24, 2026
10
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
11
+
12
+ module Automatic::Plugin
13
+ class FilterBatch
14
+ require 'rss'
15
+
16
+ # Where one source item ends and the next begins inside a batch's
17
+ # description, in the manner of FilterJoin's own delimiter -- but built
18
+ # independently, since a batch item is not a joined item.
19
+ HEADING = 'ARTICLE'.freeze
20
+
21
+ def initialize(config, pipeline = [])
22
+ @config = config || {}
23
+ @pipeline = pipeline
24
+ @batch_items = validated_batch_items
25
+ end
26
+
27
+ # Collects the whole pipeline into one Array, discarding feed boundaries,
28
+ # and slices it into fixed-size batches. Each batch becomes one item in one
29
+ # output feed.
30
+ def run
31
+ items = collect
32
+ return [] if items.empty?
33
+
34
+ feed(items.each_slice(@batch_items).to_a)
35
+ end
36
+
37
+ private
38
+
39
+ def validated_batch_items
40
+ value = begin
41
+ Integer(@config['batch_items'].to_s, 10)
42
+ rescue ArgumentError
43
+ nil
44
+ end
45
+
46
+ if value.nil? || value < 1
47
+ raise ArgumentError, 'FilterBatch needs batch_items to be a positive integer'
48
+ end
49
+
50
+ value
51
+ end
52
+
53
+ def collect
54
+ @pipeline.each_with_object([]) do |feeds, items|
55
+ next if feeds.nil?
56
+
57
+ items.concat(feeds.items)
58
+ end
59
+ end
60
+
61
+ def feed(batches)
62
+ [RSS::Maker.make('2.0') { |maker|
63
+ maker.channel.title = 'Automatic Ruby'
64
+ maker.channel.description = 'Automatic::Plugin::FilterBatch'
65
+ maker.channel.link = 'https://github.com/id774/automaticruby'
66
+ maker.items.do_sort = false
67
+
68
+ batches.each_with_index do |batch, index|
69
+ item = maker.items.new_item
70
+ item.title = "Batch #{index + 1}"
71
+ item.description = description(batch)
72
+ item.date = Time.now
73
+ end
74
+ }]
75
+ end
76
+
77
+ def description(batch)
78
+ batch.each_with_index.map { |item, index| section(index + 1, item) }.join("\n\n")
79
+ end
80
+
81
+ def section(number, item)
82
+ ["#{HEADING} #{number}",
83
+ "Title: #{value(item, :title)}",
84
+ "URL: #{value(item, :link)}",
85
+ '',
86
+ value(item, :description)].join("\n")
87
+ end
88
+
89
+ # A field an item does not carry is empty rather than absent, so that every
90
+ # ARTICLE has the same shape whatever the feed it came from left out.
91
+ def value(item, name)
92
+ return '' unless item.respond_to?(name)
93
+
94
+ item.public_send(name).to_s.strip
95
+ end
96
+ end
97
+ end