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.
- checksums.yaml +5 -5
- data/README.md +669 -83
- data/VERSION +1 -1
- data/assets/siteinfo/items_all.json +60300 -52138
- data/automatic.gemspec +110 -248
- data/bin/automatic +20 -139
- data/config/feed2console.yml +10 -4
- data/config/feed2markdown.yml +41 -0
- data/doc/AI_TUTORIAL.md +542 -0
- data/doc/BASIC_DESIGN.md +547 -0
- data/doc/COPYING.LESSER +165 -0
- data/doc/DEPLOYMENT.md +830 -0
- data/doc/LICENSE.md +14 -0
- data/doc/PLUGINS.md +2015 -0
- data/doc/PLUGIN_DEVELOPMENT.md +86 -0
- data/doc/POLICY.md +962 -0
- data/doc/QUICKSTART.md +260 -0
- data/doc/RELEASING.md +394 -0
- data/doc/REQUIREMENTS.md +556 -0
- data/doc/VERSIONS +266 -0
- data/lib/automatic/cli.rb +270 -0
- data/lib/automatic/environment.rb +31 -5
- data/lib/automatic/feed_maker.rb +65 -25
- 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 +73 -32
- data/lib/automatic/recipe.rb +100 -17
- data/lib/automatic/version.rb +14 -1
- data/lib/automatic.rb +93 -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/batch.rb +97 -0
- data/plugins/filter/claude.rb +217 -0
- data/plugins/filter/clear.rb +12 -8
- data/plugins/filter/description_link.rb +66 -51
- data/plugins/filter/full_feed.rb +170 -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 +75 -52
- data/plugins/filter/join.rb +105 -0
- data/plugins/filter/kimi.rb +216 -0
- data/plugins/filter/limit.rb +57 -0
- data/plugins/filter/one.rb +19 -26
- data/plugins/filter/open_ai.rb +198 -0
- data/plugins/filter/present.rb +75 -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 +79 -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 +107 -68
- data/plugins/store/full_text.rb +31 -28
- 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 +40 -45
- data/plugins/subscription/tumblr.rb +55 -44
- data/plugins/subscription/xml.rb +40 -36
- metadata +112 -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,75 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::Present
|
|
3
|
+
# Description:: Keep items whose configured fields are all present.
|
|
4
|
+
# Author: id774 (More info: https://id774.net)
|
|
5
|
+
# Source Code:: https://github.com/id774/automaticruby
|
|
6
|
+
# License:: The GPL version 3, or LGPL version 3 (Dual License).
|
|
7
|
+
# Contact:: idnanashi@gmail.com
|
|
8
|
+
# Created:: Aug 24, 2026
|
|
9
|
+
# Updated:: Aug 24, 2026
|
|
10
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
11
|
+
|
|
12
|
+
module Automatic::Plugin
|
|
13
|
+
class FilterPresent
|
|
14
|
+
# The item fields presence may be checked over. `date` is not one, being
|
|
15
|
+
# absent from a plain summary as often as not, and `enclosure` is not
|
|
16
|
+
# either, being a structure rather than a value.
|
|
17
|
+
FIELDS = %w[title link description author comments source content_encoded].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(config, pipeline = [])
|
|
20
|
+
@config = config || {}
|
|
21
|
+
@pipeline = pipeline
|
|
22
|
+
@fields = validated_fields
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Keeps only the items where every configured field is present. A feed
|
|
26
|
+
# that kept nothing is dropped from the output rather than passed on empty.
|
|
27
|
+
def run
|
|
28
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
29
|
+
next if feeds.nil?
|
|
30
|
+
|
|
31
|
+
survivors = feeds.items.select { |item| present?(item) }
|
|
32
|
+
returned << Automatic::FeedMaker.create_pipeline(survivors) unless survivors.empty?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def validated_fields
|
|
39
|
+
given = @config['fields']
|
|
40
|
+
raise ArgumentError, 'FilterPresent takes fields as a list' unless given.is_a?(Array)
|
|
41
|
+
|
|
42
|
+
names = given.map(&:to_s)
|
|
43
|
+
raise ArgumentError, 'FilterPresent needs a non-empty fields list' if names.empty?
|
|
44
|
+
|
|
45
|
+
unknown = names - FIELDS
|
|
46
|
+
unless unknown.empty?
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"FilterPresent cannot inspect #{unknown.join(', ')}; " \
|
|
49
|
+
"the fields are #{FIELDS.join(', ')}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
duplicated = names.tally.select { |_name, count| count > 1 }.keys
|
|
53
|
+
raise ArgumentError, "FilterPresent was given #{duplicated.join(', ')} twice" unless duplicated.empty?
|
|
54
|
+
|
|
55
|
+
names
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def present?(item)
|
|
59
|
+
@fields.all? { |field| field_present?(item, field) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A field an item does not carry, or whose value is nil, is absent. A
|
|
63
|
+
# field whose value answers #content -- a parsed source or enclosure -- is
|
|
64
|
+
# judged on that content rather than on the element itself.
|
|
65
|
+
def field_present?(item, field)
|
|
66
|
+
return false unless item.respond_to?(field)
|
|
67
|
+
|
|
68
|
+
value = item.public_send(field)
|
|
69
|
+
return false if value.nil?
|
|
70
|
+
|
|
71
|
+
value = value.content if value.respond_to?(:content)
|
|
72
|
+
!value.to_s.strip.empty?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/plugins/filter/rand.rb
CHANGED
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::Rand
|
|
3
|
+
# Author: soramugi (More info: http://soramugi.net)
|
|
4
|
+
# Source Code:: https://github.com/id774/automaticruby
|
|
5
|
+
# License:: The GPL version 3, or LGPL version 3 (Dual License).
|
|
6
|
+
# Contact:: idnanashi@gmail.com
|
|
7
|
+
# Created:: Mar 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 FilterRand
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@config = config
|
|
13
|
+
def initialize(config, pipeline = [])
|
|
14
|
+
@config = config || {}
|
|
15
15
|
@pipeline = pipeline
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# Shuffles each feed's items. Combined with FilterOne, picks one at random.
|
|
18
19
|
def run
|
|
19
|
-
@
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
@return_feeds
|
|
20
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
21
|
+
next if feeds.nil?
|
|
22
|
+
|
|
23
|
+
returned << Automatic::FeedMaker.create_pipeline(feeds.items.shuffle)
|
|
24
|
+
end
|
|
26
25
|
end
|
|
27
26
|
end
|
|
28
27
|
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::SakuraAI
|
|
3
|
+
# Description:: Replace each item's description with what the Sakura AI Engine 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 Sakura AI Engine under
|
|
13
|
+
# the 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
|
+
# The Sakura AI Engine offers an OpenAI-compatible chat completions interface,
|
|
18
|
+
# and this plugin is still its own rather than a mode of FilterOpenAI. It is a
|
|
19
|
+
# different service: a different endpoint, a different account, a different set
|
|
20
|
+
# of models, its own limits and its own errors, and any of those may move
|
|
21
|
+
# without OpenAI moving. A Recipe naming FilterSakuraAI says which service the
|
|
22
|
+
# text is sent to, which a `provider:` setting would not.
|
|
23
|
+
#
|
|
24
|
+
# @see https://manual.sakura.ad.jp/cloud/ai-engine/
|
|
25
|
+
|
|
26
|
+
module Automatic::Plugin
|
|
27
|
+
class FilterSakuraAI
|
|
28
|
+
require 'json'
|
|
29
|
+
require 'net/http'
|
|
30
|
+
require 'openssl'
|
|
31
|
+
require 'uri'
|
|
32
|
+
|
|
33
|
+
# The one endpoint the service publishes for this. It is not a setting: an
|
|
34
|
+
# operator has no version of this plugin that talks to a different host,
|
|
35
|
+
# and a setting for it would be a way to send the token somewhere else.
|
|
36
|
+
ENDPOINT = URI('https://api.ai.sakura.ad.jp/v1/chat/completions')
|
|
37
|
+
|
|
38
|
+
OPEN_TIMEOUT = 10
|
|
39
|
+
|
|
40
|
+
# Generous, and bounded. A model given several articles thinks for a while;
|
|
41
|
+
# an unattended run that waits forever is the failure this exists against.
|
|
42
|
+
READ_TIMEOUT = 300
|
|
43
|
+
|
|
44
|
+
# A failure that another attempt will not get past: a setting that is
|
|
45
|
+
# wrong, a request the service refuses, an answer this plugin cannot read.
|
|
46
|
+
class Error < StandardError; end
|
|
47
|
+
|
|
48
|
+
# A failure that another attempt may get past: the network, a rate limit, a
|
|
49
|
+
# server error.
|
|
50
|
+
class TemporaryError < StandardError; end
|
|
51
|
+
|
|
52
|
+
def initialize(config, pipeline = [])
|
|
53
|
+
@config = config || {}
|
|
54
|
+
@pipeline = pipeline
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Replaces each item's description with the answer. Nothing else about an
|
|
58
|
+
# item is touched, and the feeds and their items arrive and leave in the
|
|
59
|
+
# same order and number.
|
|
60
|
+
def run
|
|
61
|
+
validate_settings
|
|
62
|
+
|
|
63
|
+
@pipeline.each { |feeds|
|
|
64
|
+
next if feeds.nil?
|
|
65
|
+
|
|
66
|
+
feeds.items.each { |item| transform(item) }
|
|
67
|
+
}
|
|
68
|
+
@pipeline
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Checked before the first request, because a Recipe this plugin cannot
|
|
74
|
+
# carry out is the operator's mistake and will be the same mistake on every
|
|
75
|
+
# item. The token is never named in a message.
|
|
76
|
+
def validate_settings
|
|
77
|
+
raise ArgumentError, 'FilterSakuraAI needs a token' if token.empty?
|
|
78
|
+
raise ArgumentError, 'FilterSakuraAI needs a model' if model.empty?
|
|
79
|
+
raise ArgumentError, 'FilterSakuraAI needs a prompt' if prompt.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def token
|
|
83
|
+
@config['token'].to_s
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def model
|
|
87
|
+
@config['model'].to_s.strip
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def prompt
|
|
91
|
+
@config['prompt'].to_s.strip
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def transform(item)
|
|
95
|
+
text = item.description.to_s
|
|
96
|
+
if text.strip.empty?
|
|
97
|
+
Automatic::Log.puts('warn', "FilterSakuraAI: nothing to send for #{item.link}")
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
Automatic::Log.puts('info', "FilterSakuraAI: asking #{model} about #{item.link}")
|
|
102
|
+
item.description = answer(text)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The retry shape of doc/PLUGINS.md section 3.6, applied only to what
|
|
106
|
+
# retrying can help. A missing setting, a refused request or an answer in a
|
|
107
|
+
# shape this plugin cannot read is raised at once: trying again would fail
|
|
108
|
+
# the same way, more slowly.
|
|
109
|
+
def answer(text)
|
|
110
|
+
retries = 0
|
|
111
|
+
retry_max = @config['retry'].to_i
|
|
112
|
+
begin
|
|
113
|
+
completion(text)
|
|
114
|
+
rescue TemporaryError => e
|
|
115
|
+
retries += 1
|
|
116
|
+
Automatic::Log.puts('error', "ErrorCount: #{retries}, FilterSakuraAI: #{e.message}")
|
|
117
|
+
if retries <= retry_max
|
|
118
|
+
sleep(@config['interval'].to_i)
|
|
119
|
+
retry
|
|
120
|
+
end
|
|
121
|
+
raise Error, "FilterSakuraAI gave up after #{retries} attempts: #{e.message}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def completion(text)
|
|
126
|
+
# The prompt is the system turn and the description is the user turn it
|
|
127
|
+
# is applied to. They are separate messages, so that what an article says
|
|
128
|
+
# is never read as an instruction to this plugin or to the model.
|
|
129
|
+
body = {
|
|
130
|
+
'model' => model,
|
|
131
|
+
'messages' => [
|
|
132
|
+
{ 'role' => 'system', 'content' => prompt },
|
|
133
|
+
{ 'role' => 'user', 'content' => text }
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
content(post(JSON.generate(body)))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def post(body)
|
|
140
|
+
request = Net::HTTP::Post.new(ENDPOINT)
|
|
141
|
+
request['Authorization'] = "Bearer #{token}"
|
|
142
|
+
request['Content-Type'] = 'application/json'
|
|
143
|
+
request.body = body
|
|
144
|
+
|
|
145
|
+
# TLS with the certificate verified, which is Net::HTTP's own default and
|
|
146
|
+
# is named here because it is not a thing to be turned off.
|
|
147
|
+
Net::HTTP.start(ENDPOINT.host, ENDPOINT.port,
|
|
148
|
+
use_ssl: true,
|
|
149
|
+
verify_mode: OpenSSL::SSL::VERIFY_PEER,
|
|
150
|
+
open_timeout: OPEN_TIMEOUT,
|
|
151
|
+
read_timeout: READ_TIMEOUT) { |http| http.request(request) }
|
|
152
|
+
rescue Timeout::Error, SystemCallError, SocketError, IOError,
|
|
153
|
+
OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
|
|
154
|
+
raise TemporaryError, "the request to the Sakura AI Engine failed: #{e.message}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def content(response)
|
|
158
|
+
case response
|
|
159
|
+
when Net::HTTPSuccess
|
|
160
|
+
answer_text(parse(response.body))
|
|
161
|
+
when Net::HTTPTooManyRequests, Net::HTTPServerError
|
|
162
|
+
raise TemporaryError, "the Sakura AI Engine answered #{response.code}: #{reason(response)}"
|
|
163
|
+
else
|
|
164
|
+
raise Error, "the Sakura AI Engine answered #{response.code}: #{reason(response)}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def parse(body)
|
|
169
|
+
JSON.parse(body.to_s)
|
|
170
|
+
rescue JSON::ParserError => e
|
|
171
|
+
raise Error, "the Sakura AI Engine answered with something that is not JSON: #{e.message}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The first choice's message content. An answer this plugin cannot find is
|
|
175
|
+
# an error and not an empty description: a Recipe that published the empty
|
|
176
|
+
# string here would have thrown the article away and reported success.
|
|
177
|
+
def answer_text(body)
|
|
178
|
+
choices = body['choices']
|
|
179
|
+
unless choices.is_a?(Array) && choices.first.is_a?(Hash)
|
|
180
|
+
raise Error, 'the Sakura AI Engine answered without a choice'
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
message = choices.first['message']
|
|
184
|
+
raise Error, 'the Sakura AI Engine answered without a message' unless message.is_a?(Hash)
|
|
185
|
+
|
|
186
|
+
text = message['content'].to_s.strip
|
|
187
|
+
raise Error, 'the Sakura AI Engine answered with no content' if text.empty?
|
|
188
|
+
|
|
189
|
+
text
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The service's own explanation where it gave one, the status line
|
|
193
|
+
# otherwise. Neither carries the token, and the settings are never logged
|
|
194
|
+
# or raised wholesale.
|
|
195
|
+
def reason(response)
|
|
196
|
+
body = JSON.parse(response.body.to_s)
|
|
197
|
+
error = body['error']
|
|
198
|
+
return error['message'].to_s if error.is_a?(Hash) && !error['message'].to_s.empty?
|
|
199
|
+
|
|
200
|
+
response.message.to_s
|
|
201
|
+
rescue JSON::ParserError
|
|
202
|
+
response.message.to_s
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
data/plugins/filter/sanitize.rb
CHANGED
|
@@ -1,49 +1,44 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::Sanitize
|
|
3
|
+
# Author: id774 (More info: https://id774.net)
|
|
4
|
+
# Source Code:: https://github.com/id774/automaticruby
|
|
5
|
+
# License:: The GPL version 3, or LGPL version 3 (Dual License).
|
|
6
|
+
# Contact:: idnanashi@gmail.com
|
|
7
|
+
# Created:: Jun 20, 2013
|
|
8
|
+
# Updated:: Aug 15, 2026
|
|
9
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
8
10
|
|
|
9
11
|
module Automatic::Plugin
|
|
10
12
|
class FilterSanitize
|
|
11
|
-
|
|
13
|
+
Automatic.require_optional('sanitize', needed_by: 'FilterSanitize')
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
MODES = {
|
|
16
|
+
'basic' => Sanitize::Config::BASIC,
|
|
17
|
+
'relaxed' => Sanitize::Config::RELAXED
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(config, pipeline = [])
|
|
21
|
+
@config = config || {}
|
|
15
22
|
@pipeline = pipeline
|
|
16
|
-
|
|
17
|
-
when "basic"
|
|
18
|
-
@mode = Sanitize::Config::BASIC
|
|
19
|
-
when "relaxed"
|
|
20
|
-
@mode = Sanitize::Config::RELAXED
|
|
21
|
-
else
|
|
22
|
-
@mode = Sanitize::Config::RESTRICTED
|
|
23
|
-
end
|
|
23
|
+
@mode = MODES.fetch(@config['mode'].to_s, Sanitize::Config::RESTRICTED)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Strips HTML from descriptions.
|
|
26
27
|
def run
|
|
27
|
-
@
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@return_feeds << feeds
|
|
34
|
-
end
|
|
35
|
-
}
|
|
36
|
-
@return_feeds
|
|
28
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
29
|
+
next if feeds.nil?
|
|
30
|
+
|
|
31
|
+
feeds.items.each { |item| sanitize(item) }
|
|
32
|
+
returned << feeds
|
|
33
|
+
end
|
|
37
34
|
end
|
|
38
35
|
|
|
39
36
|
private
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
end
|
|
46
|
-
feed
|
|
37
|
+
|
|
38
|
+
def sanitize(item)
|
|
39
|
+
item.description = Sanitize.fragment(item.description, @mode) unless item.description.nil?
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
Automatic::Log.puts('warn', "Undefined field detected in feed: #{e.message}")
|
|
47
42
|
end
|
|
48
43
|
end
|
|
49
44
|
end
|
data/plugins/filter/sort.rb
CHANGED
|
@@ -1,37 +1,30 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::Sort
|
|
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:: Mar 23, 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 FilterSort
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@
|
|
14
|
-
@
|
|
13
|
+
def initialize(config, pipeline = [])
|
|
14
|
+
@config = config || {}
|
|
15
|
+
@pipeline = pipeline
|
|
16
|
+
@ascending = @config['sort'].to_s == 'asc'
|
|
15
17
|
end
|
|
16
18
|
|
|
19
|
+
# Sorts each feed's items by date. Items must carry one; a feed built from
|
|
20
|
+
# a source without dates fails here.
|
|
17
21
|
def run
|
|
18
|
-
@
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
a.date <=> b.date
|
|
25
|
-
}
|
|
26
|
-
else
|
|
27
|
-
feeds.items.sort!{|a,b|
|
|
28
|
-
- (a.date <=> b.date)
|
|
29
|
-
}
|
|
30
|
-
end
|
|
31
|
-
@return_feeds << feeds
|
|
32
|
-
end
|
|
33
|
-
}
|
|
34
|
-
@return_feeds
|
|
22
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
23
|
+
next if feeds.nil?
|
|
24
|
+
|
|
25
|
+
feeds.items.sort! { |a, b| @ascending ? a.date <=> b.date : b.date <=> a.date }
|
|
26
|
+
returned << feeds
|
|
27
|
+
end
|
|
35
28
|
end
|
|
36
29
|
end
|
|
37
30
|
end
|
|
@@ -1,40 +1,48 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::TumblrResize
|
|
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:: 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 FilterTumblrResize
|
|
13
|
+
# Tumblr has served images under two URL schemes. The older one carries
|
|
14
|
+
# the size as a suffix on the file name -- tumblr_xxx_500.jpg -- and is
|
|
15
|
+
# what images uploaded before 2019 still use. The newer one carries it as
|
|
16
|
+
# a path segment -- /s540x810/ -- and is what everything since uses.
|
|
17
|
+
# Both are rewritten to the largest variant the scheme offers.
|
|
18
|
+
LEGACY_SIZE = /_(?:75sq|100|250|400|500)(?=\.[a-zA-Z0-9]+\z)/
|
|
19
|
+
LEGACY_LARGEST = '_1280'
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
|
|
21
|
+
PATH_SIZE = %r{/s\d+x\d+/}
|
|
22
|
+
PATH_LARGEST = '/s1280x1920/'
|
|
23
|
+
|
|
24
|
+
def initialize(config, pipeline = [])
|
|
25
|
+
@config = config || {}
|
|
14
26
|
@pipeline = pipeline
|
|
15
27
|
end
|
|
16
28
|
|
|
29
|
+
# Assumes FilterImage or FilterImageSource has already put an image URL in
|
|
30
|
+
# the link.
|
|
17
31
|
def run
|
|
18
|
-
@
|
|
19
|
-
@pipeline.each {|feeds|
|
|
20
|
-
img_url = ""
|
|
32
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
21
33
|
unless feeds.nil?
|
|
22
|
-
feeds.items.each
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
feeds.items.each do |item|
|
|
35
|
+
item.link = resize(item.link) unless item.link.nil?
|
|
36
|
+
end
|
|
25
37
|
end
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@return_feeds
|
|
38
|
+
returned << feeds
|
|
39
|
+
end
|
|
29
40
|
end
|
|
30
41
|
|
|
31
42
|
private
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
string = string.gsub("_250\.", "_1280\.")
|
|
36
|
-
string = string.gsub("_400\.", "_1280\.")
|
|
37
|
-
string = string.gsub("_500\.", "_1280\.")
|
|
43
|
+
|
|
44
|
+
def resize(link)
|
|
45
|
+
link.sub(LEGACY_SIZE, LEGACY_LARGEST).sub(PATH_SIZE, PATH_LARGEST)
|
|
38
46
|
end
|
|
39
47
|
end
|
|
40
48
|
end
|