automatic 14.12.2 → 26.08

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +635 -83
  3. data/VERSION +1 -1
  4. data/automatic.gemspec +109 -248
  5. data/bin/automatic +20 -139
  6. data/config/feed2console.yml +10 -4
  7. data/config/feed2markdown.yml +41 -0
  8. data/doc/AI_TUTORIAL.md +518 -0
  9. data/doc/BASIC_DESIGN.md +516 -0
  10. data/doc/COPYING.LESSER +165 -0
  11. data/doc/DEPLOYMENT.md +824 -0
  12. data/doc/LICENSE.md +14 -0
  13. data/doc/PLUGINS.md +1875 -0
  14. data/doc/PLUGIN_DEVELOPMENT.md +86 -0
  15. data/doc/POLICY.md +857 -0
  16. data/doc/QUICKSTART.md +256 -0
  17. data/doc/RELEASING.md +381 -0
  18. data/doc/REQUIREMENTS.md +526 -0
  19. data/doc/VERSIONS +208 -0
  20. data/lib/automatic/cli.rb +248 -0
  21. data/lib/automatic/environment.rb +31 -5
  22. data/lib/automatic/feed_maker.rb +10 -9
  23. data/lib/automatic/feed_parser.rb +51 -35
  24. data/lib/automatic/http.rb +107 -0
  25. data/lib/automatic/log.rb +49 -18
  26. data/lib/automatic/opml.rb +3 -1
  27. data/lib/automatic/pipeline.rb +63 -32
  28. data/lib/automatic/recipe.rb +56 -17
  29. data/lib/automatic/version.rb +14 -1
  30. data/lib/automatic.rb +78 -20
  31. data/plugins/custom_feed/svn_log.rb +73 -32
  32. data/plugins/custom_feed/web.rb +348 -0
  33. data/plugins/filter/absolute_uri.rb +43 -27
  34. data/plugins/filter/accept.rb +38 -45
  35. data/plugins/filter/claude.rb +217 -0
  36. data/plugins/filter/clear.rb +12 -8
  37. data/plugins/filter/description_link.rb +49 -51
  38. data/plugins/filter/full_feed.rb +158 -52
  39. data/plugins/filter/gemini.rb +216 -0
  40. data/plugins/filter/github_feed.rb +38 -26
  41. data/plugins/filter/ignore.rb +33 -44
  42. data/plugins/filter/image.rb +36 -25
  43. data/plugins/filter/image_source.rb +58 -52
  44. data/plugins/filter/join.rb +107 -0
  45. data/plugins/filter/one.rb +19 -26
  46. data/plugins/filter/open_ai.rb +198 -0
  47. data/plugins/filter/rand.rb +16 -17
  48. data/plugins/filter/sakura_ai.rb +205 -0
  49. data/plugins/filter/sanitize.rb +29 -34
  50. data/plugins/filter/sort.rb +20 -27
  51. data/plugins/filter/tumblr_resize.rb +31 -23
  52. data/plugins/notify/ikachan.rb +86 -48
  53. data/plugins/provide/fluentd.rb +43 -24
  54. data/plugins/publish/amazon_s3.rb +73 -40
  55. data/plugins/publish/console.rb +19 -16
  56. data/plugins/publish/console_link.rb +20 -17
  57. data/plugins/publish/eject.rb +48 -26
  58. data/plugins/publish/fluentd.rb +50 -30
  59. data/plugins/publish/hatena_bookmark.rb +88 -71
  60. data/plugins/publish/instapaper.rb +69 -59
  61. data/plugins/publish/markdown.rb +278 -0
  62. data/plugins/publish/memcached.rb +35 -29
  63. data/plugins/store/database.rb +50 -48
  64. data/plugins/store/digest.rb +212 -0
  65. data/plugins/store/file.rb +99 -68
  66. data/plugins/store/full_text.rb +32 -25
  67. data/plugins/store/permalink.rb +18 -22
  68. data/plugins/subscription/feed.rb +34 -21
  69. data/plugins/subscription/link.rb +31 -32
  70. data/plugins/subscription/text.rb +32 -46
  71. data/plugins/subscription/tumblr.rb +55 -44
  72. data/plugins/subscription/xml.rb +40 -36
  73. metadata +108 -321
  74. data/Gemfile +0 -38
  75. data/Rakefile +0 -59
  76. data/doc/ChangeLog +0 -303
  77. data/doc/PLUGINS +0 -750
  78. data/doc/PLUGINS.ja +0 -753
  79. data/doc/README +0 -511
  80. data/doc/README.ja +0 -519
  81. data/plugins/filter/google_news.rb +0 -50
  82. data/plugins/publish/google_calendar.rb +0 -84
  83. data/plugins/publish/hipchat.rb +0 -46
  84. data/plugins/publish/pocket.rb +0 -45
  85. data/plugins/publish/twitter.rb +0 -58
  86. data/plugins/subscription/chan_toru.rb +0 -57
  87. data/plugins/subscription/g_guide.rb +0 -57
  88. data/plugins/subscription/pocket.rb +0 -51
  89. data/plugins/subscription/twitter.rb +0 -69
  90. data/plugins/subscription/twitter_search.rb +0 -50
  91. data/plugins/subscription/weather.rb +0 -33
  92. data/script/build +0 -84
  93. data/spec/fixtures/sampleFeeds.tsv +0 -1
  94. data/spec/fixtures/sampleFeeds2.tsv +0 -2
  95. data/spec/fixtures/sampleRecipe.yml +0 -24
  96. data/spec/lib/automatic/log_spec.rb +0 -32
  97. data/spec/lib/automatic/pipeline_spec.rb +0 -68
  98. data/spec/lib/automatic/recipe_spec.rb +0 -40
  99. data/spec/lib/automatic_spec.rb +0 -99
  100. data/spec/plugins/custom_feed/svn_log_spec.rb +0 -31
  101. data/spec/plugins/filter/absolute_uri_spec.rb +0 -61
  102. data/spec/plugins/filter/accept_spec.rb +0 -331
  103. data/spec/plugins/filter/clear_spec.rb +0 -49
  104. data/spec/plugins/filter/description_link_spec.rb +0 -138
  105. data/spec/plugins/filter/full_feed_spec.rb +0 -129
  106. data/spec/plugins/filter/github_feed_spec.rb +0 -55
  107. data/spec/plugins/filter/google_news_spec.rb +0 -69
  108. data/spec/plugins/filter/ignore_spec.rb +0 -328
  109. data/spec/plugins/filter/image_source_spec.rb +0 -89
  110. data/spec/plugins/filter/image_spec.rb +0 -65
  111. data/spec/plugins/filter/one_spec.rb +0 -71
  112. data/spec/plugins/filter/rand_spec.rb +0 -52
  113. data/spec/plugins/filter/sanitize_spec.rb +0 -153
  114. data/spec/plugins/filter/sort_spec.rb +0 -189
  115. data/spec/plugins/filter/tumblr_resize_spec.rb +0 -109
  116. data/spec/plugins/notify/ikachan_spec.rb +0 -58
  117. data/spec/plugins/provide/fluentd_spec.rb +0 -49
  118. data/spec/plugins/publish/amazon_s3_spec.rb +0 -40
  119. data/spec/plugins/publish/console_spec.rb +0 -30
  120. data/spec/plugins/publish/eject_spec.rb +0 -40
  121. data/spec/plugins/publish/fluentd_spec.rb +0 -40
  122. data/spec/plugins/publish/google_calendar_spec.rb +0 -83
  123. data/spec/plugins/publish/hatena_bookmark_spec.rb +0 -134
  124. data/spec/plugins/publish/hipchat_spec.rb +0 -69
  125. data/spec/plugins/publish/instapaper_spec.rb +0 -82
  126. data/spec/plugins/publish/memcached_spec.rb +0 -63
  127. data/spec/plugins/publish/pocket_spec.rb +0 -51
  128. data/spec/plugins/publish/twitter_spec.rb +0 -73
  129. data/spec/plugins/store/file_spec.rb +0 -58
  130. data/spec/plugins/store/full_text_spec.rb +0 -152
  131. data/spec/plugins/store/permalink_spec.rb +0 -206
  132. data/spec/plugins/subscription/chan_toru_spec.rb +0 -56
  133. data/spec/plugins/subscription/feed_spec.rb +0 -71
  134. data/spec/plugins/subscription/g_guide_spec.rb +0 -82
  135. data/spec/plugins/subscription/link_spec.rb +0 -72
  136. data/spec/plugins/subscription/pocket_spec.rb +0 -57
  137. data/spec/plugins/subscription/text_spec.rb +0 -84
  138. data/spec/plugins/subscription/tumblr_spec.rb +0 -74
  139. data/spec/plugins/subscription/twitter_search_spec.rb +0 -57
  140. data/spec/plugins/subscription/twitter_spec.rb +0 -73
  141. data/spec/plugins/subscription/weather_spec.rb +0 -44
  142. data/spec/plugins/subscription/xml_spec.rb +0 -84
  143. data/spec/spec_helper.rb +0 -106
  144. data/spec/user_dir/plugins/store/mock.rb +0 -16
  145. data/test/fixtures/sampleOPML.xml +0 -11
  146. data/test/integration/test_absoluteurl.yml +0 -25
  147. data/test/integration/test_activerecord.yml +0 -24
  148. data/test/integration/test_add_pocket.yml +0 -26
  149. data/test/integration/test_chan_toru.yml +0 -21
  150. data/test/integration/test_descriptionlink.yml +0 -21
  151. data/test/integration/test_fluentd.yml +0 -22
  152. data/test/integration/test_fulltext.yml +0 -30
  153. data/test/integration/test_google_news.yml +0 -21
  154. data/test/integration/test_googlealert.yml +0 -21
  155. data/test/integration/test_hatenabookmark.yml +0 -30
  156. data/test/integration/test_ignore.yml +0 -25
  157. data/test/integration/test_ignore2.yml +0 -22
  158. data/test/integration/test_image2local.yml +0 -33
  159. data/test/integration/test_instapaper.yml +0 -26
  160. data/test/integration/test_link2local.yml +0 -34
  161. data/test/integration/test_one.yml +0 -23
  162. data/test/integration/test_pocket.yml +0 -22
  163. data/test/integration/test_rand.yml +0 -21
  164. data/test/integration/test_sanitize.yml +0 -23
  165. data/test/integration/test_sort.yml +0 -36
  166. data/test/integration/test_svnlog.yml +0 -15
  167. data/test/integration/test_text2feed.yml +0 -36
  168. data/test/integration/test_tumblr2local.yml +0 -43
  169. data/test/integration/test_twitter_search.yml +0 -22
  170. data/test/integration/test_weather.yml +0 -19
  171. data/test/integration/test_xml2fluentd.yml +0 -21
  172. data/vendor/.gitkeep +0 -0
