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
|
@@ -1,55 +1,96 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::CustomFeed::SVNLog
|
|
3
|
+
# Author: kzgs
|
|
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 29, 2012
|
|
8
|
+
# Updated:: Aug 15, 2026
|
|
9
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
8
10
|
|
|
11
|
+
require 'rexml/document'
|
|
9
12
|
require 'rss/maker'
|
|
13
|
+
require 'time'
|
|
10
14
|
|
|
11
15
|
module Automatic::Plugin
|
|
12
16
|
class CustomFeedSVNLog
|
|
13
|
-
|
|
17
|
+
DEFAULT_FETCH_ITEMS = 30
|
|
14
18
|
|
|
15
|
-
def initialize(config, pipeline=[])
|
|
16
|
-
@config
|
|
19
|
+
def initialize(config, pipeline = [])
|
|
20
|
+
@config = config || {}
|
|
17
21
|
@pipeline = pipeline
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
def run
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return @pipeline
|
|
25
|
+
entries = revisions
|
|
26
|
+
if entries.empty?
|
|
27
|
+
# RSS 1.0 has no representation for a channel with no items, and a
|
|
28
|
+
# repository with no revisions in the window asked for is an ordinary
|
|
29
|
+
# answer rather than a failure.
|
|
30
|
+
Automatic::Log.puts('warn', "No revisions returned by svn log for #{base_url}")
|
|
31
|
+
return @pipeline
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@pipeline << feed(entries)
|
|
35
|
+
@pipeline
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
private
|
|
38
39
|
|
|
39
|
-
def
|
|
40
|
-
|
|
40
|
+
def feed(revisions)
|
|
41
|
+
RSS::Maker.make('1.0') do |maker|
|
|
42
|
+
maker.channel.title = @config['title'].to_s
|
|
43
|
+
maker.channel.about = ''
|
|
44
|
+
maker.channel.description = ''
|
|
45
|
+
maker.channel.link = base_url
|
|
46
|
+
|
|
47
|
+
revisions.each do |revision|
|
|
48
|
+
item = maker.items.new_item
|
|
49
|
+
item.title = "#{revision['msg']} by #{revision['author']}"
|
|
50
|
+
item.link = "#{base_url}/!svn/bc/#{revision['revision']}"
|
|
51
|
+
item.date = Time.parse(revision['date'])
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# `svn log --xml` as REXML sees it. REXML is a runtime dependency of this
|
|
57
|
+
# framework already -- the OPML parser uses it -- so this plugin needs the
|
|
58
|
+
# svn command and no gem of its own.
|
|
59
|
+
def revisions
|
|
60
|
+
REXML::Document.new(svn_log).elements.to_a('/log/logentry').map do |entry|
|
|
61
|
+
{
|
|
62
|
+
'revision' => entry.attributes['revision'].to_s,
|
|
63
|
+
'author' => text(entry, 'author'),
|
|
64
|
+
'msg' => text(entry, 'msg'),
|
|
65
|
+
'date' => text(entry, 'date')
|
|
66
|
+
}
|
|
67
|
+
end
|
|
41
68
|
end
|
|
42
69
|
|
|
43
|
-
def
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
def text(entry, name)
|
|
71
|
+
element = entry.elements[name]
|
|
72
|
+
element.nil? ? '' : element.text.to_s
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The command is run as an argument vector rather than through a shell, so
|
|
76
|
+
# a repository URL cannot become part of a command line. Point `target` at
|
|
77
|
+
# a repository you control regardless: svn itself will do what the URL
|
|
78
|
+
# tells it to.
|
|
79
|
+
def svn_log
|
|
80
|
+
output = IO.popen(['svn', 'log', base_url, '--xml', "--limit=#{limit}"],
|
|
81
|
+
err: File::NULL, &:read)
|
|
82
|
+
raise "svn log failed for #{base_url}" unless $?.success?
|
|
83
|
+
|
|
84
|
+
output
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def base_url
|
|
88
|
+
@base_url ||= @config['target'].to_s.sub(%r{/\z}, '')
|
|
49
89
|
end
|
|
50
90
|
|
|
51
91
|
def limit
|
|
52
|
-
|
|
92
|
+
value = @config['fetch_items'].to_i
|
|
93
|
+
value.positive? ? value : DEFAULT_FETCH_ITEMS
|
|
53
94
|
end
|
|
54
95
|
end
|
|
55
96
|
end
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Name:: Automatic::Plugin::CustomFeed::Web
|
|
3
|
+
# Description:: Build a feed from the article links of an HTML index page.
|
|
4
|
+
# Author: id774 (More info: http://id774.net)
|
|
5
|
+
# Source Code:: https://github.com/id774/automaticruby
|
|
6
|
+
# License:: The GPL version 3, or LGPL version 3 (Dual License).
|
|
7
|
+
# Contact:: idnanashi@gmail.com
|
|
8
|
+
# Created:: Aug 17, 2026
|
|
9
|
+
# Updated:: Aug 17, 2026
|
|
10
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
11
|
+
#
|
|
12
|
+
# One page in, one feed out. The pages named in the Recipe are fetched, their
|
|
13
|
+
# article links are taken with CSS selectors, and each page becomes a feed of
|
|
14
|
+
# what it currently lists. Nothing else is fetched: the links are not
|
|
15
|
+
# followed, no article body is read, and no page beyond the ones named is
|
|
16
|
+
# visited, so a run costs one request per site.
|
|
17
|
+
#
|
|
18
|
+
# It keeps no state of its own, deliberately. What was published last time is
|
|
19
|
+
# StorePermalink's question, and the Recipe that answers it puts StorePermalink
|
|
20
|
+
# after this plugin. See doc/PLUGINS.md section 6.2.
|
|
21
|
+
|
|
22
|
+
require 'rss/maker'
|
|
23
|
+
require 'set'
|
|
24
|
+
require 'time'
|
|
25
|
+
require 'uri'
|
|
26
|
+
|
|
27
|
+
module Automatic::Plugin
|
|
28
|
+
class CustomFeedWeb
|
|
29
|
+
Automatic.require_optional('nokogiri', needed_by: 'CustomFeedWeb')
|
|
30
|
+
|
|
31
|
+
DEFAULT_FETCH_ITEMS = 100
|
|
32
|
+
|
|
33
|
+
# What an article link is, where the Recipe does not say. Every mode ends
|
|
34
|
+
# at an `<a href>`, because a permalink is what a feed item needs.
|
|
35
|
+
DEFAULT_LINK_SELECTOR = 'a[href]'
|
|
36
|
+
|
|
37
|
+
# A link in a page is written by whoever wrote the page. `mailto:`,
|
|
38
|
+
# `javascript:` and `file:` links are ordinary in a navigation bar and are
|
|
39
|
+
# not articles.
|
|
40
|
+
SCHEMES = %w[http https].freeze
|
|
41
|
+
|
|
42
|
+
# One article as the page presents it, before it becomes an item. Internal
|
|
43
|
+
# to this plugin: what leaves here is the pipeline value of section 3.4.
|
|
44
|
+
Candidate = Struct.new(:title, :url, :description, :date, keyword_init: true)
|
|
45
|
+
|
|
46
|
+
def initialize(config, pipeline = [])
|
|
47
|
+
@config = config || {}
|
|
48
|
+
@pipeline = pipeline
|
|
49
|
+
@seen = Set.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def run
|
|
53
|
+
sites.each do |site|
|
|
54
|
+
feed = feed_for(site)
|
|
55
|
+
@pipeline << feed unless feed.nil?
|
|
56
|
+
end
|
|
57
|
+
@pipeline
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Every site, checked before anything is fetched.
|
|
63
|
+
#
|
|
64
|
+
# A Recipe this plugin cannot carry out -- a site that is not a mapping, a
|
|
65
|
+
# URL that cannot be fetched, a pattern that is not a regular expression,
|
|
66
|
+
# a selector combination that names no article -- is the operator's
|
|
67
|
+
# mistake, and it will be the same mistake after a retry and a wait. It is
|
|
68
|
+
# refused here, where no request has been made yet, rather than inside the
|
|
69
|
+
# retry loop, which is for a host that did not answer.
|
|
70
|
+
def sites
|
|
71
|
+
@sites ||= Array(@config['sites']).each { |site| validate(site) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate(site)
|
|
75
|
+
unless site.is_a?(Hash)
|
|
76
|
+
raise ArgumentError,
|
|
77
|
+
'CustomFeedWeb takes a mapping with a url key for each site, ' \
|
|
78
|
+
"not #{site.inspect}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
source(site)
|
|
82
|
+
patterns(site, 'include')
|
|
83
|
+
patterns(site, 'exclude')
|
|
84
|
+
article_unit(site)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The page URL as this plugin will fetch and resolve against it.
|
|
88
|
+
# Automatic::Http is what says which URLs those are.
|
|
89
|
+
def source(site)
|
|
90
|
+
Automatic::Http.uri(site['url'])
|
|
91
|
+
rescue ArgumentError, URI::InvalidURIError => e
|
|
92
|
+
raise ArgumentError, "CustomFeedWeb needs a url for each site: #{e.message}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def patterns(site, key)
|
|
96
|
+
Array(site[key]).map { |pattern| Regexp.new(pattern.to_s) }
|
|
97
|
+
rescue RegexpError => e
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
"CustomFeedWeb was given an invalid #{key} pattern for " \
|
|
100
|
+
"#{site['url']}: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# `title_selector`, `description_selector` and `date_selector` are read
|
|
104
|
+
# inside one article's node, and `item_selector` is what says where an
|
|
105
|
+
# article begins and ends. Without it there is no node to read them in,
|
|
106
|
+
# and a page of anchors cannot be divided into articles by guessing.
|
|
107
|
+
def article_unit(site)
|
|
108
|
+
return if presence(site['item_selector'])
|
|
109
|
+
|
|
110
|
+
named = %w[title_selector description_selector date_selector].
|
|
111
|
+
select { |key| presence(site[key]) }
|
|
112
|
+
return if named.empty?
|
|
113
|
+
|
|
114
|
+
raise ArgumentError,
|
|
115
|
+
"CustomFeedWeb needs item_selector to use #{named.join(', ')}: " \
|
|
116
|
+
"#{site['url']}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def feed_for(site)
|
|
120
|
+
base = source(site)
|
|
121
|
+
document = fetch(site)
|
|
122
|
+
return nil if document.nil?
|
|
123
|
+
|
|
124
|
+
articles = entries(document, site, base)
|
|
125
|
+
if articles.empty?
|
|
126
|
+
# A list page with nothing on it that this Recipe recognises is an
|
|
127
|
+
# ordinary answer rather than a failure, and an empty feed would only
|
|
128
|
+
# give the rest of the pipeline something to skip.
|
|
129
|
+
Automatic::Log.puts('warn', "No article links found on #{base}")
|
|
130
|
+
return nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
Automatic::Log.puts('info', "Web feed: #{articles.size} items from #{base}")
|
|
134
|
+
feed(site, document, base, articles)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The page, or nil where it could not be read after its retries. One site
|
|
138
|
+
# that is down does not take the rest of the Recipe with it.
|
|
139
|
+
#
|
|
140
|
+
# The parser is given the stream rather than a decoded string, so that it
|
|
141
|
+
# reads the document's own meta charset instead of the encoding open-uri
|
|
142
|
+
# settled on. See doc/PLUGINS.md section 3.8.1.
|
|
143
|
+
def fetch(site)
|
|
144
|
+
url = site['url']
|
|
145
|
+
retries = 0
|
|
146
|
+
retry_max = @config['retry'].to_i
|
|
147
|
+
begin
|
|
148
|
+
Automatic::Log.puts('info', "Parsing Web page: #{url}")
|
|
149
|
+
document = Automatic::Http.open(url) { |io| Nokogiri::HTML(io) }
|
|
150
|
+
sleep(@config['interval'].to_i)
|
|
151
|
+
document
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
retries += 1
|
|
154
|
+
Automatic::Log.puts('error',
|
|
155
|
+
"ErrorCount: #{retries}, Fault in fetching: #{url}, #{e.message}")
|
|
156
|
+
if retries > retry_max
|
|
157
|
+
Automatic::Log.puts('warn', "Skipping #{url}")
|
|
158
|
+
return nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
sleep(@config['interval'].to_i)
|
|
162
|
+
retry
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The candidates that survive, in the order the page lists them, up to
|
|
167
|
+
# `fetch_items`. A list page's own order is the only ordering information
|
|
168
|
+
# it carries, so nothing here sorts.
|
|
169
|
+
def entries(document, site, base)
|
|
170
|
+
includes = patterns(site, 'include')
|
|
171
|
+
excludes = patterns(site, 'exclude')
|
|
172
|
+
limit = fetch_items(site)
|
|
173
|
+
|
|
174
|
+
candidates(document, site).each_with_object([]) do |candidate, kept|
|
|
175
|
+
next if candidate.title.empty?
|
|
176
|
+
|
|
177
|
+
url = permalink(candidate.url, base, site, includes, excludes)
|
|
178
|
+
next if url.nil?
|
|
179
|
+
|
|
180
|
+
candidate.url = url
|
|
181
|
+
kept << candidate
|
|
182
|
+
break kept if kept.size >= limit
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The three ways a page is read, and the only three: every anchor, the
|
|
187
|
+
# anchors a selector names, or an article node with its parts inside it.
|
|
188
|
+
def candidates(document, site)
|
|
189
|
+
item_selector = presence(site['item_selector'])
|
|
190
|
+
return anchors(document, site) if item_selector.nil?
|
|
191
|
+
|
|
192
|
+
document.css(item_selector).filter_map { |node| article(node, site) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def anchors(document, site)
|
|
196
|
+
selector = presence(site['link_selector']) || DEFAULT_LINK_SELECTOR
|
|
197
|
+
document.css(selector).filter_map { |node|
|
|
198
|
+
next if node['href'].nil?
|
|
199
|
+
|
|
200
|
+
Candidate.new(title: normalize(node.text), url: node['href'], description: '')
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def article(node, site)
|
|
205
|
+
link = node.at_css(presence(site['link_selector']) || DEFAULT_LINK_SELECTOR)
|
|
206
|
+
return nil if link.nil? || link['href'].nil?
|
|
207
|
+
|
|
208
|
+
Candidate.new(
|
|
209
|
+
title: title(node, site, link),
|
|
210
|
+
url: link['href'],
|
|
211
|
+
description: description(node, site),
|
|
212
|
+
date: date(node, site)
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# A `title_selector` that selects nothing in this particular article falls
|
|
217
|
+
# back to the link's own text rather than dropping the article: one entry
|
|
218
|
+
# laid out differently from the rest of a list is ordinary.
|
|
219
|
+
def title(node, site, link)
|
|
220
|
+
selector = presence(site['title_selector'])
|
|
221
|
+
found = selector.nil? ? nil : node.at_css(selector)
|
|
222
|
+
normalize((found || link).text)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The summary the list page prints, as text. Not the article: this plugin
|
|
226
|
+
# does not fetch one.
|
|
227
|
+
def description(node, site)
|
|
228
|
+
selector = presence(site['description_selector'])
|
|
229
|
+
return '' if selector.nil?
|
|
230
|
+
|
|
231
|
+
found = node.at_css(selector)
|
|
232
|
+
found.nil? ? '' : normalize(found.text)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# `<time datetime="2026-08-17T09:00:00+09:00">yesterday</time>` is the
|
|
236
|
+
# reason the attribute is preferred: it is written for a machine, and the
|
|
237
|
+
# text beside it is written for a reader.
|
|
238
|
+
def date(node, site)
|
|
239
|
+
selector = presence(site['date_selector'])
|
|
240
|
+
return nil if selector.nil?
|
|
241
|
+
|
|
242
|
+
found = node.at_css(selector)
|
|
243
|
+
return nil if found.nil?
|
|
244
|
+
|
|
245
|
+
published(found.name == 'time' && found['datetime'] ? found['datetime'] : found.text)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# A date that cannot be read costs the article its date and not its place
|
|
249
|
+
# in the feed. Nothing is substituted for it: the time this ran is when
|
|
250
|
+
# the page was fetched, which is not when the article was published.
|
|
251
|
+
def published(value)
|
|
252
|
+
Time.parse(value.to_s)
|
|
253
|
+
rescue ArgumentError => e
|
|
254
|
+
Automatic::Log.puts('warn', "Unreadable date #{value.to_s.strip.inspect}: #{e.message}")
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The judgements a candidate URL passes, in this order: it is resolved
|
|
259
|
+
# against the page it was found on, then it is one this framework fetches,
|
|
260
|
+
# then it is not the page itself, then the host, then include, then
|
|
261
|
+
# exclude, and only what has survived all of them is recorded as seen.
|
|
262
|
+
def permalink(href, base, site, includes, excludes)
|
|
263
|
+
uri = absolute(href, base)
|
|
264
|
+
return nil if uri.nil? || !SCHEMES.include?(uri.scheme)
|
|
265
|
+
|
|
266
|
+
url = uri.to_s
|
|
267
|
+
return nil if url == base.to_s
|
|
268
|
+
return nil if same_host?(site) && !same_host_as?(uri, base)
|
|
269
|
+
return nil if includes.any? && includes.none? { |pattern| pattern.match?(url) }
|
|
270
|
+
return nil if excludes.any? { |pattern| pattern.match?(url) }
|
|
271
|
+
|
|
272
|
+
# Within one run only. Whether a URL was published last week is
|
|
273
|
+
# StorePermalink's record, not this plugin's.
|
|
274
|
+
@seen.add?(url).nil? ? nil : url
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Resolved against the page it was found on, so that `/articles/42`,
|
|
278
|
+
# `../42` and `//example.com/42` all become the URL a reader would follow.
|
|
279
|
+
# The fragment goes, because two links differing only in their anchor are
|
|
280
|
+
# one article. The query string stays, because `?id=42` is frequently the
|
|
281
|
+
# whole of what identifies one, and no canonical form is guessed.
|
|
282
|
+
def absolute(href, base)
|
|
283
|
+
uri = base.merge(href.to_s.strip)
|
|
284
|
+
uri.fragment = nil
|
|
285
|
+
uri
|
|
286
|
+
rescue URI::Error, ArgumentError
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def same_host?(site)
|
|
291
|
+
value = site['same_host']
|
|
292
|
+
value.nil? ? true : value
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# An exact host match, and nothing cleverer. `www.example.com` and
|
|
296
|
+
# `blog.example.com` are one organisation and are not one site, and which
|
|
297
|
+
# of them a Recipe wants is the Recipe's to say.
|
|
298
|
+
def same_host_as?(uri, base)
|
|
299
|
+
uri.host.to_s.downcase == base.host.to_s.downcase
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def fetch_items(site)
|
|
303
|
+
value = site['fetch_items'].to_i
|
|
304
|
+
value.positive? ? value : DEFAULT_FETCH_ITEMS
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def feed(site, document, base, entries)
|
|
308
|
+
RSS::Maker.make('2.0') do |maker|
|
|
309
|
+
maker.channel.title = channel_title(site, document, base)
|
|
310
|
+
maker.channel.link = base.to_s
|
|
311
|
+
maker.channel.description = "Web page items from #{base}"
|
|
312
|
+
# The page's order is kept. RSS::Maker sorts its items by date when it
|
|
313
|
+
# is asked to, and an article the page listed first is first for a
|
|
314
|
+
# reason a date does not carry.
|
|
315
|
+
maker.items.do_sort = false
|
|
316
|
+
|
|
317
|
+
entries.each do |entry|
|
|
318
|
+
item = maker.items.new_item
|
|
319
|
+
item.title = entry.title
|
|
320
|
+
item.link = entry.url
|
|
321
|
+
item.description = entry.description
|
|
322
|
+
item.date = entry.date unless entry.date.nil?
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# `name`, then what the page calls itself, then the host it came from.
|
|
328
|
+
def channel_title(site, document, base)
|
|
329
|
+
name = presence(site['name'])
|
|
330
|
+
return name unless name.nil?
|
|
331
|
+
|
|
332
|
+
title = normalize(document.title.to_s)
|
|
333
|
+
title.empty? ? base.host.to_s : title
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# " Ruby\n 4.0\t released " is "Ruby 4.0 released". A title in a list
|
|
337
|
+
# page is laid out for a browser, and the line breaks and indentation of
|
|
338
|
+
# the markup are not part of it.
|
|
339
|
+
def normalize(text)
|
|
340
|
+
text.to_s.gsub(/[[:space:]]+/, ' ').strip
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def presence(value)
|
|
344
|
+
string = value.to_s.strip
|
|
345
|
+
string.empty? ? nil : string
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
@@ -1,44 +1,60 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::AbsoluteURI
|
|
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:: Jun 20, 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 FilterAbsoluteURI
|
|
13
|
+
require 'uri'
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
# Anything already carrying a scheme is left alone. The earlier spelling
|
|
16
|
+
# of this test matched `http://` only, so an https link was treated as
|
|
17
|
+
# relative and had the base prepended to it.
|
|
18
|
+
ABSOLUTE = %r{\A[a-zA-Z][a-zA-Z0-9+.\-]*://}
|
|
19
|
+
|
|
20
|
+
# URI::Parser became the RFC 3986 parser in Ruby 3.4, which reports #escape
|
|
21
|
+
# as obsolete. The RFC 2396 parser is what this was always reaching and is
|
|
22
|
+
# spelled the same way on every supported Ruby.
|
|
23
|
+
ESCAPER = URI::RFC2396_Parser.new
|
|
24
|
+
|
|
25
|
+
def initialize(config, pipeline = [])
|
|
26
|
+
@config = config || {}
|
|
14
27
|
@pipeline = pipeline
|
|
28
|
+
@base = base_url
|
|
15
29
|
end
|
|
16
30
|
|
|
17
31
|
def run
|
|
18
|
-
@
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
@return_feeds << feeds
|
|
32
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
33
|
+
next if feeds.nil?
|
|
34
|
+
|
|
35
|
+
feeds.items.each do |item|
|
|
36
|
+
item.link = rewrite(item.link) unless item.link.nil?
|
|
25
37
|
end
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
returned << feeds
|
|
39
|
+
end
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
private
|
|
31
|
-
def rewrite(string)
|
|
32
|
-
if /^http:\/\/.*$/ =~ string
|
|
33
|
-
return string
|
|
34
|
-
end
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return
|
|
44
|
+
# Read once, in the constructor: the earlier version appended the trailing
|
|
45
|
+
# slash to the Recipe's own config mapping, which is the plugin's input
|
|
46
|
+
# rather than its state.
|
|
47
|
+
def base_url
|
|
48
|
+
url = @config['url'].to_s
|
|
49
|
+
return url if url.empty? || url.end_with?('/')
|
|
50
|
+
|
|
51
|
+
"#{url}/"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rewrite(link)
|
|
55
|
+
return link if ABSOLUTE.match?(link)
|
|
56
|
+
|
|
57
|
+
ESCAPER.escape(@base + link.sub(/\A\./, '').sub(%r{\A/}, ''))
|
|
42
58
|
end
|
|
43
59
|
end
|
|
44
60
|
end
|
data/plugins/filter/accept.rb
CHANGED
|
@@ -1,62 +1,55 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Filter::Accept
|
|
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:: Jun 4, 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 FilterAccept
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
FIELDS = %i[title link description].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(config, pipeline = [])
|
|
16
|
+
@config = config || {}
|
|
14
17
|
@pipeline = pipeline
|
|
15
18
|
end
|
|
16
19
|
|
|
20
|
+
# The complement of FilterIgnore: keeps only the items that match. Matching
|
|
21
|
+
# is a substring test, so an empty keyword matches everything.
|
|
17
22
|
def run
|
|
18
|
-
@
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
feeds.items.each {|items|
|
|
23
|
-
new_feeds << items if contain(items) == true
|
|
24
|
-
}
|
|
25
|
-
end
|
|
26
|
-
@return_feeds << Automatic::FeedMaker.create_pipeline(new_feeds) if new_feeds.length > 0
|
|
27
|
-
}
|
|
28
|
-
@return_feeds
|
|
23
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
24
|
+
kept = feeds.nil? ? [] : feeds.items.select { |item| contain?(item) }
|
|
25
|
+
returned << Automatic::FeedMaker.create_pipeline(kept) unless kept.empty?
|
|
26
|
+
end
|
|
29
27
|
end
|
|
30
28
|
|
|
31
29
|
private
|
|
32
30
|
|
|
33
|
-
def contain(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
detection = true
|
|
39
|
-
Automatic::Log.puts("info", "Contain by title: #{items.link}")
|
|
40
|
-
end
|
|
41
|
-
}
|
|
42
|
-
end
|
|
43
|
-
unless @config['link'].nil?
|
|
44
|
-
@config['link'].each {|e|
|
|
45
|
-
if items.link.include?(e.chomp)
|
|
46
|
-
detection = true
|
|
47
|
-
Automatic::Log.puts("info", "Contain by link: #{items.link}")
|
|
48
|
-
end
|
|
49
|
-
}
|
|
31
|
+
def contain?(item)
|
|
32
|
+
FIELDS.any? do |field|
|
|
33
|
+
Array(@config[field.to_s]).any? do |keyword|
|
|
34
|
+
matched?(item, field, keyword.to_s.chomp)
|
|
35
|
+
end
|
|
50
36
|
end
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# An item whose field is missing is not matched, and says so. The earlier
|
|
40
|
+
# version called #include? on it and ended the run with a NoMethodError,
|
|
41
|
+
# which is not what the complementary filter does with the same item.
|
|
42
|
+
def matched?(item, field, keyword)
|
|
43
|
+
value = item.send(field)
|
|
44
|
+
unless value.respond_to?(:include?)
|
|
45
|
+
Automatic::Log.puts('warn', "Invalid feed detected in accept process with #{field}")
|
|
46
|
+
return false
|
|
58
47
|
end
|
|
59
|
-
|
|
48
|
+
|
|
49
|
+
return false unless value.include?(keyword)
|
|
50
|
+
|
|
51
|
+
Automatic::Log.puts('info', "Contain by #{field}: #{item.link}")
|
|
52
|
+
true
|
|
60
53
|
end
|
|
61
54
|
end
|
|
62
55
|
end
|