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,212 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Name:: Automatic::Plugin::Store::Digest
|
|
3
|
+
# Description:: Drop items whose content has been seen before, by SHA-256 digest.
|
|
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
|
+
# Content identity, where StorePermalink is URL identity. The fields the Recipe
|
|
13
|
+
# names are normalized, joined into one canonical string and hashed, and the
|
|
14
|
+
# digest is what the database holds: an item whose digest is already there has
|
|
15
|
+
# been seen, whatever its link says, and an item whose link has been seen before
|
|
16
|
+
# under different content has not.
|
|
17
|
+
#
|
|
18
|
+
# It is exact matching, of the content the Recipe selected. Whitespace runs and
|
|
19
|
+
# Unicode composition differences are absorbed; nothing else is. Two articles
|
|
20
|
+
# that say the same thing in different words are two articles here, and making
|
|
21
|
+
# them one is not this plugin's work -- see doc/PLUGINS.md section 6.4.
|
|
22
|
+
|
|
23
|
+
require 'digest'
|
|
24
|
+
require_relative 'database'
|
|
25
|
+
|
|
26
|
+
module Automatic::Plugin
|
|
27
|
+
# Named for what it holds rather than as `Digest`, which is Ruby's own
|
|
28
|
+
# hashing module and is what computes the value stored here.
|
|
29
|
+
class DigestRecord < ActiveRecord::Base
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class StoreDigest
|
|
33
|
+
include Automatic::Plugin::Database
|
|
34
|
+
|
|
35
|
+
# The item fields a digest may be taken over. `date` is not one: an item
|
|
36
|
+
# republished unchanged carries a new date often enough that including it
|
|
37
|
+
# would defeat the purpose. `enclosure` is not one either, being a
|
|
38
|
+
# structure rather than a value.
|
|
39
|
+
FIELDS = %w[title link description author comments source content_encoded].freeze
|
|
40
|
+
|
|
41
|
+
# What an item is, absent a Recipe saying otherwise: what it says, not
|
|
42
|
+
# where it is. A feed that reissues an article under a new URL is the case
|
|
43
|
+
# this plugin exists for.
|
|
44
|
+
DEFAULT_FIELDS = %w[title description].freeze
|
|
45
|
+
|
|
46
|
+
# The field name is part of what is hashed, and this separates it from its
|
|
47
|
+
# value and one field from the next. A byte no title, body or URL contains,
|
|
48
|
+
# so that no arrangement of two fields collides with another.
|
|
49
|
+
SEPARATOR = "\0"
|
|
50
|
+
|
|
51
|
+
def initialize(config, pipeline = [])
|
|
52
|
+
@config = config || {}
|
|
53
|
+
@pipeline = pipeline
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def column_definition
|
|
57
|
+
{ digest: :string, created_at: :string }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def model_class
|
|
61
|
+
Automatic::Plugin::DigestRecord
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Records the digest of each item's selected fields and passes on only the
|
|
65
|
+
# digests not already recorded.
|
|
66
|
+
#
|
|
67
|
+
# Nothing is rescued around the database. A store plugin that failed to
|
|
68
|
+
# write and passed the item on anyway would publish it again on the next
|
|
69
|
+
# run, which is the one thing this plugin is for.
|
|
70
|
+
def run
|
|
71
|
+
validate_settings
|
|
72
|
+
prepare_database
|
|
73
|
+
|
|
74
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
75
|
+
next if feeds.nil?
|
|
76
|
+
|
|
77
|
+
new_items = feeds.items.select { |item| new_item?(item) }
|
|
78
|
+
returned << Automatic::FeedMaker.create_pipeline(new_items) unless new_items.empty?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# The table the mixin builds, plus the constraint. Two runs of one Recipe
|
|
85
|
+
# overlapping -- a `cron` entry that takes longer than its interval -- would
|
|
86
|
+
# otherwise both read a digest as absent and both store it. The unique
|
|
87
|
+
# index makes the second write fail instead, which #store below reads as
|
|
88
|
+
# what it is.
|
|
89
|
+
def create_table
|
|
90
|
+
super
|
|
91
|
+
ActiveRecord::Base.connection.add_index(model_class.table_name, :digest, unique: true)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Everything the Recipe has to get right, checked before the database file
|
|
95
|
+
# is opened. A Recipe this plugin cannot carry out is the operator's
|
|
96
|
+
# mistake and will be the same mistake next hour.
|
|
97
|
+
def validate_settings
|
|
98
|
+
raise ArgumentError, 'StoreDigest needs a db file name' if @config['db'].to_s.strip.empty?
|
|
99
|
+
|
|
100
|
+
fields
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The fields, in the order the Recipe wrote them. That order is part of the
|
|
104
|
+
# fingerprint and is not sorted here: a Recipe that changes it has said
|
|
105
|
+
# something different, and its digests are different digests.
|
|
106
|
+
#
|
|
107
|
+
# Only an absent `fields` takes the default. Every other way of getting it
|
|
108
|
+
# wrong is refused rather than corrected, because a Recipe's own statement
|
|
109
|
+
# of what makes two items the same is what this plugin has to obey.
|
|
110
|
+
def fields
|
|
111
|
+
@fields ||= validated_fields
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validated_fields
|
|
115
|
+
given = @config['fields']
|
|
116
|
+
return DEFAULT_FIELDS if given.nil?
|
|
117
|
+
|
|
118
|
+
unless given.is_a?(Array)
|
|
119
|
+
raise ArgumentError, "StoreDigest takes a list of fields, not #{given.inspect}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
names = given.map(&:to_s)
|
|
123
|
+
raise ArgumentError, 'StoreDigest was given an empty fields list' if names.empty?
|
|
124
|
+
|
|
125
|
+
unknown = names - FIELDS
|
|
126
|
+
unless unknown.empty?
|
|
127
|
+
raise ArgumentError,
|
|
128
|
+
"StoreDigest cannot take a digest over #{unknown.join(', ')}; " \
|
|
129
|
+
"the fields are #{FIELDS.join(', ')}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
duplicated = names.tally.select { |_name, count| count > 1 }.keys
|
|
133
|
+
unless duplicated.empty?
|
|
134
|
+
raise ArgumentError, "StoreDigest was given #{duplicated.join(', ')} twice"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
names
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Whether the item goes downstream. An item whose digest was stored here is
|
|
141
|
+
# new, one whose digest was already stored is not, and one there is nothing
|
|
142
|
+
# to hash goes on unjudged.
|
|
143
|
+
def new_item?(item)
|
|
144
|
+
digest = digest_for(item)
|
|
145
|
+
|
|
146
|
+
if digest.nil?
|
|
147
|
+
Automatic::Log.puts('warn',
|
|
148
|
+
"StoreDigest: no digestable content for #{item.link}; " \
|
|
149
|
+
'passing item unchanged')
|
|
150
|
+
return true
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
return false if stored_digest?(digest)
|
|
154
|
+
|
|
155
|
+
store(digest, item)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def stored_digest?(digest)
|
|
159
|
+
model_class.exists?(digest: digest)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# True when this run is the one that stored the digest. The read above
|
|
163
|
+
# answers this on its own for a single run; the rescue is for the run
|
|
164
|
+
# overlapping another, where the row appeared between the two statements.
|
|
165
|
+
def store(digest, item)
|
|
166
|
+
model_class.create!(digest: digest, created_at: Time.now.strftime('%Y/%m/%d %X'))
|
|
167
|
+
Automatic::Log.puts('info', "Saving Digest: #{digest} (#{item.link})")
|
|
168
|
+
true
|
|
169
|
+
rescue ActiveRecord::RecordNotUnique
|
|
170
|
+
false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The digest of the item's selected fields, or nil where the Recipe
|
|
174
|
+
# selected nothing the item has.
|
|
175
|
+
#
|
|
176
|
+
# An item with nothing to hash is not stored under the digest of the empty
|
|
177
|
+
# string: every item with no description would then be the same item as
|
|
178
|
+
# every other, and the first of them would silence the rest for good.
|
|
179
|
+
def digest_for(item)
|
|
180
|
+
values = fields.map { |field| [field, normalize(value_of(item, field))] }
|
|
181
|
+
return nil if values.all? { |_field, value| value.empty? }
|
|
182
|
+
|
|
183
|
+
canonical = values.map { |field, value| "#{field}#{SEPARATOR}#{value}" }.join(SEPARATOR)
|
|
184
|
+
::Digest::SHA256.hexdigest(canonical)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A field an item does not carry reads as absent rather than raising.
|
|
188
|
+
# `content_encoded` is the case that matters: an item built by a filter has
|
|
189
|
+
# it, one straight from an RSS 2.0 feed may not.
|
|
190
|
+
def value_of(item, field)
|
|
191
|
+
item.respond_to?(field) ? item.public_send(field) : nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# What two spellings of one string have to survive to hash alike: the
|
|
195
|
+
# encoding, the composition of accented characters, and how much whitespace
|
|
196
|
+
# a feed happens to have put between words. Nothing beyond that -- case,
|
|
197
|
+
# punctuation and markup are content here, and an item that differs in them
|
|
198
|
+
# is a different item.
|
|
199
|
+
#
|
|
200
|
+
# `scrub` is not redundant after `encode`: a conversion whose source and
|
|
201
|
+
# destination encodings are the same is skipped, invalid bytes and all, and
|
|
202
|
+
# `unicode_normalize` raises on what is left.
|
|
203
|
+
def normalize(value)
|
|
204
|
+
value.to_s.
|
|
205
|
+
encode('UTF-8', invalid: :replace, undef: :replace).
|
|
206
|
+
scrub.
|
|
207
|
+
unicode_normalize(:nfc).
|
|
208
|
+
gsub(/\s+/, ' ').
|
|
209
|
+
strip
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
data/plugins/store/file.rb
CHANGED
|
@@ -1,95 +1,126 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
1
|
# -*- coding: utf-8 -*-
|
|
3
|
-
# Name::
|
|
4
|
-
# Author
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
# Name:: Automatic::Plugin::Store::File
|
|
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.
|
|
10
|
+
|
|
11
|
+
require 'fileutils'
|
|
11
12
|
require 'uri'
|
|
12
|
-
require 'aws-sdk'
|
|
13
13
|
|
|
14
14
|
module Automatic::Plugin
|
|
15
15
|
class StoreFile
|
|
16
|
+
# A link with either scheme is fetched from S3 rather than over HTTP.
|
|
17
|
+
# `s3n` is what Recipes written for this plugin use; `s3` is the spelling
|
|
18
|
+
# everything else uses and is accepted as well.
|
|
19
|
+
S3_SCHEMES = %w[s3 s3n].freeze
|
|
16
20
|
|
|
17
|
-
def initialize(config, pipeline=[])
|
|
18
|
-
@config
|
|
21
|
+
def initialize(config, pipeline = [])
|
|
22
|
+
@config = config || {}
|
|
19
23
|
@pipeline = pipeline
|
|
20
|
-
unless @config['bucket_name'].nil?
|
|
21
|
-
s3 = AWS::S3.new(
|
|
22
|
-
:access_key_id => @config['access_key'],
|
|
23
|
-
:secret_access_key => @config['secret_key']
|
|
24
|
-
)
|
|
25
|
-
@bucket = s3.buckets[@config['bucket_name']]
|
|
26
|
-
end
|
|
27
|
-
@return_feeds = []
|
|
28
24
|
end
|
|
29
25
|
|
|
26
|
+
# Downloads what each link points at and rewrites the link to a file URI,
|
|
27
|
+
# which is how PublishAmazonS3 later knows it has a local file.
|
|
30
28
|
def run
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
begin
|
|
40
|
-
retries += 1
|
|
41
|
-
feed.link = get_file(feed.link)
|
|
42
|
-
sleep ||= @config['interval'].to_i
|
|
43
|
-
@return_feeds << feed
|
|
44
|
-
rescue
|
|
45
|
-
Automatic::Log.puts("error", "ErrorCount: #{retries}, Fault during file download.")
|
|
46
|
-
sleep ||= @config['interval'].to_i
|
|
47
|
-
retry if retries <= retry_max
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
}
|
|
29
|
+
stored = []
|
|
30
|
+
@pipeline.each do |feeds|
|
|
31
|
+
next if feeds.nil?
|
|
32
|
+
|
|
33
|
+
feeds.items.each do |feed|
|
|
34
|
+
next if feed.link.nil?
|
|
35
|
+
|
|
36
|
+
stored << feed if store(feed)
|
|
51
37
|
end
|
|
52
|
-
|
|
38
|
+
end
|
|
39
|
+
|
|
53
40
|
@pipeline = []
|
|
54
|
-
@pipeline << Automatic::FeedMaker.create_pipeline(
|
|
41
|
+
@pipeline << Automatic::FeedMaker.create_pipeline(stored) unless stored.empty?
|
|
55
42
|
@pipeline
|
|
56
43
|
end
|
|
57
44
|
|
|
58
45
|
private
|
|
59
46
|
|
|
47
|
+
def store(feed)
|
|
48
|
+
Automatic::Log.puts('info', "Downloading File: #{feed.link}")
|
|
49
|
+
FileUtils.mkdir_p(@config['path'].to_s)
|
|
50
|
+
|
|
51
|
+
retries = 0
|
|
52
|
+
retry_max = @config['retry'].to_i
|
|
53
|
+
begin
|
|
54
|
+
feed.link = get_file(feed.link)
|
|
55
|
+
sleep(@config['interval'].to_i)
|
|
56
|
+
true
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
retries += 1
|
|
59
|
+
Automatic::Log.puts('error',
|
|
60
|
+
"ErrorCount: #{retries}, Fault during file download: #{e.message}")
|
|
61
|
+
return false if retries > retry_max
|
|
62
|
+
|
|
63
|
+
sleep(@config['interval'].to_i)
|
|
64
|
+
retry
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
60
68
|
def get_file(url)
|
|
61
69
|
uri = URI.parse(url)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
else
|
|
66
|
-
return_path = wget(uri, url)
|
|
67
|
-
end
|
|
68
|
-
Automatic::Log.puts("info", "Saved File: #{return_path}")
|
|
69
|
-
"file://" + return_path
|
|
70
|
+
path = S3_SCHEMES.include?(uri.scheme) ? from_s3(uri) : download(url)
|
|
71
|
+
Automatic::Log.puts('info', "Saved File: #{path}")
|
|
72
|
+
"file://#{path}"
|
|
70
73
|
end
|
|
71
74
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
filepath
|
|
75
|
+
# Only HTTP and HTTPS are fetched: a link arrives from a feed, which is to
|
|
76
|
+
# say from outside, and `file://` is not something a store plugin should
|
|
77
|
+
# be talked into reading.
|
|
78
|
+
def download(url)
|
|
79
|
+
path = local_path(Automatic::Http.uri(url).path)
|
|
80
|
+
File.binwrite(path, Automatic::Http.read(url))
|
|
81
|
+
path
|
|
81
82
|
end
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
# AWS SDK for Ruby v3, one gem for one service. The bucket is the Recipe's
|
|
85
|
+
# `bucket_name` where it has one, so that an existing Recipe keeps its
|
|
86
|
+
# meaning, and the link's own host otherwise. Credentials are the Recipe's
|
|
87
|
+
# where it carries them and the SDK's default chain -- environment,
|
|
88
|
+
# profile, instance role -- where it does not, which is the way to run
|
|
89
|
+
# this without a secret in a file.
|
|
90
|
+
def from_s3(uri)
|
|
91
|
+
path = local_path(uri.path)
|
|
92
|
+
s3.get_object(bucket: bucket(uri), key: uri.path.sub(%r{\A/}, ''),
|
|
93
|
+
response_target: path)
|
|
94
|
+
path
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def s3
|
|
98
|
+
@s3 ||= begin
|
|
99
|
+
Automatic.require_optional('aws-sdk-s3', needed_by: 'the S3 path of StoreFile')
|
|
100
|
+
Aws::S3::Client.new(**client_options)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def client_options
|
|
105
|
+
options = {}
|
|
106
|
+
options[:region] = @config['region'].to_s unless @config['region'].nil?
|
|
107
|
+
unless @config['access_key'].nil?
|
|
108
|
+
options[:access_key_id] = @config['access_key'].to_s
|
|
109
|
+
options[:secret_access_key] = @config['secret_key'].to_s
|
|
91
110
|
end
|
|
92
|
-
|
|
111
|
+
options
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def bucket(uri)
|
|
115
|
+
name = @config['bucket_name'].to_s
|
|
116
|
+
name.empty? ? uri.host.to_s : name
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def local_path(remote_path)
|
|
120
|
+
name = File.basename(remote_path.to_s)
|
|
121
|
+
raise ArgumentError, "no file name in #{remote_path}" if name.empty? || name == '/'
|
|
122
|
+
|
|
123
|
+
File.join(@config['path'].to_s, name)
|
|
93
124
|
end
|
|
94
125
|
end
|
|
95
126
|
end
|
data/plugins/store/full_text.rb
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Store::FullText
|
|
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 26, 2012
|
|
8
|
+
# Updated:: Aug 15, 2026
|
|
9
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
require_relative 'database'
|
|
10
12
|
|
|
11
13
|
module Automatic::Plugin
|
|
12
14
|
class Blog < ActiveRecord::Base
|
|
@@ -15,43 +17,48 @@ module Automatic::Plugin
|
|
|
15
17
|
class StoreFullText
|
|
16
18
|
include Automatic::Plugin::Database
|
|
17
19
|
|
|
18
|
-
def initialize(config, pipeline=[])
|
|
19
|
-
@config
|
|
20
|
+
def initialize(config, pipeline = [])
|
|
21
|
+
@config = config || {}
|
|
20
22
|
@pipeline = pipeline
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def column_definition
|
|
24
26
|
{
|
|
25
|
-
:
|
|
26
|
-
:
|
|
27
|
-
:
|
|
28
|
-
:
|
|
29
|
-
:
|
|
27
|
+
title: :string,
|
|
28
|
+
link: :string,
|
|
29
|
+
description: :string,
|
|
30
|
+
content: :string,
|
|
31
|
+
created_at: :string
|
|
30
32
|
}
|
|
31
33
|
end
|
|
32
34
|
|
|
35
|
+
# Link or title, so that a republished article with a new URL is not
|
|
36
|
+
# stored twice.
|
|
33
37
|
def unique_keys
|
|
34
|
-
[
|
|
38
|
+
%i[link title]
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
def model_class
|
|
38
42
|
Automatic::Plugin::Blog
|
|
39
43
|
end
|
|
40
44
|
|
|
45
|
+
# Records title, link, description and content_encoded, and passes on only
|
|
46
|
+
# what is new.
|
|
41
47
|
def run
|
|
42
|
-
for_each_new_feed
|
|
43
|
-
Automatic::Log.puts(
|
|
48
|
+
for_each_new_feed do |feed|
|
|
49
|
+
Automatic::Log.puts('info', "Saving FullText: #{feed.link}")
|
|
44
50
|
begin
|
|
45
51
|
Blog.create(
|
|
46
|
-
:
|
|
47
|
-
:
|
|
48
|
-
:
|
|
49
|
-
:
|
|
50
|
-
:
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
title: feed.title,
|
|
53
|
+
link: feed.link,
|
|
54
|
+
description: feed.description,
|
|
55
|
+
content: feed.content_encoded,
|
|
56
|
+
created_at: Time.now.strftime('%Y/%m/%d %X')
|
|
57
|
+
)
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
Automatic::Log.puts('warn', "Skip feed due to fault in save: #{e.message}")
|
|
53
60
|
end
|
|
54
|
-
|
|
61
|
+
end
|
|
55
62
|
end
|
|
56
63
|
end
|
|
57
64
|
end
|
data/plugins/store/permalink.rb
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
1
|
# -*- coding: utf-8 -*-
|
|
3
|
-
# Name::
|
|
4
|
-
# Author
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Store::Permalink
|
|
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.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
require_relative 'database'
|
|
11
12
|
|
|
12
13
|
module Automatic::Plugin
|
|
13
14
|
class Permalink < ActiveRecord::Base
|
|
@@ -16,16 +17,13 @@ module Automatic::Plugin
|
|
|
16
17
|
class StorePermalink
|
|
17
18
|
include Automatic::Plugin::Database
|
|
18
19
|
|
|
19
|
-
def initialize(config, pipeline=[])
|
|
20
|
-
@config
|
|
20
|
+
def initialize(config, pipeline = [])
|
|
21
|
+
@config = config || {}
|
|
21
22
|
@pipeline = pipeline
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def column_definition
|
|
25
|
-
{
|
|
26
|
-
:url => :string,
|
|
27
|
-
:created_at => :string
|
|
28
|
-
}
|
|
26
|
+
{ url: :string, created_at: :string }
|
|
29
27
|
end
|
|
30
28
|
|
|
31
29
|
def unique_keys
|
|
@@ -36,15 +34,13 @@ module Automatic::Plugin
|
|
|
36
34
|
Automatic::Plugin::Permalink
|
|
37
35
|
end
|
|
38
36
|
|
|
37
|
+
# Records each item's link and passes on only the links not already
|
|
38
|
+
# recorded. The usual guard against publishing the same item twice.
|
|
39
39
|
def run
|
|
40
|
-
for_each_new_feed
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
:created_at => Time.now.strftime("%Y/%m/%d %X"))
|
|
45
|
-
Automatic::Log.puts("info", "Saving Permalink: #{feed.link}")
|
|
46
|
-
end
|
|
47
|
-
}
|
|
40
|
+
for_each_new_feed do |feed|
|
|
41
|
+
Permalink.create(url: feed.link, created_at: Time.now.strftime('%Y/%m/%d %X'))
|
|
42
|
+
Automatic::Log.puts('info', "Saving Permalink: #{feed.link}")
|
|
43
|
+
end
|
|
48
44
|
end
|
|
49
45
|
end
|
|
50
46
|
end
|
|
@@ -1,33 +1,46 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::SubscriptionFeed
|
|
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 SubscriptionFeed
|
|
11
|
-
def initialize(config, pipeline=[])
|
|
12
|
-
@config
|
|
13
|
+
def initialize(config, pipeline = [])
|
|
14
|
+
@config = config || {}
|
|
13
15
|
@pipeline = pipeline
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def run
|
|
17
|
-
@config['feeds'].each
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
rss = Automatic::FeedParser.get_url(feed)
|
|
22
|
-
@pipeline << rss
|
|
23
|
-
rescue
|
|
24
|
-
retries += 1
|
|
25
|
-
Automatic::Log.puts("error", "ErrorCount: #{retries}, Fault in parsing: #{feed}")
|
|
26
|
-
sleep ||= @config['interval'].to_i
|
|
27
|
-
retry if retries <= retry_max
|
|
28
|
-
end
|
|
29
|
-
}
|
|
19
|
+
Array(@config['feeds']).each do |url|
|
|
20
|
+
feed = fetch(url)
|
|
21
|
+
@pipeline << feed unless feed.nil?
|
|
22
|
+
end
|
|
30
23
|
@pipeline
|
|
31
24
|
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# A feed that fails after its retries is logged and skipped; the others
|
|
29
|
+
# still run.
|
|
30
|
+
def fetch(url)
|
|
31
|
+
retries = 0
|
|
32
|
+
retry_max = @config['retry'].to_i
|
|
33
|
+
begin
|
|
34
|
+
Automatic::FeedParser.get_url(url)
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
retries += 1
|
|
37
|
+
Automatic::Log.puts('error',
|
|
38
|
+
"ErrorCount: #{retries}, Fault in parsing: #{url}, #{e.message}")
|
|
39
|
+
return nil if retries > retry_max
|
|
40
|
+
|
|
41
|
+
sleep(@config['interval'].to_i)
|
|
42
|
+
retry
|
|
43
|
+
end
|
|
44
|
+
end
|
|
32
45
|
end
|
|
33
46
|
end
|