@@ -0,0 +1,217 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Name:: Automatic::Plugin::Filter::Claude
3
+ # Description:: Replace each item's description with what the Anthropic Claude API answers.
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 transformation: the item's description goes to the Anthropic Messages API
13
+ # under the Recipe's prompt, and the answer becomes the item's description.
14
+ # What that transformation is -- a summary, a translation, an extraction, a
15
+ # classification -- is the prompt's business, not this plugin's.
16
+ #
17
+ # This plugin knows Anthropic and nothing else. Its authentication is an
18
+ # `x-api-key` header rather than a bearer token, it requires an API version
19
+ # header and a `max_tokens`, and its answer is a list of content blocks. None
20
+ # of that is bent into another service's shape, and no other service's request
21
+ # is built here.
22
+ #
23
+ # @see https://docs.anthropic.com/en/api/messages
24
+
25
+ module Automatic::Plugin
26
+ class FilterClaude
27
+ require 'json'
28
+ require 'net/http'
29
+ require 'openssl'
30
+ require 'uri'
31
+
32
+ ENDPOINT = URI('https://api.anthropic.com/v1/messages')
33
+
34
+ # The API version header the Messages API requires on every request. It is
35
+ # the version of the HTTP interface, not of a model, which is why it is a
36
+ # constant here and the model is a setting.
37
+ API_VERSION = '2023-06-01'.freeze
38
+
39
+ # `max_tokens` is required by this API and by no other one this repository
40
+ # speaks to, so it is a setting of this plugin alone. The default is a
41
+ # length a digest or a translation fits in; a Recipe that wants a longer
42
+ # answer says so.
43
+ DEFAULT_MAX_TOKENS = 4096
44
+
45
+ OPEN_TIMEOUT = 10
46
+
47
+ # Generous, and bounded. A model given several articles thinks for a while;
48
+ # an unattended run that waits forever is the failure this exists against.
49
+ READ_TIMEOUT = 300
50
+
51
+ # A failure that another attempt will not get past: a setting that is
52
+ # wrong, a request the service refuses, an answer this plugin cannot read.
53
+ class Error < StandardError; end
54
+
55
+ # A failure that another attempt may get past: the network, a rate limit, a
56
+ # server error.
57
+ class TemporaryError < StandardError; end
58
+
59
+ def initialize(config, pipeline = [])
60
+ @config = config || {}
61
+ @pipeline = pipeline
62
+ end
63
+
64
+ # Replaces each item's description with the answer. Nothing else about an
65
+ # item is touched, and the feeds and their items arrive and leave in the
66
+ # same order and number.
67
+ def run
68
+ validate_settings
69
+
70
+ @pipeline.each { |feeds|
71
+ next if feeds.nil?
72
+
73
+ feeds.items.each { |item| transform(item) }
74
+ }
75
+ @pipeline
76
+ end
77
+
78
+ private
79
+
80
+ # Checked before the first request, because a Recipe this plugin cannot
81
+ # carry out is the operator's mistake and will be the same mistake on every
82
+ # item. The token is never named in a message.
83
+ def validate_settings
84
+ raise ArgumentError, 'FilterClaude needs a token' if token.empty?
85
+ raise ArgumentError, 'FilterClaude needs a model' if model.empty?
86
+ raise ArgumentError, 'FilterClaude needs a prompt' if prompt.empty?
87
+ raise ArgumentError, 'FilterClaude needs a positive max_tokens' unless max_tokens.positive?
88
+ end
89
+
90
+ def token
91
+ @config['token'].to_s
92
+ end
93
+
94
+ def model
95
+ @config['model'].to_s.strip
96
+ end
97
+
98
+ def prompt
99
+ @config['prompt'].to_s.strip
100
+ end
101
+
102
+ def max_tokens
103
+ given = @config['max_tokens']
104
+ given.nil? ? DEFAULT_MAX_TOKENS : given.to_i
105
+ end
106
+
107
+ def transform(item)
108
+ text = item.description.to_s
109
+ if text.strip.empty?
110
+ Automatic::Log.puts('warn', "FilterClaude: nothing to send for #{item.link}")
111
+ return
112
+ end
113
+
114
+ Automatic::Log.puts('info', "FilterClaude: asking #{model} about #{item.link}")
115
+ item.description = answer(text)
116
+ end
117
+
118
+ # The retry shape of doc/PLUGINS.md section 3.6, applied only to what
119
+ # retrying can help. A missing setting, a refused request or an answer in a
120
+ # shape this plugin cannot read is raised at once: trying again would fail
121
+ # the same way, more slowly.
122
+ def answer(text)
123
+ retries = 0
124
+ retry_max = @config['retry'].to_i
125
+ begin
126
+ message(text)
127
+ rescue TemporaryError => e
128
+ retries += 1
129
+ Automatic::Log.puts('error', "ErrorCount: #{retries}, FilterClaude: #{e.message}")
130
+ if retries <= retry_max
131
+ sleep(@config['interval'].to_i)
132
+ retry
133
+ end
134
+ raise Error, "FilterClaude gave up after #{retries} attempts: #{e.message}"
135
+ end
136
+ end
137
+
138
+ def message(text)
139
+ # The prompt is the system instruction and the description is the user
140
+ # turn it is applied to. They are separate fields, so that what an
141
+ # article says is never read as an instruction to this plugin or to the
142
+ # model.
143
+ body = {
144
+ 'model' => model,
145
+ 'max_tokens' => max_tokens,
146
+ 'system' => prompt,
147
+ 'messages' => [{ 'role' => 'user', 'content' => text }]
148
+ }
149
+ content(post(JSON.generate(body)))
150
+ end
151
+
152
+ def post(body)
153
+ request = Net::HTTP::Post.new(ENDPOINT)
154
+ request['x-api-key'] = token
155
+ request['anthropic-version'] = API_VERSION
156
+ request['Content-Type'] = 'application/json'
157
+ request.body = body
158
+
159
+ # TLS with the certificate verified, which is Net::HTTP's own default and
160
+ # is named here because it is not a thing to be turned off.
161
+ Net::HTTP.start(ENDPOINT.host, ENDPOINT.port,
162
+ use_ssl: true,
163
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
164
+ open_timeout: OPEN_TIMEOUT,
165
+ read_timeout: READ_TIMEOUT) { |http| http.request(request) }
166
+ rescue Timeout::Error, SystemCallError, SocketError, IOError,
167
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
168
+ raise TemporaryError, "the request to Claude failed: #{e.message}"
169
+ end
170
+
171
+ def content(response)
172
+ case response
173
+ when Net::HTTPSuccess
174
+ answer_text(parse(response.body))
175
+ when Net::HTTPTooManyRequests, Net::HTTPServerError
176
+ raise TemporaryError, "Claude answered #{response.code}: #{reason(response)}"
177
+ else
178
+ raise Error, "Claude answered #{response.code}: #{reason(response)}"
179
+ end
180
+ end
181
+
182
+ def parse(body)
183
+ JSON.parse(body.to_s)
184
+ rescue JSON::ParserError => e
185
+ raise Error, "Claude answered with something that is not JSON: #{e.message}"
186
+ end
187
+
188
+ # The text of the answer, out of the content blocks the Messages API
189
+ # returns. A block of another type -- this API has several -- is not text
190
+ # and is passed over. An answer this plugin cannot find is an error and not
191
+ # an empty description: a Recipe that published the empty string here would
192
+ # have thrown the article away and reported success.
193
+ def answer_text(body)
194
+ blocks = body['content']
195
+ raise Error, 'Claude answered without a content array' unless blocks.is_a?(Array)
196
+
197
+ text = blocks.select { |block| block.is_a?(Hash) && block['type'] == 'text' }.
198
+ map { |block| block['text'].to_s }.join.strip
199
+ raise Error, 'Claude answered with no text content' if text.empty?
200
+
201
+ text
202
+ end
203
+
204
+ # The service's own explanation where it gave one, the status line
205
+ # otherwise. Neither carries the token, and the settings are never logged
206
+ # or raised wholesale.
207
+ def reason(response)
208
+ body = JSON.parse(response.body.to_s)
209
+ error = body['error']
210
+ return error['message'].to_s if error.is_a?(Hash) && !error['message'].to_s.empty?
211
+
212
+ response.message.to_s
213
+ rescue JSON::ParserError
214
+ response.message.to_s
215
+ end
216
+ end
217
+ end
@@ -1,18 +1,22 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::Clear
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Oct 20, 2014
5
- # Updated:: Oct 20, 2014
6
- # Copyright:: Copyright (c) 2014 Automatic Ruby Developers.
7
- # License:: Licensed under the GNU GENERAL PUBLIC LICENSE, Version 3.0.
2
+ # Name:: Automatic::Plugin::Filter::Clear
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:: Oct 20, 2014
8
+ # Updated:: Aug 15, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
8
10
 
