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,105 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::Join
|
|
3
|
+
# Description:: Join every item in the pipeline into one item.
|
|
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
|
+
# Many items in, one item out. That is the whole of it: no summarizing, no
|
|
13
|
+
# fetching, no knowledge of what reads the result. What the joined description
|
|
14
|
+
# is for is decided by whatever the Recipe puts next -- an AI filter, a publish
|
|
15
|
+
# plugin, or nothing at all.
|
|
16
|
+
#
|
|
17
|
+
# The whole pipeline becomes one item rather than one item per feed, because
|
|
18
|
+
# the point of joining is to have a single text; a Recipe that wants one item
|
|
19
|
+
# per feed still has the feeds separate before this plugin runs.
|
|
20
|
+
|
|
21
|
+
module Automatic::Plugin
|
|
22
|
+
class FilterJoin
|
|
23
|
+
require 'rss'
|
|
24
|
+
|
|
25
|
+
# A title an operator has not set. Short and predictable, in the manner of
|
|
26
|
+
# PublishMarkdown's `(untitled)`; a Recipe that publishes this item names
|
|
27
|
+
# it in `title`.
|
|
28
|
+
DEFAULT_TITLE = 'Joined items'.freeze
|
|
29
|
+
|
|
30
|
+
# Where one item ends and the next begins, for whatever reads the joined
|
|
31
|
+
# text. Plain lines rather than markup: the descriptions being joined may
|
|
32
|
+
# be HTML or text, and a delimiter that survives both is one that neither
|
|
33
|
+
# can be mistaken for.
|
|
34
|
+
HEADING = 'ARTICLE'.freeze
|
|
35
|
+
|
|
36
|
+
def initialize(config, pipeline = [])
|
|
37
|
+
@config = config || {}
|
|
38
|
+
@pipeline = pipeline
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns one feed holding one item, or an empty pipeline when there was
|
|
42
|
+
# nothing to join. An item saying nothing is worse than no item: the
|
|
43
|
+
# plugins after this one would publish it.
|
|
44
|
+
def run
|
|
45
|
+
items = collect
|
|
46
|
+
if items.empty?
|
|
47
|
+
Automatic::Log.puts('warn', 'FilterJoin: no items to join')
|
|
48
|
+
return []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Automatic::Log.puts('info', "FilterJoin: joining #{items.size} items into one")
|
|
52
|
+
[feed(title, description(items))]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def collect
|
|
58
|
+
@pipeline.each_with_object([]) { |feeds, items|
|
|
59
|
+
next if feeds.nil?
|
|
60
|
+
|
|
61
|
+
items.concat(feeds.items)
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def title
|
|
66
|
+
given = @config['title'].to_s
|
|
67
|
+
given.empty? ? DEFAULT_TITLE : given
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def description(items)
|
|
71
|
+
items.each_with_index.map { |item, index| section(index + 1, item) }.join("\n\n")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def section(number, item)
|
|
75
|
+
["#{HEADING} #{number}",
|
|
76
|
+
"Title: #{value(item, :title)}",
|
|
77
|
+
"URL: #{value(item, :link)}",
|
|
78
|
+
'',
|
|
79
|
+
value(item, :description)].join("\n")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A field an item does not carry is empty rather than absent, so that every
|
|
83
|
+
# section has the same shape whatever the feed it came from left out.
|
|
84
|
+
def value(item, name)
|
|
85
|
+
return '' unless item.respond_to?(name)
|
|
86
|
+
|
|
87
|
+
item.public_send(name).to_s.strip
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# This joined item deliberately has no link. It is several articles at once,
|
|
91
|
+
# so there is no single page it points at; using the first article's URL would
|
|
92
|
+
# name a source for text that is not only from it.
|
|
93
|
+
def feed(item_title, item_description)
|
|
94
|
+
RSS::Maker.make('2.0') { |maker|
|
|
95
|
+
maker.channel.title = 'Automatic Ruby'
|
|
96
|
+
maker.channel.description = 'Automatic::Plugin::FilterJoin'
|
|
97
|
+
maker.channel.link = 'https://github.com/id774/automaticruby'
|
|
98
|
+
item = maker.items.new_item
|
|
99
|
+
item.title = item_title
|
|
100
|
+
item.description = item_description
|
|
101
|
+
item.date = Time.now
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::Kimi
|
|
3
|
+
# Description:: Replace each item's description with what the Kimi 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:: Sep 11, 2026
|
|
9
|
+
# Updated:: Sep 11, 2026
|
|
10
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
11
|
+
#
|
|
12
|
+
# One transformation: the item's description goes to the Kimi 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
|
+
# Moonshot AI's Kimi 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
|
|
20
|
+
# set of models, its own limits and its own errors, and any of those may move
|
|
21
|
+
# without OpenAI moving. A Recipe naming FilterKimi says which service the
|
|
22
|
+
# text is sent to, which a `provider:` setting would not.
|
|
23
|
+
#
|
|
24
|
+
# Kimi may return a `reasoning_content` alongside the answer's own `content`.
|
|
25
|
+
# That reasoning is never read, logged or written anywhere by this plugin --
|
|
26
|
+
# only `content` is the answer, and only once `finish_reason` says the model
|
|
27
|
+
# is done.
|
|
28
|
+
#
|
|
29
|
+
# @see https://platform.moonshot.ai/docs/api/chat
|
|
30
|
+
|
|
31
|
+
module Automatic::Plugin
|
|
32
|
+
class FilterKimi
|
|
33
|
+
require 'json'
|
|
34
|
+
require 'net/http'
|
|
35
|
+
require 'openssl'
|
|
36
|
+
require 'uri'
|
|
37
|
+
|
|
38
|
+
# The one endpoint the service publishes for this. It is not a setting: an
|
|
39
|
+
# operator has no version of this plugin that talks to a different host,
|
|
40
|
+
# and a setting for it would be a way to send the token somewhere else.
|
|
41
|
+
ENDPOINT = URI('https://api.moonshot.ai/v1/chat/completions')
|
|
42
|
+
|
|
43
|
+
OPEN_TIMEOUT = 10
|
|
44
|
+
|
|
45
|
+
# Generous, and bounded. A model given several articles thinks for a while;
|
|
46
|
+
# an unattended run that waits forever is the failure this exists against.
|
|
47
|
+
READ_TIMEOUT = 300
|
|
48
|
+
|
|
49
|
+
# A failure that another attempt will not get past: a setting that is
|
|
50
|
+
# wrong, a request the service refuses, an answer this plugin cannot read.
|
|
51
|
+
class Error < StandardError; end
|
|
52
|
+
|
|
53
|
+
# A failure that another attempt may get past: the network, a rate limit, a
|
|
54
|
+
# server error.
|
|
55
|
+
class TemporaryError < StandardError; end
|
|
56
|
+
|
|
57
|
+
def initialize(config, pipeline = [])
|
|
58
|
+
@config = config || {}
|
|
59
|
+
@pipeline = pipeline
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Replaces each item's description with the answer. Nothing else about an
|
|
63
|
+
# item is touched, and the feeds and their items arrive and leave in the
|
|
64
|
+
# same number and the same order.
|
|
65
|
+
def run
|
|
66
|
+
validate_settings
|
|
67
|
+
|
|
68
|
+
@pipeline.each { |feeds|
|
|
69
|
+
next if feeds.nil?
|
|
70
|
+
|
|
71
|
+
feeds.items.each { |item| transform(item) }
|
|
72
|
+
}
|
|
73
|
+
@pipeline
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Checked before the first request, because a Recipe this plugin cannot
|
|
79
|
+
# carry out is the operator's mistake and will be the same mistake on every
|
|
80
|
+
# item. The token is never named in a message.
|
|
81
|
+
def validate_settings
|
|
82
|
+
raise ArgumentError, 'FilterKimi needs a token' if token.empty?
|
|
83
|
+
raise ArgumentError, 'FilterKimi needs a model' if model.empty?
|
|
84
|
+
raise ArgumentError, 'FilterKimi needs a prompt' if prompt.empty?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def token
|
|
88
|
+
@config['token'].to_s
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def model
|
|
92
|
+
@config['model'].to_s.strip
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def prompt
|
|
96
|
+
@config['prompt'].to_s.strip
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def transform(item)
|
|
100
|
+
text = item.description.to_s
|
|
101
|
+
if text.strip.empty?
|
|
102
|
+
Automatic::Log.puts('warn', "FilterKimi: nothing to send for #{item.link}")
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
Automatic::Log.puts('info', "FilterKimi: asking #{model} about #{item.link}")
|
|
107
|
+
item.description = answer(text)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The retry shape of doc/PLUGINS.md section 3.6, applied only to what
|
|
111
|
+
# retrying can help. A missing setting, a refused request or an answer in a
|
|
112
|
+
# shape this plugin cannot read is raised at once: trying again would fail
|
|
113
|
+
# the same way, more slowly.
|
|
114
|
+
def answer(text)
|
|
115
|
+
retries = 0
|
|
116
|
+
retry_max = @config['retry'].to_i
|
|
117
|
+
begin
|
|
118
|
+
completion(text)
|
|
119
|
+
rescue TemporaryError => e
|
|
120
|
+
retries += 1
|
|
121
|
+
Automatic::Log.puts('error', "ErrorCount: #{retries}, FilterKimi: #{e.message}")
|
|
122
|
+
if retries <= retry_max
|
|
123
|
+
sleep(@config['interval'].to_i)
|
|
124
|
+
retry
|
|
125
|
+
end
|
|
126
|
+
raise Error, "FilterKimi gave up after #{retries} attempts: #{e.message}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def completion(text)
|
|
131
|
+
# The prompt is the system turn and the description is the user turn it
|
|
132
|
+
# is applied to. They are separate messages, so that what an article says
|
|
133
|
+
# is never read as an instruction to this plugin or to the model. Nothing
|
|
134
|
+
# else -- no sampling or tool-use parameter -- is sent.
|
|
135
|
+
body = {
|
|
136
|
+
'model' => model,
|
|
137
|
+
'messages' => [
|
|
138
|
+
{ 'role' => 'system', 'content' => prompt },
|
|
139
|
+
{ 'role' => 'user', 'content' => text }
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
content(post(JSON.generate(body)))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def post(body)
|
|
146
|
+
request = Net::HTTP::Post.new(ENDPOINT)
|
|
147
|
+
request['Authorization'] = "Bearer #{token}"
|
|
148
|
+
request['Content-Type'] = 'application/json'
|
|
149
|
+
request.body = body
|
|
150
|
+
|
|
151
|
+
# TLS with the certificate verified, which is Net::HTTP's own default and
|
|
152
|
+
# is named here because it is not a thing to be turned off.
|
|
153
|
+
Net::HTTP.start(ENDPOINT.host, ENDPOINT.port,
|
|
154
|
+
use_ssl: true,
|
|
155
|
+
verify_mode: OpenSSL::SSL::VERIFY_PEER,
|
|
156
|
+
open_timeout: OPEN_TIMEOUT,
|
|
157
|
+
read_timeout: READ_TIMEOUT) { |http| http.request(request) }
|
|
158
|
+
rescue Timeout::Error, SystemCallError, SocketError, IOError,
|
|
159
|
+
OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
|
|
160
|
+
raise TemporaryError, "the request to Kimi failed: #{e.message}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def content(response)
|
|
164
|
+
case response
|
|
165
|
+
when Net::HTTPSuccess
|
|
166
|
+
answer_text(parse(response.body))
|
|
167
|
+
when Net::HTTPTooManyRequests, Net::HTTPServerError
|
|
168
|
+
raise TemporaryError, "Kimi answered #{response.code}: #{reason(response)}"
|
|
169
|
+
else
|
|
170
|
+
raise Error, "Kimi answered #{response.code}: #{reason(response)}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def parse(body)
|
|
175
|
+
JSON.parse(body.to_s)
|
|
176
|
+
rescue JSON::ParserError => e
|
|
177
|
+
raise Error, "Kimi answered with something that is not JSON: #{e.message}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The first choice's message content, once the model says it is actually
|
|
181
|
+
# done. `reasoning_content`, when Kimi sends one alongside `content`, is
|
|
182
|
+
# never read here: it is not the answer, and it never becomes one. An
|
|
183
|
+
# answer this plugin cannot find is an error and not an empty description:
|
|
184
|
+
# a Recipe that published the empty string here would have thrown the
|
|
185
|
+
# article away and reported success.
|
|
186
|
+
def answer_text(body)
|
|
187
|
+
choices = body['choices']
|
|
188
|
+
unless choices.is_a?(Array) && choices.first.is_a?(Hash)
|
|
189
|
+
raise Error, 'Kimi answered without a choice'
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
choice = choices.first
|
|
193
|
+
message = choice['message']
|
|
194
|
+
raise Error, 'Kimi answered without a message' unless message.is_a?(Hash)
|
|
195
|
+
raise Error, "Kimi did not finish: #{choice['finish_reason']}" unless choice['finish_reason'] == 'stop'
|
|
196
|
+
|
|
197
|
+
text = message['content'].to_s.strip
|
|
198
|
+
raise Error, 'Kimi answered with no content' 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
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::Limit
|
|
3
|
+
# Description:: Limit the number of items passed by the whole pipeline.
|
|
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 FilterLimit
|
|
14
|
+
def initialize(config, pipeline = [])
|
|
15
|
+
@config = config || {}
|
|
16
|
+
@pipeline = pipeline
|
|
17
|
+
@max_items = validated_max_items
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Selects items in feed order, item order, up to max_items shared across the
|
|
21
|
+
# whole pipeline rather than reset per feed. A feed that contributed no item
|
|
22
|
+
# is dropped from the output; the walk stops as soon as the limit is met.
|
|
23
|
+
def run
|
|
24
|
+
remaining = @max_items
|
|
25
|
+
returned = []
|
|
26
|
+
|
|
27
|
+
@pipeline.each do |feeds|
|
|
28
|
+
next if feeds.nil?
|
|
29
|
+
break if remaining <= 0
|
|
30
|
+
|
|
31
|
+
selected = feeds.items.first(remaining)
|
|
32
|
+
next if selected.empty?
|
|
33
|
+
|
|
34
|
+
returned << Automatic::FeedMaker.create_pipeline(selected)
|
|
35
|
+
remaining -= selected.size
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
returned
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validated_max_items
|
|
44
|
+
value = begin
|
|
45
|
+
Integer(@config['max_items'].to_s, 10)
|
|
46
|
+
rescue ArgumentError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if value.nil? || value < 1
|
|
51
|
+
raise ArgumentError, 'FilterLimit needs max_items to be a positive integer'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/plugins/filter/one.rb
CHANGED
|
@@ -1,37 +1,30 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::One
|
|
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:: May 8, 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 FilterOne
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@config = config
|
|
13
|
+
def initialize(config, pipeline = [])
|
|
14
|
+
@config = config || {}
|
|
15
15
|
@pipeline = pipeline
|
|
16
|
+
@last = @config['pick'].to_s == 'last'
|
|
16
17
|
end
|
|
17
18
|
|
|
19
|
+
# Reduces each feed to a single item. A feed that has none is dropped
|
|
20
|
+
# rather than reduced to one nil.
|
|
18
21
|
def run
|
|
19
|
-
@
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
item << feed.items.pop
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
if item.count == 0
|
|
29
|
-
item << feed.items.shift
|
|
30
|
-
end
|
|
31
|
-
@return_feeds << Automatic::FeedMaker.create_pipeline(item)
|
|
32
|
-
end
|
|
33
|
-
}
|
|
34
|
-
@return_feeds
|
|
22
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
23
|
+
next if feeds.nil? || feeds.items.empty?
|
|
24
|
+
|
|
25
|
+
item = @last ? feeds.items.last : feeds.items.first
|
|
26
|
+
returned << Automatic::FeedMaker.create_pipeline([item])
|
|
27
|
+
end
|
|
35
28
|
end
|
|
36
29
|
end
|
|
37
30
|
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::OpenAI
|
|
3
|
+
# Description:: Replace each item's description with what the OpenAI 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 OpenAI 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 OpenAI and nothing else. Anthropic, Google and Sakura have
|
|
18
|
+
# their own plugins, because their endpoints, authentication, request bodies,
|
|
19
|
+
# responses and errors are their own and will keep diverging. A Recipe changes
|
|
20
|
+
# service by naming a different plugin.
|
|
21
|
+
#
|
|
22
|
+
# It speaks the Responses API, which is the interface OpenAI recommends for new
|
|
23
|
+
# integrations. @see https://platform.openai.com/docs/api-reference/responses
|
|
24
|
+
|
|
25
|
+
module Automatic::Plugin
|
|
26
|
+
class FilterOpenAI
|
|
27
|
+
require 'json'
|
|
28
|
+
require 'net/http'
|
|
29
|
+
require 'openssl'
|
|
30
|
+
require 'uri'
|
|
31
|
+
|
|
32
|
+
ENDPOINT = URI('https://api.openai.com/v1/responses')
|
|
33
|
+
|
|
34
|
+
OPEN_TIMEOUT = 10
|
|
35
|
+
|
|
36
|
+
# Generous, and bounded. A model given several articles thinks for a while;
|
|
37
|
+
# an unattended run that waits forever is the failure this exists against.
|
|
38
|
+
READ_TIMEOUT = 300
|
|
39
|
+
|
|
40
|
+
# A failure that another attempt will not get past: a setting that is
|
|
41
|
+
# wrong, a request the service refuses, an answer this plugin cannot read.
|
|
42
|
+
class Error < StandardError; end
|
|
43
|
+
|
|
44
|
+
# A failure that another attempt may get past: the network, a rate limit, a
|
|
45
|
+
# server error.
|
|
46
|
+
class TemporaryError < StandardError; end
|
|
47
|
+
|
|
48
|
+
def initialize(config, pipeline = [])
|
|
49
|
+
@config = config || {}
|
|
50
|
+
@pipeline = pipeline
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Replaces each item's description with the answer. Nothing else about an
|
|
54
|
+
# item is touched, and the feeds and their items arrive and leave in the
|
|
55
|
+
# same order and number.
|
|
56
|
+
def run
|
|
57
|
+
validate_settings
|
|
58
|
+
|
|
59
|
+
@pipeline.each { |feeds|
|
|
60
|
+
next if feeds.nil?
|
|
61
|
+
|
|
62
|
+
feeds.items.each { |item| transform(item) }
|
|
63
|
+
}
|
|
64
|
+
@pipeline
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Checked before the first request, because a Recipe this plugin cannot
|
|
70
|
+
# carry out is the operator's mistake and will be the same mistake on every
|
|
71
|
+
# item. The token is never named in a message.
|
|
72
|
+
def validate_settings
|
|
73
|
+
raise ArgumentError, 'FilterOpenAI needs a token' if token.empty?
|
|
74
|
+
raise ArgumentError, 'FilterOpenAI needs a model' if model.empty?
|
|
75
|
+
raise ArgumentError, 'FilterOpenAI needs a prompt' if prompt.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def token
|
|
79
|
+
@config['token'].to_s
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def model
|
|
83
|
+
@config['model'].to_s.strip
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def prompt
|
|
87
|
+
@config['prompt'].to_s.strip
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def transform(item)
|
|
91
|
+
text = item.description.to_s
|
|
92
|
+
if text.strip.empty?
|
|
93
|
+
Automatic::Log.puts('warn', "FilterOpenAI: nothing to send for #{item.link}")
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
Automatic::Log.puts('info', "FilterOpenAI: asking #{model} about #{item.link}")
|
|
98
|
+
item.description = answer(text)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The retry shape of doc/PLUGINS.md section 3.6, applied only to what
|
|
102
|
+
# retrying can help. A missing setting, a refused request or an answer in a
|
|
103
|
+
# shape this plugin cannot read is raised at once: trying again would fail
|
|
104
|
+
# the same way, more slowly.
|
|
105
|
+
def answer(text)
|
|
106
|
+
retries = 0
|
|
107
|
+
retry_max = @config['retry'].to_i
|
|
108
|
+
begin
|
|
109
|
+
completion(text)
|
|
110
|
+
rescue TemporaryError => e
|
|
111
|
+
retries += 1
|
|
112
|
+
Automatic::Log.puts('error', "ErrorCount: #{retries}, FilterOpenAI: #{e.message}")
|
|
113
|
+
if retries <= retry_max
|
|
114
|
+
sleep(@config['interval'].to_i)
|
|
115
|
+
retry
|
|
116
|
+
end
|
|
117
|
+
raise Error, "FilterOpenAI gave up after #{retries} attempts: #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def completion(text)
|
|
122
|
+
# The prompt is the instruction and the description is the text it is
|
|
123
|
+
# applied to. They are separate fields, so that what an article says is
|
|
124
|
+
# never read as an instruction to this plugin or to the model.
|
|
125
|
+
body = {
|
|
126
|
+
'model' => model,
|
|
127
|
+
'instructions' => prompt,
|
|
128
|
+
'input' => text
|
|
129
|
+
}
|
|
130
|
+
content(post(JSON.generate(body)))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def post(body)
|
|
134
|
+
request = Net::HTTP::Post.new(ENDPOINT)
|
|
135
|
+
request['Authorization'] = "Bearer #{token}"
|
|
136
|
+
request['Content-Type'] = 'application/json'
|
|
137
|
+
request.body = body
|
|
138
|
+
|
|
139
|
+
# TLS with the certificate verified, which is Net::HTTP's own default and
|
|
140
|
+
# is named here because it is not a thing to be turned off.
|
|
141
|
+
Net::HTTP.start(ENDPOINT.host, ENDPOINT.port,
|
|
142
|
+
use_ssl: true,
|
|
143
|
+
verify_mode: OpenSSL::SSL::VERIFY_PEER,
|
|
144
|
+
open_timeout: OPEN_TIMEOUT,
|
|
145
|
+
read_timeout: READ_TIMEOUT) { |http| http.request(request) }
|
|
146
|
+
rescue Timeout::Error, SystemCallError, SocketError, IOError,
|
|
147
|
+
OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
|
|
148
|
+
raise TemporaryError, "the request to OpenAI failed: #{e.message}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def content(response)
|
|
152
|
+
case response
|
|
153
|
+
when Net::HTTPSuccess
|
|
154
|
+
answer_text(parse(response.body))
|
|
155
|
+
when Net::HTTPTooManyRequests, Net::HTTPServerError
|
|
156
|
+
raise TemporaryError, "OpenAI answered #{response.code}: #{reason(response)}"
|
|
157
|
+
else
|
|
158
|
+
raise Error, "OpenAI answered #{response.code}: #{reason(response)}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def parse(body)
|
|
163
|
+
JSON.parse(body.to_s)
|
|
164
|
+
rescue JSON::ParserError => e
|
|
165
|
+
raise Error, "OpenAI answered with something that is not JSON: #{e.message}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The text of the assistant's message, out of the typed output array the
|
|
169
|
+
# Responses API returns. An answer this plugin cannot find is an error and
|
|
170
|
+
# not an empty description: a Recipe that published the empty string here
|
|
171
|
+
# would have thrown the article away and reported success.
|
|
172
|
+
def answer_text(body)
|
|
173
|
+
output = body['output']
|
|
174
|
+
raise Error, 'OpenAI answered without an output array' unless output.is_a?(Array)
|
|
175
|
+
|
|
176
|
+
text = output.select { |item| item.is_a?(Hash) && item['type'] == 'message' }.
|
|
177
|
+
flat_map { |item| Array(item['content']) }.
|
|
178
|
+
select { |part| part.is_a?(Hash) && part['type'] == 'output_text' }.
|
|
179
|
+
map { |part| part['text'].to_s }.join.strip
|
|
180
|
+
raise Error, 'OpenAI answered with no output text' if text.empty?
|
|
181
|
+
|
|
182
|
+
text
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The service's own explanation where it gave one, the status line
|
|
186
|
+
# otherwise. Neither carries the token, and the settings are never logged
|
|
187
|
+
# or raised wholesale.
|
|
188
|
+
def reason(response)
|
|
189
|
+
body = JSON.parse(response.body.to_s)
|
|
190
|
+
error = body['error']
|
|
191
|
+
return error['message'].to_s if error.is_a?(Hash) && !error['message'].to_s.empty?
|
|
192
|
+
|
|
193
|
+
response.message.to_s
|
|
194
|
+
rescue JSON::ParserError
|
|
195
|
+
response.message.to_s
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|