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,216 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::Gemini
|
|
3
|
+
# Description:: Replace each item's description with what the Google Gemini 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 Gemini API under the
|
|
13
|
+
# Recipe's prompt, and the answer becomes the item's description. What that
|
|
14
|
+
# 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 Gemini and nothing else. Its model is named in the URL
|
|
18
|
+
# rather than in the body, its API key is a header of its own, and its request
|
|
19
|
+
# and answer are built of `contents` and `parts`. None of that is bent into
|
|
20
|
+
# another service's shape, and no other service's request is built here.
|
|
21
|
+
#
|
|
22
|
+
# It speaks `generateContent`, which Google states remains fully supported and
|
|
23
|
+
# is the single-turn text interface: a request that carries an instruction and
|
|
24
|
+
# a text and answers with a text, which is exactly what this plugin does.
|
|
25
|
+
# @see https://ai.google.dev/api/generate-content
|
|
26
|
+
|
|
27
|
+
module Automatic::Plugin
|
|
28
|
+
class FilterGemini
|
|
29
|
+
require 'json'
|
|
30
|
+
require 'net/http'
|
|
31
|
+
require 'openssl'
|
|
32
|
+
require 'uri'
|
|
33
|
+
|
|
34
|
+
# The model is part of the path here, unlike every other service in this
|
|
35
|
+
# directory, so the endpoint is built per Recipe rather than being one
|
|
36
|
+
# constant.
|
|
37
|
+
ENDPOINT_FORMAT = 'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent'.freeze
|
|
38
|
+
|
|
39
|
+
OPEN_TIMEOUT = 10
|
|
40
|
+
|
|
41
|
+
# Generous, and bounded. A model given several articles thinks for a while;
|
|
42
|
+
# an unattended run that waits forever is the failure this exists against.
|
|
43
|
+
READ_TIMEOUT = 300
|
|
44
|
+
|
|
45
|
+
# A failure that another attempt will not get past: a setting that is
|
|
46
|
+
# wrong, a request the service refuses, an answer this plugin cannot read.
|
|
47
|
+
class Error < StandardError; end
|
|
48
|
+
|
|
49
|
+
# A failure that another attempt may get past: the network, a rate limit, a
|
|
50
|
+
# server error.
|
|
51
|
+
class TemporaryError < StandardError; end
|
|
52
|
+
|
|
53
|
+
def initialize(config, pipeline = [])
|
|
54
|
+
@config = config || {}
|
|
55
|
+
@pipeline = pipeline
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Replaces each item's description with the answer. Nothing else about an
|
|
59
|
+
# item is touched, and the feeds and their items arrive and leave in the
|
|
60
|
+
# same order and number.
|
|
61
|
+
def run
|
|
62
|
+
validate_settings
|
|
63
|
+
|
|
64
|
+
@pipeline.each { |feeds|
|
|
65
|
+
next if feeds.nil?
|
|
66
|
+
|
|
67
|
+
feeds.items.each { |item| transform(item) }
|
|
68
|
+
}
|
|
69
|
+
@pipeline
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Checked before the first request, because a Recipe this plugin cannot
|
|
75
|
+
# carry out is the operator's mistake and will be the same mistake on every
|
|
76
|
+
# item. The token is never named in a message.
|
|
77
|
+
def validate_settings
|
|
78
|
+
raise ArgumentError, 'FilterGemini needs a token' if token.empty?
|
|
79
|
+
raise ArgumentError, 'FilterGemini needs a model' if model.empty?
|
|
80
|
+
raise ArgumentError, 'FilterGemini needs a prompt' if prompt.empty?
|
|
81
|
+
|
|
82
|
+
endpoint
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def token
|
|
86
|
+
@config['token'].to_s
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def model
|
|
90
|
+
@config['model'].to_s.strip
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def prompt
|
|
94
|
+
@config['prompt'].to_s.strip
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A model name that cannot go in a URL is the Recipe's mistake, and is
|
|
98
|
+
# reported as one rather than as a failed request.
|
|
99
|
+
def endpoint
|
|
100
|
+
@endpoint ||= URI(format(ENDPOINT_FORMAT, model))
|
|
101
|
+
rescue URI::InvalidURIError
|
|
102
|
+
raise ArgumentError, "FilterGemini cannot build a request for the model #{model}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def transform(item)
|
|
106
|
+
text = item.description.to_s
|
|
107
|
+
if text.strip.empty?
|
|
108
|
+
Automatic::Log.puts('warn', "FilterGemini: nothing to send for #{item.link}")
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
Automatic::Log.puts('info', "FilterGemini: asking #{model} about #{item.link}")
|
|
113
|
+
item.description = answer(text)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The retry shape of doc/PLUGINS.md section 3.6, applied only to what
|
|
117
|
+
# retrying can help. A missing setting, a refused request or an answer in a
|
|
118
|
+
# shape this plugin cannot read is raised at once: trying again would fail
|
|
119
|
+
# the same way, more slowly.
|
|
120
|
+
def answer(text)
|
|
121
|
+
retries = 0
|
|
122
|
+
retry_max = @config['retry'].to_i
|
|
123
|
+
begin
|
|
124
|
+
generated(text)
|
|
125
|
+
rescue TemporaryError => e
|
|
126
|
+
retries += 1
|
|
127
|
+
Automatic::Log.puts('error', "ErrorCount: #{retries}, FilterGemini: #{e.message}")
|
|
128
|
+
if retries <= retry_max
|
|
129
|
+
sleep(@config['interval'].to_i)
|
|
130
|
+
retry
|
|
131
|
+
end
|
|
132
|
+
raise Error, "FilterGemini gave up after #{retries} attempts: #{e.message}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def generated(text)
|
|
137
|
+
# The prompt is the system instruction and the description is the content
|
|
138
|
+
# it is applied to. They are separate fields, so that what an article
|
|
139
|
+
# says is never read as an instruction to this plugin or to the model.
|
|
140
|
+
body = {
|
|
141
|
+
'system_instruction' => { 'parts' => [{ 'text' => prompt }] },
|
|
142
|
+
'contents' => [{ 'role' => 'user', 'parts' => [{ 'text' => text }] }]
|
|
143
|
+
}
|
|
144
|
+
content(post(JSON.generate(body)))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def post(body)
|
|
148
|
+
request = Net::HTTP::Post.new(endpoint)
|
|
149
|
+
# The key goes in a header rather than in the query string, which is the
|
|
150
|
+
# way Google documents and the way that keeps a credential out of a URL.
|
|
151
|
+
request['x-goog-api-key'] = token
|
|
152
|
+
request['Content-Type'] = 'application/json'
|
|
153
|
+
request.body = body
|
|
154
|
+
|
|
155
|
+
# TLS with the certificate verified, which is Net::HTTP's own default and
|
|
156
|
+
# is named here because it is not a thing to be turned off.
|
|
157
|
+
Net::HTTP.start(endpoint.host, endpoint.port,
|
|
158
|
+
use_ssl: true,
|
|
159
|
+
verify_mode: OpenSSL::SSL::VERIFY_PEER,
|
|
160
|
+
open_timeout: OPEN_TIMEOUT,
|
|
161
|
+
read_timeout: READ_TIMEOUT) { |http| http.request(request) }
|
|
162
|
+
rescue Timeout::Error, SystemCallError, SocketError, IOError,
|
|
163
|
+
OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
|
|
164
|
+
raise TemporaryError, "the request to Gemini failed: #{e.message}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def content(response)
|
|
168
|
+
case response
|
|
169
|
+
when Net::HTTPSuccess
|
|
170
|
+
answer_text(parse(response.body))
|
|
171
|
+
when Net::HTTPTooManyRequests, Net::HTTPServerError
|
|
172
|
+
raise TemporaryError, "Gemini answered #{response.code}: #{reason(response)}"
|
|
173
|
+
else
|
|
174
|
+
raise Error, "Gemini answered #{response.code}: #{reason(response)}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def parse(body)
|
|
179
|
+
JSON.parse(body.to_s)
|
|
180
|
+
rescue JSON::ParserError => e
|
|
181
|
+
raise Error, "Gemini answered with something that is not JSON: #{e.message}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The text of the first candidate. A response with no candidate is what a
|
|
185
|
+
# request stopped by a safety filter looks like, and it is an error rather
|
|
186
|
+
# than an empty description: a Recipe that published the empty string here
|
|
187
|
+
# would have thrown the article away and reported success.
|
|
188
|
+
def answer_text(body)
|
|
189
|
+
candidates = body['candidates']
|
|
190
|
+
unless candidates.is_a?(Array) && candidates.first.is_a?(Hash)
|
|
191
|
+
raise Error, 'Gemini answered without a candidate'
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
parts = candidates.first.dig('content', 'parts')
|
|
195
|
+
raise Error, 'Gemini answered without content parts' unless parts.is_a?(Array)
|
|
196
|
+
|
|
197
|
+
text = parts.select { |part| part.is_a?(Hash) }.map { |part| part['text'].to_s }.join.strip
|
|
198
|
+
raise Error, 'Gemini answered with no text' if text.empty?
|
|
199
|
+
|
|
200
|
+
text
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The service's own explanation where it gave one, the status line
|
|
204
|
+
# otherwise. Neither carries the token, and the settings are never logged
|
|
205
|
+
# or raised wholesale.
|
|
206
|
+
def reason(response)
|
|
207
|
+
body = JSON.parse(response.body.to_s)
|
|
208
|
+
error = body['error']
|
|
209
|
+
return error['message'].to_s if error.is_a?(Hash) && !error['message'].to_s.empty?
|
|
210
|
+
|
|
211
|
+
response.message.to_s
|
|
212
|
+
rescue JSON::ParserError
|
|
213
|
+
response.message.to_s
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -1,37 +1,49 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::GithubFeed
|
|
3
|
+
# Author: Kohei Hasegawa (More info: http://github.com/banyan)
|
|
4
|
+
# Source Code:: https://github.com/id774/automaticruby
|
|
5
|
+
# License:: The GPL version 3, or LGPL version 3 (Dual License).
|
|
6
|
+
# Contact:: idnanashi@gmail.com
|
|
7
|
+
# Created:: Jun 6, 2013
|
|
8
|
+
# Updated:: Aug 15, 2026
|
|
9
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
9
10
|
|
|
10
11
|
module Automatic::Plugin
|
|
11
12
|
class FilterGithubFeed
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@config = config
|
|
13
|
+
def initialize(config, pipeline = [])
|
|
14
|
+
@config = config || {}
|
|
15
15
|
@pipeline = pipeline
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# Converts Atom entries -- where title, id and content are elements with a
|
|
19
|
+
# #content -- into the flat items the rest of the pipeline expects. Needed
|
|
20
|
+
# because GitHub publishes Atom, not RSS.
|
|
18
21
|
def run
|
|
19
|
-
@
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
22
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
23
|
+
items = feeds.nil? ? [] : feeds.items.map { |item| flatten(item) }
|
|
24
|
+
returned << Automatic::FeedMaker.create_pipeline(items)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def flatten(item)
|
|
31
|
+
Automatic::Log.puts('info', 'Invoked: FilterGithubFeed')
|
|
32
|
+
entry = Hashie::Mash.new
|
|
33
|
+
entry.title = value(item, :title)
|
|
34
|
+
entry.link = value(item, :id)
|
|
35
|
+
entry.description = value(item, :content)
|
|
36
|
+
entry
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# An Atom element carries its text in #content; a field that is already a
|
|
40
|
+
# string is used as it stands, so that a pipeline which has been through
|
|
41
|
+
# another filter first is not a NoMethodError.
|
|
42
|
+
def value(item, name)
|
|
43
|
+
return nil unless item.respond_to?(name)
|
|
44
|
+
|
|
45
|
+
field = item.send(name)
|
|
46
|
+
field.respond_to?(:content) ? field.content : field
|
|
35
47
|
end
|
|
36
48
|
end
|
|
37
49
|
end
|
data/plugins/filter/ignore.rb
CHANGED
|
@@ -1,63 +1,52 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::Ignore
|
|
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:: Feb 22, 2012
|
|
8
|
+
# Updated:: Aug 15, 2026
|
|
9
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
8
10
|
|
|
9
11
|
module Automatic::Plugin
|
|
10
12
|
class FilterIgnore
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
FIELDS = %i[title link description].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(config, pipeline = [])
|
|
16
|
+
@config = config || {}
|
|
13
17
|
@pipeline = pipeline
|
|
14
18
|
end
|
|
15
19
|
|
|
20
|
+
# Drops items containing any listed keyword. Matching is a substring test,
|
|
21
|
+
# so an empty keyword drops everything.
|
|
16
22
|
def run
|
|
17
|
-
@
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
feeds.items.each {|items|
|
|
22
|
-
new_feeds << items if exclude(items) == false
|
|
23
|
-
}
|
|
24
|
-
end
|
|
25
|
-
@return_feeds << Automatic::FeedMaker.create_pipeline(new_feeds) if new_feeds.length > 0
|
|
26
|
-
}
|
|
27
|
-
@return_feeds
|
|
23
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
24
|
+
kept = feeds.nil? ? [] : feeds.items.reject { |item| exclude?(item) }
|
|
25
|
+
returned << Automatic::FeedMaker.create_pipeline(kept) unless kept.empty?
|
|
26
|
+
end
|
|
28
27
|
end
|
|
29
28
|
|
|
30
29
|
private
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
|
|
31
|
+
def exclude?(item)
|
|
32
|
+
FIELDS.any? do |field|
|
|
33
|
+
Array(@config[field.to_s]).any? do |keyword|
|
|
34
|
+
excluded?(item.send(field), keyword.to_s.chomp, field)
|
|
36
35
|
end
|
|
37
|
-
rescue NoMethodError
|
|
38
|
-
Automatic::Log.puts("warn", "Invalid feed detected in ignore process with #{item}")
|
|
39
|
-
return false
|
|
40
36
|
end
|
|
41
37
|
end
|
|
42
38
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
unless
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
end
|
|
50
|
-
unless @config['link'].nil?
|
|
51
|
-
@config['link'].each {|e|
|
|
52
|
-
detection = true if detect_exclude(items.link, e.chomp, 'link')
|
|
53
|
-
}
|
|
54
|
-
end
|
|
55
|
-
unless @config['description'].nil?
|
|
56
|
-
@config['description'].each {|e|
|
|
57
|
-
detection = true if detect_exclude(items.description, e.chomp, 'description')
|
|
58
|
-
}
|
|
39
|
+
# An item whose field is missing is kept, with a warning.
|
|
40
|
+
def excluded?(value, keyword, field)
|
|
41
|
+
unless value.respond_to?(:include?)
|
|
42
|
+
Automatic::Log.puts('warn', "Invalid feed detected in ignore process with #{value}")
|
|
43
|
+
return false
|
|
59
44
|
end
|
|
60
|
-
|
|
45
|
+
|
|
46
|
+
return false unless value.include?(keyword)
|
|
47
|
+
|
|
48
|
+
Automatic::Log.puts('info', "Excluded by #{field}: #{value}")
|
|
49
|
+
true
|
|
61
50
|
end
|
|
62
51
|
end
|
|
63
52
|
end
|
data/plugins/filter/image.rb
CHANGED
|
@@ -1,42 +1,53 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::Image
|
|
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:: Sep 18, 2012
|
|
8
|
+
# Updated:: Aug 15, 2026
|
|
9
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
8
10
|
|
|
9
11
|
module Automatic::Plugin
|
|
10
12
|
class FilterImage
|
|
13
|
+
require 'uri'
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
# webp and avif are here because they are what an image link on the
|
|
16
|
+
# current web frequently is; tif joins tiff for the same reason.
|
|
17
|
+
EXTENSIONS = /\.(jpe?g|gif|png|tiff?|webp|avif)\z/i
|
|
18
|
+
|
|
19
|
+
def initialize(config, pipeline = [])
|
|
20
|
+
@config = config || {}
|
|
14
21
|
@pipeline = pipeline
|
|
15
22
|
end
|
|
16
23
|
|
|
24
|
+
# Sets link to nil unless it names an image. Note that the items are kept:
|
|
25
|
+
# their links are blanked, and the plugins after this one skip an item
|
|
26
|
+
# whose link is nil.
|
|
17
27
|
def run
|
|
18
|
-
@
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
feed.link = image?(feed.link) unless feed.link.nil?
|
|
24
|
-
}
|
|
25
|
-
@return_feeds << feeds
|
|
28
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
29
|
+
next if feeds.nil?
|
|
30
|
+
|
|
31
|
+
feeds.items.each do |item|
|
|
32
|
+
item.link = nil unless item.link.nil? || image?(item.link)
|
|
26
33
|
end
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
returned << feeds
|
|
35
|
+
end
|
|
29
36
|
end
|
|
30
37
|
|
|
31
38
|
private
|
|
39
|
+
|
|
40
|
+
# The test is on the path, so that a link carrying a query string -- which
|
|
41
|
+
# is how an image is served by most of what serves images now -- is still
|
|
42
|
+
# recognised. A string that will not parse is tested whole, as before.
|
|
32
43
|
def image?(link)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
EXTENSIONS.match?(path(link))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def path(link)
|
|
48
|
+
URI.parse(link).path.to_s
|
|
49
|
+
rescue URI::InvalidURIError
|
|
50
|
+
link
|
|
40
51
|
end
|
|
41
52
|
end
|
|
42
53
|
end
|
|
@@ -1,71 +1,77 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::ImageSource
|
|
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:: Feb 28, 2012
|
|
8
|
+
# Updated:: Aug 15, 2026
|
|
9
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
8
10
|
|
|
9
11
|
module Automatic::Plugin
|
|
10
12
|
class FilterImageSource
|
|
11
|
-
|
|
12
|
-
require '
|
|
13
|
+
Automatic.require_optional('nokogiri', needed_by: 'FilterImageSource')
|
|
14
|
+
require 'uri'
|
|
13
15
|
|
|
14
|
-
def initialize(config, pipeline=[])
|
|
15
|
-
@config
|
|
16
|
+
def initialize(config, pipeline = [])
|
|
17
|
+
@config = config || {}
|
|
16
18
|
@pipeline = pipeline
|
|
17
19
|
end
|
|
18
20
|
|
|
21
|
+
# Replaces each item with one item per image found: the images in the
|
|
22
|
+
# description, or, where it has none, the images on the page the link
|
|
23
|
+
# points at. The second case reaches the network.
|
|
19
24
|
def run
|
|
20
|
-
@
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
feeds.items.each {|feed|
|
|
25
|
-
arr = rewrite_link(feed)
|
|
26
|
-
if arr.length > 0
|
|
27
|
-
arr.each {|link|
|
|
28
|
-
Automatic::Log.puts("info", "Extract Image: #{link}")
|
|
29
|
-
hashie = Hashie::Mash.new
|
|
30
|
-
hashie.title = 'FilterImageSource'
|
|
31
|
-
hashie.link = link
|
|
32
|
-
new_feeds << hashie
|
|
33
|
-
}
|
|
34
|
-
end
|
|
35
|
-
}
|
|
36
|
-
end
|
|
37
|
-
@return_feeds << Automatic::FeedMaker.create_pipeline(new_feeds)
|
|
38
|
-
}
|
|
39
|
-
@return_feeds
|
|
25
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
26
|
+
items = feeds.nil? ? [] : feeds.items.flat_map { |item| extract(item) }
|
|
27
|
+
returned << Automatic::FeedMaker.create_pipeline(items)
|
|
28
|
+
end
|
|
40
29
|
end
|
|
41
30
|
|
|
42
31
|
private
|
|
43
32
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
33
|
+
def extract(item)
|
|
34
|
+
images(item).map do |link|
|
|
35
|
+
Automatic::Log.puts('info', "Extract Image: #{link}")
|
|
36
|
+
image = Hashie::Mash.new
|
|
37
|
+
image.title = 'FilterImageSource'
|
|
38
|
+
image.link = link
|
|
39
|
+
image
|
|
51
40
|
end
|
|
52
|
-
array
|
|
53
41
|
end
|
|
54
42
|
|
|
55
|
-
def
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
43
|
+
def images(item)
|
|
44
|
+
found = sources(item.description.to_s, item.link)
|
|
45
|
+
return found unless found.empty?
|
|
46
|
+
return [] if item.link.nil?
|
|
47
|
+
|
|
48
|
+
page_images(item.link)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def page_images(link)
|
|
52
|
+
sources(Automatic::Http.read(link), link)
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
Automatic::Log.puts('warn', "Failed to read images from #{link}: #{e.message}")
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The images of an HTML fragment, as absolute URLs. This was a scan for
|
|
59
|
+
# `<img src="` before, which found nothing in a document quoting its
|
|
60
|
+
# attributes with apostrophes or writing src after another attribute; the
|
|
61
|
+
# parser this plugin already needs answers the question properly.
|
|
62
|
+
def sources(html, base)
|
|
63
|
+
Nokogiri::HTML.fragment(html).css('img').filter_map do |image|
|
|
64
|
+
source = image['src'].to_s.strip
|
|
65
|
+
absolute(source, base) unless source.empty?
|
|
66
|
+
end.uniq
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def absolute(source, base)
|
|
70
|
+
return source if base.nil?
|
|
71
|
+
|
|
72
|
+
URI.join(base, source).to_s
|
|
73
|
+
rescue StandardError
|
|
74
|
+
source
|
|
69
75
|
end
|
|
70
76
|
end
|
|
71
77
|
end
|