9
11
  module Automatic::Plugin
10
12
  class FilterClear
11
- def initialize(config, pipeline=[])
12
- @config = config
13
+ def initialize(config, pipeline = [])
14
+ @config = config || {}
13
15
  @pipeline = pipeline
14
16
  end
15
17
 
18
+ # Returns an empty pipeline, to end a Recipe after a store plugin has done
19
+ # the work so that later plugins publish nothing.
16
20
  def run
17
21
  []
18
22
  end
@@ -1,74 +1,72 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::DescriptionLink
3
- # Author:: 774 <http://id774.net>
4
- # Created:: Oct 03, 2014
5
- # Updated:: Oct 16, 2014
6
- # Copyright:: Copyright (c) 2014 Automatic Ruby Developers.
7
- # License:: Licensed under the GNU GENERAL PUBLIC LICENSE, Version 3.0.
2
+ # Name:: Automatic::Plugin::Filter::DescriptionLink
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:: Oct 03, 2014
8
+ # Updated:: Aug 15, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
8
10
 
9
11
  module Automatic::Plugin
10
12
  class FilterDescriptionLink
13
+ Automatic.require_optional('nokogiri', needed_by: 'FilterDescriptionLink')
11
14
  require 'uri'
12
- require 'nkf'
13
15
 
