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,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: 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 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: 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:: 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,89 @@
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: 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:: Oct 03, 2014
8
+ # Updated:: Aug 24, 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')
44
+
45
+ item
46
+ end
47
47
 
48
- new_title
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'
49
54
  end
50
55
 
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?
56
+ def retitle(item)
57
+ title = fetch_title(item.link)
58
+ item.title = title unless title.nil? || title.empty?
59
+ end
54
60
 
55
- if @config.class == Hash
56
- if @config['clear_description'] == 1
57
- feed.description = ""
58
- 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)
59
65
 
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
66
+ Nokogiri::HTML.parse(page(url)).xpath('//title').text
67
+ rescue StandardError => e
68
+ Automatic::Log.puts('warn', "Failed in get title for: #{url}, #{e.message}")
69
+ nil
70
+ end
69
71
 
70
- feed
72
+ # The one place this plugin actually reaches the network. `wait` runs in
73
+ # the `ensure` so that a fetch attempt is waited out whether it succeeded
74
+ # or raised -- a URL that is not fetchable never gets here at all, and so
75
+ # never waits.
76
+ def page(url)
77
+ Automatic::Http.read(url)
78
+ ensure
79
+ wait
80
+ end
81
+
82
+ # `interval` seconds after a real fetch attempt, positive values only. See
83
+ # doc/PLUGINS.md section 6.3.
84
+ def wait
85
+ seconds = @config['interval'].to_i
86
+ sleep(seconds) if seconds.positive?
71
87
  end
72
88
  end
73
89
  end
74
-
@@ -1,77 +1,195 @@
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 24, 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. Compiling patterns when the database is
21
+ # loaded avoids rebuilding regular expressions for every item.
22
+ Entry = Struct.new(:url, :pattern, :xpath, :encoding)
16
23
 
17
- def initialize(config, pipeline=[])
18
- @config = config
24
+ def initialize(config, pipeline = [])
25
+ @config = config || {}
19
26
  @pipeline = pipeline
20
- @siteinfo = get_siteinfo
27
+ @siteinfo = siteinfo
21
28
  end
22
29
 
30
+ # Replaces a summary with the article body, by matching the link against a
31
+ # siteinfo database of URL patterns and XPaths and fetching the page.
23
32
  def run
24
- @return_feeds = []
25
- @pipeline.each {|feeds|
33
+ @pipeline.each_with_object([]) do |feeds, returned|
26
34
  unless feeds.nil?
27
- feeds.items.each {|feed|
28
- feed = fulltext(feed)
29
- }
35
+ feeds.items.each { |item| fulltext(item) }
30
36
  end
31
- @return_feeds << feeds
32
- }
33
- @return_feeds
37
+ returned << feeds
38
+ end
34
39
  end
35
40
 
36
41
  private
37
42
 
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
43
+ def siteinfo
44
+ name = @config['siteinfo'].to_s
45
+ raise ArgumentError, 'FilterFullFeed needs a siteinfo file name' if name.empty?
46
+
47
+ Automatic::Log.puts('info', "Loading siteinfo from #{name}")
48
+ records = JSON.parse(File.read(File.join(assets_dir, name), encoding: 'UTF-8'))
49
+ entries = records.select { |info| SITEINFO_TYPES.include?(info['data']['type']) }
50
+ .sort_by { |info| SITEINFO_TYPES.index(info['data']['type']) }
51
+ .filter_map { |info| entry(info['data']) }
52
+ Automatic::Log.puts('info', "Loaded #{entries.size} siteinfo entries")
53
+ entries
54
+ end
55
+
56
+ # A record is dropped here rather than failing a match later. A record
57
+ # without a URL pattern would match every link -- an empty pattern matches
58
+ # everything -- and one without an XPath has nothing to select with.
59
+ def entry(data)
60
+ url = data['url'].to_s
61
+ xpath = data['xpath'].to_s.strip
62
+ return nil if url.empty? || xpath.empty?
63
+
64
+ Entry.new(url, Regexp.new(url), xpath, charset(data['enc']))
65
+ rescue RegexpError
66
+ nil
47
67
  end
48
68
 
49
69
  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')
70
+ dir = File.expand_path('~/.automatic/assets/siteinfo')
71
+ return dir if File.directory?(dir)
72
+
73
+ File.expand_path('../../assets/siteinfo', __dir__)
74
+ end
75
+
76
+ def fulltext(item)
77
+ return if item.link.nil?
78
+
79
+ record = match(item.link)
80
+ if record.nil?
81
+ Automatic::Log.puts('info', "Fulltext SITEINFO not found: #{item.link}")
82
+ return
83
+ end
84
+
85
+ Automatic::Log.puts('info', "Siteinfo matched: #{record.url}")
86
+ html = body(item.link, record)
87
+ if html.nil?
88
+ # The page was read but the XPath selected nothing, which is what a
89
+ # site that has been redesigned since its record was written looks
90
+ # like. Assigning the empty result here is what used to replace a
91
+ # perfectly good summary with an empty description.
92
+ Automatic::Log.puts('warn', "Fulltext XPath selected nothing on #{item.link}: #{record.xpath}")
93
+ return
55
94
  end
