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.
- checksums.yaml +5 -5
- data/README.md +635 -83
- data/VERSION +1 -1
- data/automatic.gemspec +109 -248
- data/bin/automatic +20 -139
- data/config/feed2console.yml +10 -4
- data/config/feed2markdown.yml +41 -0
- data/doc/AI_TUTORIAL.md +518 -0
- data/doc/BASIC_DESIGN.md +516 -0
- data/doc/COPYING.LESSER +165 -0
- data/doc/DEPLOYMENT.md +824 -0
- data/doc/LICENSE.md +14 -0
- data/doc/PLUGINS.md +1875 -0
- data/doc/PLUGIN_DEVELOPMENT.md +86 -0
- data/doc/POLICY.md +857 -0
- data/doc/QUICKSTART.md +256 -0
- data/doc/RELEASING.md +381 -0
- data/doc/REQUIREMENTS.md +526 -0
- data/doc/VERSIONS +208 -0
- data/lib/automatic/cli.rb +248 -0
- data/lib/automatic/environment.rb +31 -5
- data/lib/automatic/feed_maker.rb +10 -9
- data/lib/automatic/feed_parser.rb +51 -35
- data/lib/automatic/http.rb +107 -0
- data/lib/automatic/log.rb +49 -18
- data/lib/automatic/opml.rb +3 -1
- data/lib/automatic/pipeline.rb +63 -32
- data/lib/automatic/recipe.rb +56 -17
- data/lib/automatic/version.rb +14 -1
- data/lib/automatic.rb +78 -20
- data/plugins/custom_feed/svn_log.rb +73 -32
- data/plugins/custom_feed/web.rb +348 -0
- data/plugins/filter/absolute_uri.rb +43 -27
- data/plugins/filter/accept.rb +38 -45
- data/plugins/filter/claude.rb +217 -0
- data/plugins/filter/clear.rb +12 -8
- data/plugins/filter/description_link.rb +49 -51
- data/plugins/filter/full_feed.rb +158 -52
- data/plugins/filter/gemini.rb +216 -0
- data/plugins/filter/github_feed.rb +38 -26
- data/plugins/filter/ignore.rb +33 -44
- data/plugins/filter/image.rb +36 -25
- data/plugins/filter/image_source.rb +58 -52
- data/plugins/filter/join.rb +107 -0
- data/plugins/filter/one.rb +19 -26
- data/plugins/filter/open_ai.rb +198 -0
- data/plugins/filter/rand.rb +16 -17
- data/plugins/filter/sakura_ai.rb +205 -0
- data/plugins/filter/sanitize.rb +29 -34
- data/plugins/filter/sort.rb +20 -27
- data/plugins/filter/tumblr_resize.rb +31 -23
- data/plugins/notify/ikachan.rb +86 -48
- data/plugins/provide/fluentd.rb +43 -24
- data/plugins/publish/amazon_s3.rb +73 -40
- data/plugins/publish/console.rb +19 -16
- data/plugins/publish/console_link.rb +20 -17
- data/plugins/publish/eject.rb +48 -26
- data/plugins/publish/fluentd.rb +50 -30
- data/plugins/publish/hatena_bookmark.rb +88 -71
- data/plugins/publish/instapaper.rb +69 -59
- data/plugins/publish/markdown.rb +278 -0
- data/plugins/publish/memcached.rb +35 -29
- data/plugins/store/database.rb +50 -48
- data/plugins/store/digest.rb +212 -0
- data/plugins/store/file.rb +99 -68
- data/plugins/store/full_text.rb +32 -25
- data/plugins/store/permalink.rb +18 -22
- data/plugins/subscription/feed.rb +34 -21
- data/plugins/subscription/link.rb +31 -32
- data/plugins/subscription/text.rb +32 -46
- data/plugins/subscription/tumblr.rb +55 -44
- data/plugins/subscription/xml.rb +40 -36
- metadata +108 -321
- data/Gemfile +0 -38
- data/Rakefile +0 -59
- data/doc/ChangeLog +0 -303
- data/doc/PLUGINS +0 -750
- data/doc/PLUGINS.ja +0 -753
- data/doc/README +0 -511
- data/doc/README.ja +0 -519
- data/plugins/filter/google_news.rb +0 -50
- data/plugins/publish/google_calendar.rb +0 -84
- data/plugins/publish/hipchat.rb +0 -46
- data/plugins/publish/pocket.rb +0 -45
- data/plugins/publish/twitter.rb +0 -58
- data/plugins/subscription/chan_toru.rb +0 -57
- data/plugins/subscription/g_guide.rb +0 -57
- data/plugins/subscription/pocket.rb +0 -51
- data/plugins/subscription/twitter.rb +0 -69
- data/plugins/subscription/twitter_search.rb +0 -50
- data/plugins/subscription/weather.rb +0 -33
- data/script/build +0 -84
- data/spec/fixtures/sampleFeeds.tsv +0 -1
- data/spec/fixtures/sampleFeeds2.tsv +0 -2
- data/spec/fixtures/sampleRecipe.yml +0 -24
- data/spec/lib/automatic/log_spec.rb +0 -32
- data/spec/lib/automatic/pipeline_spec.rb +0 -68
- data/spec/lib/automatic/recipe_spec.rb +0 -40
- data/spec/lib/automatic_spec.rb +0 -99
- data/spec/plugins/custom_feed/svn_log_spec.rb +0 -31
- data/spec/plugins/filter/absolute_uri_spec.rb +0 -61
- data/spec/plugins/filter/accept_spec.rb +0 -331
- data/spec/plugins/filter/clear_spec.rb +0 -49
- data/spec/plugins/filter/description_link_spec.rb +0 -138
- data/spec/plugins/filter/full_feed_spec.rb +0 -129
- data/spec/plugins/filter/github_feed_spec.rb +0 -55
- data/spec/plugins/filter/google_news_spec.rb +0 -69
- data/spec/plugins/filter/ignore_spec.rb +0 -328
- data/spec/plugins/filter/image_source_spec.rb +0 -89
- data/spec/plugins/filter/image_spec.rb +0 -65
- data/spec/plugins/filter/one_spec.rb +0 -71
- data/spec/plugins/filter/rand_spec.rb +0 -52
- data/spec/plugins/filter/sanitize_spec.rb +0 -153
- data/spec/plugins/filter/sort_spec.rb +0 -189
- data/spec/plugins/filter/tumblr_resize_spec.rb +0 -109
- data/spec/plugins/notify/ikachan_spec.rb +0 -58
- data/spec/plugins/provide/fluentd_spec.rb +0 -49
- data/spec/plugins/publish/amazon_s3_spec.rb +0 -40
- data/spec/plugins/publish/console_spec.rb +0 -30
- data/spec/plugins/publish/eject_spec.rb +0 -40
- data/spec/plugins/publish/fluentd_spec.rb +0 -40
- data/spec/plugins/publish/google_calendar_spec.rb +0 -83
- data/spec/plugins/publish/hatena_bookmark_spec.rb +0 -134
- data/spec/plugins/publish/hipchat_spec.rb +0 -69
- data/spec/plugins/publish/instapaper_spec.rb +0 -82
- data/spec/plugins/publish/memcached_spec.rb +0 -63
- data/spec/plugins/publish/pocket_spec.rb +0 -51
- data/spec/plugins/publish/twitter_spec.rb +0 -73
- data/spec/plugins/store/file_spec.rb +0 -58
- data/spec/plugins/store/full_text_spec.rb +0 -152
- data/spec/plugins/store/permalink_spec.rb +0 -206
- data/spec/plugins/subscription/chan_toru_spec.rb +0 -56
- data/spec/plugins/subscription/feed_spec.rb +0 -71
- data/spec/plugins/subscription/g_guide_spec.rb +0 -82
- data/spec/plugins/subscription/link_spec.rb +0 -72
- data/spec/plugins/subscription/pocket_spec.rb +0 -57
- data/spec/plugins/subscription/text_spec.rb +0 -84
- data/spec/plugins/subscription/tumblr_spec.rb +0 -74
- data/spec/plugins/subscription/twitter_search_spec.rb +0 -57
- data/spec/plugins/subscription/twitter_spec.rb +0 -73
- data/spec/plugins/subscription/weather_spec.rb +0 -44
- data/spec/plugins/subscription/xml_spec.rb +0 -84
- data/spec/spec_helper.rb +0 -106
- data/spec/user_dir/plugins/store/mock.rb +0 -16
- data/test/fixtures/sampleOPML.xml +0 -11
- data/test/integration/test_absoluteurl.yml +0 -25
- data/test/integration/test_activerecord.yml +0 -24
- data/test/integration/test_add_pocket.yml +0 -26
- data/test/integration/test_chan_toru.yml +0 -21
- data/test/integration/test_descriptionlink.yml +0 -21
- data/test/integration/test_fluentd.yml +0 -22
- data/test/integration/test_fulltext.yml +0 -30
- data/test/integration/test_google_news.yml +0 -21
- data/test/integration/test_googlealert.yml +0 -21
- data/test/integration/test_hatenabookmark.yml +0 -30
- data/test/integration/test_ignore.yml +0 -25
- data/test/integration/test_ignore2.yml +0 -22
- data/test/integration/test_image2local.yml +0 -33
- data/test/integration/test_instapaper.yml +0 -26
- data/test/integration/test_link2local.yml +0 -34
- data/test/integration/test_one.yml +0 -23
- data/test/integration/test_pocket.yml +0 -22
- data/test/integration/test_rand.yml +0 -21
- data/test/integration/test_sanitize.yml +0 -23
- data/test/integration/test_sort.yml +0 -36
- data/test/integration/test_svnlog.yml +0 -15
- data/test/integration/test_text2feed.yml +0 -36
- data/test/integration/test_tumblr2local.yml +0 -43
- data/test/integration/test_twitter_search.yml +0 -22
- data/test/integration/test_weather.yml +0 -19
- data/test/integration/test_xml2fluentd.yml +0 -21
- 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
|
data/plugins/filter/clear.rb
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
|
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::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
@
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
45
|
+
item
|
|
49
46
|
end
|
|
50
47
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
def retitle(item)
|
|
57
|
+
title = fetch_title(item.link)
|
|
58
|
+
item.title = title unless title.nil? || title.empty?
|
|
59
|
+
end
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
data/plugins/filter/full_feed.rb
CHANGED
|
@@ -1,77 +1,183 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
26
|
+
def initialize(config, pipeline = [])
|
|
27
|
+
@config = config || {}
|
|
19
28
|
@pipeline = pipeline
|
|
20
|
-
@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
|
-
@
|
|
25
|
-
@pipeline.each {|feeds|
|
|
35
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
26
36
|
unless feeds.nil?
|
|
27
|
-
feeds.items.each {|
|
|
28
|
-
feed = fulltext(feed)
|
|
29
|
-
}
|
|
37
|
+
feeds.items.each { |item| fulltext(item) }
|
|
30
38
|
end
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@return_feeds
|
|
39
|
+
returned << feeds
|
|
40
|
+
end
|
|
34
41
|
end
|
|
35
42
|
|
|
36
43
|
private
|
|
37
44
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 =
|
|
51
|
-
if File.directory?(dir)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
@siteinfo.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
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
|