14
- def initialize(config, pipeline=[])
15
- @config = config
16
+ # URI.extract and URI::PATTERN answer through the RFC 3986 parser that
17
+ # URI::Parser became in Ruby 3.4, which reports both as obsolete. The RFC
18
+ # 2396 parser is what they were always reaching, it is spelled the same way
19
+ # on every supported Ruby, and it is named here directly.
20
+ PARSER = URI::RFC2396_Parser.new
21
+
22
+ def initialize(config, pipeline = [])
23
+ @config = config || {}
16
24
  @pipeline = pipeline
17
25
  end
18
26
 
27
+ # Takes the last HTTP or HTTPS URL out of the description and makes it the
28
+ # link, for feeds that carry the real destination in the body.
19
29
  def run
20
- @return_feeds = []
21
- @pipeline.each {|feeds|
22
- new_feeds = []
23
- unless feeds.nil?
24
- feeds.items.each {|feed|
25
- new_feeds << rewrite_link(feed)
26
- }
27
- end
28
- @return_feeds << Automatic::FeedMaker.create_pipeline(new_feeds)
29
- }
30
- @return_feeds
30
+ @pipeline.each_with_object([]) do |feeds, returned|
31
+ items = feeds.nil? ? [] : feeds.items.map { |item| rewrite(item) }
32
+ returned << Automatic::FeedMaker.create_pipeline(items)
33
+ end
31
34
  end