95
+
96
+ item.description = html
97
+ rescue StandardError => e
98
+ # An unreadable page leaves the item's own summary in place, which is
99
+ # what this filter is an improvement on rather than a replacement for.
100
+ Automatic::Log.puts('warn', "Failed to read fulltext for #{item.link}: #{e.message}")
56
101
  end
57
102
 
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
103
+ def match(link)
104
+ links = schemes(link)
105
+ @siteinfo.find { |record| links.any? { |candidate| record.pattern.match?(candidate) } }
106
+ end
107
+
108
+ # A stored siteinfo pattern may name HTTP while a current feed supplies HTTPS,
109
+ # or the reverse. A record describes a site's layout rather than its current
110
+ # transport scheme, so matching tries both schemes. Only the candidate used
111
+ # for matching changes; the page is fetched from the original item link.
112
+ def schemes(link)
113
+ case link
114
+ when %r{\Ahttps://} then [link, link.sub(%r{\Ahttps://}, 'http://')]
115
+ when %r{\Ahttp://} then [link, link.sub(%r{\Ahttp://}, 'https://')]
116
+ else [link]
117
+ end
118
+ end
119
+
120
+ # Returns the selected body as UTF-8, or nil when the XPath selects
121
+ # nothing, so that the caller can tell an article apart from an empty
122
+ # result and keep the summary it already had.
123
+ def body(link, entry)
124
+ nodes = document(link, entry).xpath(entry.xpath)
125
+ return nil if nodes.empty?
126
+
127
+ # Normalised to UTF-8, and scrubbed rather than raised on. The parser
128
+ # returns a string in whatever encoding it settled on for the page, and
129
+ # a page whose declared charset is not the one it is written in is
130
+ # common enough in a database this old; what leaves here goes on to a
131
+ # publish plugin, which has no way to recover from a string it cannot
132
+ # encode. `invalid:` as well as `undef:`, because converting UTF-8 to
133
+ # UTF-8 leaves invalid bytes alone unless they are named.
134
+ html = nodes.to_html.encode('UTF-8', invalid: :replace, undef: :replace)
135
+ html.strip.empty? ? nil : html
136
+ end
137
+
138
+ # Read the page and parse it under the encoding it is actually written in.
139
+ #
140
+ # The page is handed to the parser as a stream rather than as a decoded
141
+ # string. open-uri applies an encoding to what it returns whether or not
142
+ # the response declared one -- a page served as `text/html` with no charset
143
+ # comes back tagged UTF-8 -- and a decoded string tells the parser to
144
+ # believe that tag and never look at the meta tag underneath it. The
145
+ # database is full of sites that declare their charset only in a meta tag,
146
+ # and for those the difference is the whole article in mojibake.
147
+ #
148
+ # A record's own `enc` is the last resort for a page that declares nothing.
149
+ # Page declarations take precedence because a site's encoding can change after
150
+ # a siteinfo record is written.
151
+ def document(link, entry)
152
+ page, declared = fetch_page(link)
153
+ parsed = Nokogiri::HTML.parse(StringIO.new(page))
154
+ return parsed if declared || parsed.meta_encoding || entry.encoding.nil?
155
+
156
+ Nokogiri::HTML.parse(StringIO.new(page), nil, entry.encoding)
157
+ end
158
+
159
+ # The one place this plugin actually reaches the network. `wait` runs in
160
+ # the `ensure` so that a fetch attempt is waited out whether it succeeded
161
+ # or raised -- an item with no link and an item whose siteinfo did not
162
+ # match never get here at all, and so never wait.
163
+ def fetch_page(link)
164
+ Automatic::Http.open(link) { |io| [io.read, declared_charset?(io)] }
165
+ ensure
166
+ wait
167
+ end
168
+
169
+ # `interval` seconds after a real fetch attempt, positive values only. See
170
+ # doc/PLUGINS.md section 6.3.
171
+ def wait
172
+ seconds = @config['interval'].to_i
173
+ sleep(seconds) if seconds.positive?
174
+ end
175
+
176
+ # Whether the response itself named a charset, as opposed to open-uri
177
+ # having settled on one in the absence of an answer.
178
+ def declared_charset?(io)
179
+ return false unless io.respond_to?(:meta)
180
+
181
+ io.meta['content-type'].to_s.match?(/;\s*charset\s*=/i)
182
+ end
183
+
184
+ # An encoding named by a siteinfo record. An unknown name is ignored rather
185
+ # than raised, and the page is left to speak for itself.
186
+ def charset(name)
187
+ string = name.to_s.strip
188
+ return nil if string.empty?
189
+
190
+ Encoding.find(string).name
191
+ rescue ArgumentError
192
+ nil
75
193
  end
76
194
  end
77
195
  end