32
35
 
33
36
  private
34
37
 
35
- def get_title(url)
36
- new_title = nil
37
- if url.class == String
38
- url.gsub!(Regexp.new("[^#{URI::PATTERN::ALNUM}\/\:\?\=&~,\.\(\)#]")) {|match| ERB::Util.url_encode(match)}
39
- begin
40
- read_data = NKF.nkf("--utf8", open(url).read)
41
- get_text = Nokogiri::HTML.parse(read_data, nil, 'utf8').xpath('//title').text
42
- new_title = get_text if get_text.class == String
43
- rescue
44
- Automatic::Log.puts("warn", "Failed in get title for: #{url}")
45
- end
46
- end
38
+ def rewrite(item)
39
+ link = PARSER.extract(item.description.to_s, %w[http https]).uniq.last
40
+ item.link = link unless link.nil?
41
+
42
+ item.description = '' if setting?('clear_description')
43
+ retitle(item) if setting?('get_title')
47
44
 
48
- new_title
45
+ item
49
46
  end
50
47
 
51
- def rewrite_link(feed)
52
- new_link = URI.extract(feed.description, %w{http https}).uniq.last
53
- feed.link = new_link unless new_link.nil?
48
+ # Settings are read whatever the mapping is. This tested `@config.class ==
49
+ # Hash`, which a Recipe never satisfies: the framework hands a plugin a
50
+ # Hashie::Mash, so both settings below were silently ignored in every real
51
+ # run and read only by a spec passing a plain Hash.
52
+ def setting?(name)
53
+ @config[name].to_s == '1'
54
+ end
54
55
 
55
- if @config.class == Hash
56
- if @config['clear_description'] == 1
57
- feed.description = ""
58
- end
56
+ def retitle(item)
57
+ title = fetch_title(item.link)
58
+ item.title = title unless title.nil? || title.empty?
59
+ end
59
60
 
60
- if @config['get_title'] == 1
61
- begin
62
- new_title = get_title(feed.link)
63
- feed.title = new_title unless new_title.nil?
64
- rescue OpenURI::HTTPError
65
- Automatic::Log.puts("warn", "404 Not Found in get title process.")
66
- end
67
- end
68
- end
61
+ # One request per item; use FilterOne or a store plugin before this on a
62
+ # large feed. A page that cannot be read leaves the item's own title.
63
+ def fetch_title(url)
64
+ return nil unless Automatic::Http.fetchable?(url)
69
65
 
70
- feed
66
+ Nokogiri::HTML.parse(Automatic::Http.read(url)).xpath('//title').text
67
+ rescue StandardError => e
68
+ Automatic::Log.puts('warn', "Failed in get title for: #{url}, #{e.message}")
69
+ nil
71
70
  end
72
71
  end
73
72
  end
74
-
@@ -1,77 +1,183 @@
1
1
  # -*- coding: utf-8 -*-
2
- # Name:: Automatic::Plugin::Filter::FullFeed
3
- # Author:: progd <http://d.hatena.ne.jp/progd/20120429/automatic_ruby_filter_full_feed>
4
- # 774 <http://id774.net>
5
- # Created:: Apr 29, 2012
6
- # Updated:: Jun 23, 2013
7
- # Copyright:: Copyright (c) 2012-2013 Automatic Ruby Developers.
8
- # License:: Licensed under the GNU GENERAL PUBLIC LICENSE, Version 3.0.
2
+ # Name:: Automatic::Plugin::Filter::FullFeed
3
+ # Author: progd (More info: http://d.hatena.ne.jp/progd/20120429/automatic_ruby_filter_full_feed)
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:: Apr 29, 2012
8
+ # Updated:: Aug 15, 2026
9
+ # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
9
10
 
10
11
  module Automatic::Plugin
11
-
12
12
  class FilterFullFeed
13
- require 'nokogiri'
13
+ Automatic.require_optional('nokogiri', needed_by: 'FilterFullFeed')
14
+ require 'json'
15
+ require 'stringio'
16
+
17
+ SITEINFO_TYPES = %w[SBM INDIVIDUAL IND SUBGENERAL SUB GENERAL GEN].freeze
14
18
 
15
- SITEINFO_TYPES = %w[SBM INDIVIDUAL IND SUBGENERAL SUB GENERAL GEN]
19
+ # One siteinfo record, reduced to the four things a match needs and with
20
+ # its URL pattern compiled once. The database ships with 3,504 usable
21
+ # records, so compiling them per item -- which is what matching against
22
+ # the raw JSON did -- was several thousand `Regexp.new` calls for every
23
+ # link in every feed.
24
+ Entry = Struct.new(:url, :pattern, :xpath, :encoding)
16
25
 
17
- def initialize(config, pipeline=[])
18
- @config = config
26
+ def initialize(config, pipeline = [])
27
+ @config = config || {}
19
28
  @pipeline = pipeline
20
- @siteinfo = get_siteinfo
29
+ @siteinfo = siteinfo
21
30
  end
22
31
 
32
+ # Replaces a summary with the article body, by matching the link against a
33
+ # siteinfo database of URL patterns and XPaths and fetching the page.
23
34
  def run
24
- @return_feeds = []
25
- @pipeline.each {|feeds|
35
+ @pipeline.each_with_object([]) do |feeds, returned|
26
36
  unless feeds.nil?
27
- feeds.items.each {|feed|
28
- feed = fulltext(feed)
29
- }
37
+ feeds.items.each { |item| fulltext(item) }
30
38
  end
31
- @return_feeds << feeds
32
- }
33
- @return_feeds
39
+ returned << feeds
40
+ end
34
41
  end
35
42
 
36
43
  private
37
44
 
38
- def get_siteinfo
39
- Automatic::Log.puts(:info, "Loading siteinfo from #{@config['siteinfo']}")
40
- siteinfo = JSON.load(open(File.join(assets_dir, @config['siteinfo'])).read.force_encoding("UTF-8"))
41
- siteinfo.select! { |info| SITEINFO_TYPES.include? (info['data']['type']) }
42
- siteinfo.sort! { |a, b|
43
- atype, btype = a['data']['type'], b['data']['type']
44
- SITEINFO_TYPES.index(atype) <=> SITEINFO_TYPES.index(btype)
45
- }
46
- return siteinfo
45
+ def siteinfo
46
+ name = @config['siteinfo'].to_s
47
+ raise ArgumentError, 'FilterFullFeed needs a siteinfo file name' if name.empty?
48
+
49
+ Automatic::Log.puts('info', "Loading siteinfo from #{name}")
50
+ records = JSON.parse(File.read(File.join(assets_dir, name), encoding: 'UTF-8'))
51
+ entries = records.select { |info| SITEINFO_TYPES.include?(info['data']['type']) }
52
+ .sort_by { |info| SITEINFO_TYPES.index(info['data']['type']) }
53
+ .filter_map { |info| entry(info['data']) }
54
+ Automatic::Log.puts('info', "Loaded #{entries.size} siteinfo entries")
55
+ entries
56
+ end
57
+
58
+ # A record is dropped here rather than failing a match later. A record
59
+ # without a URL pattern would match every link -- an empty pattern matches
60
+ # everything -- and one without an XPath has nothing to select with.
61
+ def entry(data)
62
+ url = data['url'].to_s
63
+ xpath = data['xpath'].to_s.strip
64
+ return nil if url.empty? || xpath.empty?
65
+
66
+ Entry.new(url, Regexp.new(url), xpath, charset(data['enc']))
67
+ rescue RegexpError
68
+ nil
47
69
  end
48
70
 
49
71
  def assets_dir
50
- dir = (File.expand_path('~/.automatic/assets/siteinfo'))
51
- if File.directory?(dir)
52
- dir
53
- else
54
- File.join(File.dirname(__FILE__), '..', '..', 'assets', 'siteinfo')
72
+ dir = File.expand_path('~/.automatic/assets/siteinfo')
73
+ return dir if File.directory?(dir)
74
+
75
+ File.expand_path('../../assets/siteinfo', __dir__)
76
+ end
77
+
78
+ def fulltext(item)
79
+ return if item.link.nil?
80
+
81
+ record = match(item.link)
82
+ if record.nil?
83
+ Automatic::Log.puts('info', "Fulltext SITEINFO not found: #{item.link}")
84
+ return
55
85
  end
86
+
87
+ Automatic::Log.puts('info', "Siteinfo matched: #{record.url}")
88
+ html = body(item.link, record)
89
+ if html.nil?
90
+ # The page was read but the XPath selected nothing, which is what a
91
+ # site that has been redesigned since its record was written looks
92
+ # like. Assigning the empty result here is what used to replace a
93
+ # perfectly good summary with an empty description.
94
+ Automatic::Log.puts('warn', "Fulltext XPath selected nothing on #{item.link}: #{record.xpath}")
95
+ return
96
+ end
97
+
98
+ item.description = html
99
+ rescue StandardError => e
100
+ # An unreadable page leaves the item's own summary in place, which is
101
+ # what this filter is an improvement on rather than a replacement for.
102
+ Automatic::Log.puts('warn', "Failed to read fulltext for #{item.link}: #{e.message}")
56
103
  end
57
104
 
58
- def fulltext(feed)
59
- return feed unless feed.link
60
- @siteinfo.each {|info|
61
- begin
62
- if feed.link.match(info['data']['url'])
63
- Automatic::Log.puts(:info, "Siteinfo matched: #{info['data']['url']}")
64
- html = Nokogiri::HTML.parse(open(feed.link))
65
- body = html.xpath(info['data']['xpath'])
66
- feed.description = body.to_html.encode('UTF-8', :undef => :replace)
67
- return feed
68
- end
69
- rescue
70
- return feed
71
- end
72
- }
73
- Automatic::Log.puts(:info, "Fulltext SITEINFO not found: #{feed.link}")
74
- return feed
105
+ def match(link)
106
+ links = schemes(link)
107
+ @siteinfo.find { |record| links.any? { |candidate| record.pattern.match?(candidate) } }
108
+ end
109
+
110
+ # The database was last updated in 2013 and 3,448 of its 3,504 records
111
+ # anchor on a scheme, nearly all of them `^http://`. The sites they name
112
+ # have since moved to HTTPS, so an https link out of a feed matches none of
113
+ # them and the filter silently does nothing. Matching the link under either
114
+ # scheme is what keeps those records reachable; a record is about a site's
115
+ # layout, not about how it is transported. Only the match is rewritten --
116
+ # the page is fetched from the link the feed gave.
117
+ def schemes(link)
118
+ case link
119
+ when %r{\Ahttps://} then [link, link.sub(%r{\Ahttps://}, 'http://')]
120
+ when %r{\Ahttp://} then [link, link.sub(%r{\Ahttp://}, 'https://')]
121
+ else [link]
122
+ end
123
+ end
124
+
125
+ # Returns the selected body as UTF-8, or nil when the XPath selects
126
+ # nothing, so that the caller can tell an article apart from an empty
127
+ # result and keep the summary it already had.
128
+ def body(link, entry)
129
+ nodes = document(link, entry).xpath(entry.xpath)
130
+ return nil if nodes.empty?
131
+
132
+ # Normalised to UTF-8, and scrubbed rather than raised on. The parser
133
+ # returns a string in whatever encoding it settled on for the page, and
134
+ # a page whose declared charset is not the one it is written in is
135
+ # common enough in a database this old; what leaves here goes on to a
136
+ # publish plugin, which has no way to recover from a string it cannot
137
+ # encode. `invalid:` as well as `undef:`, because converting UTF-8 to
138
+ # UTF-8 leaves invalid bytes alone unless they are named.
139
+ html = nodes.to_html.encode('UTF-8', invalid: :replace, undef: :replace)
140
+ html.strip.empty? ? nil : html
141
+ end
142
+
143
+ # Read the page and parse it under the encoding it is actually written in.
144
+ #
145
+ # The page is handed to the parser as a stream rather than as a decoded
146
+ # string. open-uri applies an encoding to what it returns whether or not
147
+ # the response declared one -- a page served as `text/html` with no charset
148
+ # comes back tagged UTF-8 -- and a decoded string tells the parser to
149
+ # believe that tag and never look at the meta tag underneath it. The
150
+ # database is full of sites that declare their charset only in a meta tag,
151
+ # and for those the difference is the whole article in mojibake.
152
+ #
153
+ # A record's own `enc` is the last resort, for a page that declares nothing
154
+ # anywhere: it was recorded in 2013, and trusting it ahead of what the page
155
+ # says would break every site that has changed encoding since.
156
+ def document(link, entry)
157
+ page, declared = Automatic::Http.open(link) { |io| [io.read, declared_charset?(io)] }
158
+ parsed = Nokogiri::HTML.parse(StringIO.new(page))
159
+ return parsed if declared || parsed.meta_encoding || entry.encoding.nil?
160
+
161
+ Nokogiri::HTML.parse(StringIO.new(page), nil, entry.encoding)
162
+ end
163
+
164
+ # Whether the response itself named a charset, as opposed to open-uri
165
+ # having settled on one in the absence of an answer.
166
+ def declared_charset?(io)
167
+ return false unless io.respond_to?(:meta)
168
+
169
+ io.meta['content-type'].to_s.match?(/;\s*charset\s*=/i)
170
+ end
171
+
172
+ # An encoding named by a siteinfo record. An unknown name is ignored rather
173
+ # than raised, and the page is left to speak for itself.
174
+ def charset(name)
175
+ string = name.to_s.strip
176
+ return nil if string.empty?
177
+
178
+ Encoding.find(string).name
179
+ rescue ArgumentError
180
+ nil
75
181
  end
76
182
  end
77
183
  end