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,278 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Name:: Automatic::Plugin::Publish::Markdown
|
|
3
|
+
# Description:: Render the pipeline as a Markdown document, to a file or to standard output.
|
|
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 14, 2026
|
|
9
|
+
# Updated:: Aug 14, 2026
|
|
10
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
11
|
+
|
|
12
|
+
module Automatic::Plugin
|
|
13
|
+
class PublishMarkdown
|
|
14
|
+
require 'fileutils'
|
|
15
|
+
|
|
16
|
+
# Written under an item's heading, in this order. A field the item does not
|
|
17
|
+
# carry produces no bullet at all, so an item is not padded out with empty
|
|
18
|
+
# ones. See doc/PLUGINS.md section 6.7.
|
|
19
|
+
METADATA_FIELDS = [
|
|
20
|
+
['Link', :link],
|
|
21
|
+
['Date', :date],
|
|
22
|
+
['Author', :author],
|
|
23
|
+
['Comments', :comments],
|
|
24
|
+
['Source', :source],
|
|
25
|
+
['Enclosure', :enclosure]
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# Elements that end a line of text. Everything else is unwrapped: the body
|
|
29
|
+
# is reduced to text rather than translated into Markdown, because doing
|
|
30
|
+
# the latter properly needs a library this framework declines to depend on.
|
|
31
|
+
BLOCK_ELEMENTS = %w[
|
|
32
|
+
address article aside blockquote dd div dl dt figcaption figure footer
|
|
33
|
+
form h1 h2 h3 h4 h5 h6 header hr li main nav ol p pre section table
|
|
34
|
+
tbody td th thead tr ul
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
# A body counts as HTML when it carries a tag or a character entity. One
|
|
38
|
+
# that carries neither is written as it stands, so that a "<" in a
|
|
39
|
+
# plain-text description is not parsed away.
|
|
40
|
+
MARKUP = %r{<[a-zA-Z/!]|&[a-zA-Z#][0-9a-zA-Z]*;}
|
|
41
|
+
|
|
42
|
+
# What the substitute reducer below matches, in the order it applies them.
|
|
43
|
+
COMMENT_ELEMENT = /<!--.*?-->/m
|
|
44
|
+
DISCARDED_ELEMENT = %r{<(script|style)\b[^>]*>.*?</\1\s*>}mi
|
|
45
|
+
BREAK_ELEMENT = /<br\b[^>]*>/i
|
|
46
|
+
BLOCK_ELEMENT = %r{</?(?:#{BLOCK_ELEMENTS.join('|')})\b[^>]*>}i
|
|
47
|
+
TAG = /<[^>]*>/m
|
|
48
|
+
ENTITY = /&(\#\d+|\#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/
|
|
49
|
+
|
|
50
|
+
# The character references a feed body actually tends to carry: the five of
|
|
51
|
+
# XML, and the punctuation and symbols that follow prose out of a web page.
|
|
52
|
+
# A reference in neither this table nor the numeric form is left as it is
|
|
53
|
+
# written, which a reader can still make sense of; the full HTML set is two
|
|
54
|
+
# thousand entries and a parser's business.
|
|
55
|
+
ENTITIES = {
|
|
56
|
+
'amp' => '&', 'lt' => '<', 'gt' => '>', 'quot' => '"', 'apos' => "'",
|
|
57
|
+
'nbsp' => "\u00A0", 'copy' => '©', 'reg' => '®', 'trade' => '™',
|
|
58
|
+
'hellip' => '…', 'mdash' => '—', 'ndash' => '–', 'middot' => '·',
|
|
59
|
+
'bull' => '•', 'deg' => '°', 'laquo' => '«', 'raquo' => '»',
|
|
60
|
+
'lsquo' => '‘', 'rsquo' => '’', 'ldquo' => '“', 'rdquo' => '”',
|
|
61
|
+
'yen' => '¥', 'pound' => '£', 'euro' => '€'
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
# The item's own date, in the zone the item carries. Nothing is converted
|
|
65
|
+
# to local time: the same pipeline then produces the same document
|
|
66
|
+
# wherever it runs.
|
|
67
|
+
DATE_FORMAT = '%Y-%m-%d %H:%M:%S %z'
|
|
68
|
+
|
|
69
|
+
UNTITLED = '(untitled)'
|
|
70
|
+
|
|
71
|
+
def initialize(config, pipeline=[])
|
|
72
|
+
@config = config || {}
|
|
73
|
+
@pipeline = pipeline
|
|
74
|
+
@output = $stdout
|
|
75
|
+
@file = @config['file'].nil? ? nil : File.expand_path(@config['file'].to_s)
|
|
76
|
+
@mode = @config['mode'] == 'overwrite' ? 'w' : 'a'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def run
|
|
80
|
+
sections = render
|
|
81
|
+
write(sections) unless sections.empty?
|
|
82
|
+
@pipeline
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def render
|
|
88
|
+
sections = []
|
|
89
|
+
@pipeline.each {|feeds|
|
|
90
|
+
next if feeds.nil?
|
|
91
|
+
|
|
92
|
+
feeds.items.each {|feed|
|
|
93
|
+
sections << section(feed)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
sections
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Heading, then the metadata list, then the body, each separated by a blank
|
|
100
|
+
# line and the whole followed by one. Appending a document to a document is
|
|
101
|
+
# then still a Markdown document.
|
|
102
|
+
def section(feed)
|
|
103
|
+
parts = ["## #{heading(feed)}\n"]
|
|
104
|
+
metadata = metadata_list(feed)
|
|
105
|
+
parts << metadata unless metadata.empty?
|
|
106
|
+
body = body_text(feed)
|
|
107
|
+
parts << body unless body.empty?
|
|
108
|
+
parts.join("\n") + "\n"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def heading(feed)
|
|
112
|
+
title = one_line(value(feed, :title))
|
|
113
|
+
return title unless title.empty?
|
|
114
|
+
|
|
115
|
+
link = one_line(value(feed, :link))
|
|
116
|
+
link.empty? ? UNTITLED : link
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def metadata_list(feed)
|
|
120
|
+
METADATA_FIELDS.map {|label, name|
|
|
121
|
+
text = name == :date ? date(feed) : one_line(value(feed, name))
|
|
122
|
+
next if text.empty?
|
|
123
|
+
|
|
124
|
+
"- #{label}: #{url?(text) ? "<#{text}>" : text}\n"
|
|
125
|
+
}.compact.join
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# content_encoded when the item has one and description otherwise: a feed
|
|
129
|
+
# carrying both puts the summary in the second and the article in the first.
|
|
130
|
+
def body_text(feed)
|
|
131
|
+
text = value(feed, :content_encoded)
|
|
132
|
+
text = value(feed, :description) if text.empty?
|
|
133
|
+
MARKUP.match?(text) ? normalize(html_to_text(text), true) : normalize(text, false)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def date(feed)
|
|
137
|
+
field = feed.respond_to?(:date) ? feed.date : nil
|
|
138
|
+
return '' if field.nil?
|
|
139
|
+
|
|
140
|
+
field.respond_to?(:strftime) ? field.strftime(DATE_FORMAT) : one_line(field.to_s)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Any field may be absent, and a parsed feed answers with an element rather
|
|
144
|
+
# than a string where the format has one: a source carries its text in
|
|
145
|
+
# #content, an enclosure its URL in #url.
|
|
146
|
+
def value(feed, name)
|
|
147
|
+
return '' unless feed.respond_to?(name)
|
|
148
|
+
|
|
149
|
+
field = feed.send(name)
|
|
150
|
+
return '' if field.nil?
|
|
151
|
+
|
|
152
|
+
if field.respond_to?(:content)
|
|
153
|
+
field.content.to_s.strip
|
|
154
|
+
elsif field.respond_to?(:url)
|
|
155
|
+
field.url.to_s.strip
|
|
156
|
+
else
|
|
157
|
+
field.to_s.strip
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def one_line(text)
|
|
162
|
+
text.gsub(/\s+/, ' ').strip
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def url?(text)
|
|
166
|
+
text.match?(%r{\A[a-zA-Z][a-zA-Z0-9+.-]*://\S+\z})
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Reduce markup to text: script and style go with their contents, a break
|
|
170
|
+
# ends a line, a block element ends a paragraph, entities are decoded, and
|
|
171
|
+
# the tags themselves are dropped.
|
|
172
|
+
#
|
|
173
|
+
# A parser does it where one is installed, and the substitution below does
|
|
174
|
+
# it where none is. That is what keeps this plugin -- the one a Recipe ends
|
|
175
|
+
# with when the result is meant to be read -- runnable on a plain `gem
|
|
176
|
+
# install automatic`: nokogiri is an optional dependency, and reducing a
|
|
177
|
+
# feed body to text is not a good enough reason to make everyone install a
|
|
178
|
+
# native extension.
|
|
179
|
+
# The two agree on what a body is reduced to; a parser is simply better at
|
|
180
|
+
# markup that is malformed. See doc/PLUGINS.md section 6.7.
|
|
181
|
+
def html_to_text(html)
|
|
182
|
+
html_parser? ? parsed_text(html) : substituted_text(html)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Memoized, and false rather than an exception when the gem is absent: this
|
|
186
|
+
# question is asked once per body.
|
|
187
|
+
def html_parser?
|
|
188
|
+
return @html_parser unless @html_parser.nil?
|
|
189
|
+
|
|
190
|
+
@html_parser =
|
|
191
|
+
begin
|
|
192
|
+
require 'nokogiri'
|
|
193
|
+
true
|
|
194
|
+
rescue LoadError
|
|
195
|
+
false
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def substituted_text(html)
|
|
200
|
+
text = html.gsub(COMMENT_ELEMENT, '')
|
|
201
|
+
text = text.gsub(DISCARDED_ELEMENT, '')
|
|
202
|
+
text = text.gsub(BREAK_ELEMENT, "\n")
|
|
203
|
+
text = text.gsub(BLOCK_ELEMENT, "\n\n")
|
|
204
|
+
unescape(text.gsub(TAG, ''))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# One pass, so that "&lt;" decodes to "<" and not to "<", which is
|
|
208
|
+
# what a parser does with it too.
|
|
209
|
+
def unescape(text)
|
|
210
|
+
text.gsub(ENTITY) {|reference|
|
|
211
|
+
name = Regexp.last_match(1)
|
|
212
|
+
name.start_with?('#') ? character(name) || reference
|
|
213
|
+
: ENTITIES.fetch(name, reference)
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# A numeric character reference, or nil where it names no character: a
|
|
218
|
+
# surrogate, a value past the last code point, or zero.
|
|
219
|
+
def character(name)
|
|
220
|
+
code = name.start_with?('#x', '#X') ? name[2..].to_i(16) : name[1..].to_i
|
|
221
|
+
return nil unless code.positive?
|
|
222
|
+
|
|
223
|
+
begin
|
|
224
|
+
code.chr(Encoding::UTF_8)
|
|
225
|
+
rescue RangeError
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def parsed_text(html)
|
|
231
|
+
fragment = Nokogiri::HTML.fragment(html)
|
|
232
|
+
fragment.css('script, style').each {|node| node.remove }
|
|
233
|
+
fragment.css('br').each {|node| node.replace(text_node(node, "\n")) }
|
|
234
|
+
fragment.css(BLOCK_ELEMENTS.join(', ')).each {|node|
|
|
235
|
+
node.add_previous_sibling(text_node(node, "\n"))
|
|
236
|
+
node.add_next_sibling(text_node(node, "\n\n"))
|
|
237
|
+
}
|
|
238
|
+
fragment.text
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def text_node(node, text)
|
|
242
|
+
Nokogiri::XML::Text.new(text, node.document)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Line endings become "\n", trailing whitespace goes, and a run of blank
|
|
246
|
+
# lines collapses to one. Text that came from markup also loses the
|
|
247
|
+
# source's own indentation, which is layout rather than content and which
|
|
248
|
+
# Markdown would otherwise read as a code block.
|
|
249
|
+
def normalize(text, from_markup)
|
|
250
|
+
lines = text.gsub(/\r\n?/, "\n").split("\n", -1).map {|line|
|
|
251
|
+
from_markup ? line.gsub(/[[:blank:]]+/, ' ').strip : line.rstrip
|
|
252
|
+
}
|
|
253
|
+
normalized = lines.each_with_object([]) {|line, kept|
|
|
254
|
+
next if line.empty? && (kept.empty? || kept.last.empty?)
|
|
255
|
+
|
|
256
|
+
kept << line
|
|
257
|
+
}
|
|
258
|
+
normalized.pop while !normalized.empty? && normalized.last.empty?
|
|
259
|
+
normalized.empty? ? '' : normalized.join("\n") + "\n"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# One line per run rather than one per item: this log shares standard
|
|
263
|
+
# output with the document, and the Recipe decides which of them is wanted
|
|
264
|
+
# there. See doc/PLUGINS.md section 6.7.
|
|
265
|
+
def write(sections)
|
|
266
|
+
document = sections.join
|
|
267
|
+
counted = sections.size == 1 ? '1 item' : "#{sections.size} items"
|
|
268
|
+
if @file.nil?
|
|
269
|
+
@output.print(document)
|
|
270
|
+
Automatic::Log.puts('info', "Publish Markdown: #{counted} to standard output")
|
|
271
|
+
else
|
|
272
|
+
FileUtils.mkdir_p(File.dirname(@file))
|
|
273
|
+
File.open(@file, @mode, encoding: 'UTF-8') {|out| out.print(document) }
|
|
274
|
+
Automatic::Log.puts('info', "Publish Markdown: #{counted} to #{@file}")
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -1,44 +1,50 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Publish::Memcached
|
|
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 25, 2013
|
|
8
|
+
# Updated:: Aug 15, 2026
|
|
9
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
8
10
|
|
|
9
11
|
module Automatic::Plugin
|
|
10
12
|
class PublishMemcached
|
|
11
|
-
|
|
13
|
+
Automatic.require_optional('dalli', needed_by: 'PublishMemcached')
|
|
12
14
|
|
|
13
|
-
def initialize(config, pipeline=[])
|
|
14
|
-
@config
|
|
15
|
+
def initialize(config, pipeline = [])
|
|
16
|
+
@config = config || {}
|
|
15
17
|
@pipeline = pipeline
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
# Interpolated rather than concatenated: `port: 11211` in a Recipe is an
|
|
19
|
+
# Integer, and String#+ ended the run on it.
|
|
20
|
+
@cache = Dalli::Client.new("#{@config['host']}:#{@config['port']}")
|
|
19
21
|
end
|
|
20
22
|
|
|
23
|
+
# Collects the whole pipeline into one hash keyed by link and stores it
|
|
24
|
+
# under a single key, replacing the previous value.
|
|
21
25
|
def run
|
|
22
|
-
|
|
23
|
-
@pipeline
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
@cache.set(@config['key'], collect)
|
|
27
|
+
@pipeline
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
Automatic::Log.puts('warn', "Skip feed due to fault in put to memcached: #{e.message}")
|
|
30
|
+
@pipeline
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def collect
|
|
36
|
+
@pipeline.each_with_object({}) do |feeds, hash|
|
|
37
|
+
next if feeds.nil?
|
|
38
|
+
|
|
39
|
+
feeds.items.each do |feed|
|
|
40
|
+
hash[feed.link] = {
|
|
41
|
+
title: feed.title,
|
|
42
|
+
description: feed.description,
|
|
43
|
+
content: feed.content_encoded,
|
|
44
|
+
created_at: Time.now.strftime('%Y/%m/%d %X')
|
|
33
45
|
}
|
|
34
46
|
end
|
|
35
|
-
}
|
|
36
|
-
begin
|
|
37
|
-
@cache.set(@config['key'], hash)
|
|
38
|
-
rescue
|
|
39
|
-
Automatic::Log.puts("warn", "Skip feed due to fault in put to memcached.")
|
|
40
47
|
end
|
|
41
|
-
@pipeline
|
|
42
48
|
end
|
|
43
49
|
end
|
|
44
50
|
end
|
data/plugins/store/database.rb
CHANGED
|
@@ -1,69 +1,71 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
# Name::
|
|
3
|
-
# Author
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
2
|
+
# Name:: Automatic::Plugin::Store::Database
|
|
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 27, 2012
|
|
8
|
+
# Updated:: Aug 17, 2026
|
|
9
|
+
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.
|
|
10
|
+
#
|
|
11
|
+
# The SQLite storage the store plugins share. ActiveRecord and sqlite3 are the
|
|
12
|
+
# store plugins' own dependencies, not the framework's: a Recipe that stores
|
|
13
|
+
# nothing runs without them. See doc/POLICY.md section 9.1.
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
Automatic.require_optional('active_record',
|
|
16
|
+
gem_name: 'activerecord',
|
|
17
|
+
needed_by: 'the store plugins')
|
|
18
|
+
Automatic.require_optional('sqlite3',
|
|
19
|
+
needed_by: 'the store plugins')
|
|
12
20
|
|
|
13
21
|
module Automatic::Plugin
|
|
14
22
|
module Database
|
|
23
|
+
# Yields each item that is not already in the table, and returns a pipeline
|
|
24
|
+
# of those items. An item with no link is neither stored nor passed on.
|
|
15
25
|
def for_each_new_feed
|
|
16
26
|
prepare_database
|
|
17
|
-
|
|
18
|
-
@
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
detection = existing_records.detect {|b| b.try(unique_keys[0]) == feed.link || b.try(unique_keys[1]) == feed.title }
|
|
26
|
-
else
|
|
27
|
-
detection = existing_records.detect {|b| b.try(unique_keys[0]) == feed.link }
|
|
28
|
-
end
|
|
29
|
-
unless detection
|
|
30
|
-
yield(feed)
|
|
31
|
-
new_feeds << feed
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
}
|
|
35
|
-
@return_feeds << Automatic::FeedMaker.create_pipeline(new_feeds) if new_feeds.length > 0
|
|
36
|
-
end
|
|
37
|
-
}
|
|
38
|
-
@return_feeds
|
|
27
|
+
|
|
28
|
+
@pipeline.each_with_object([]) do |feeds, returned|
|
|
29
|
+
next if feeds.nil?
|
|
30
|
+
|
|
31
|
+
new_feeds = feeds.items.reject { |feed| feed.link.nil? || stored?(feed) }
|
|
32
|
+
new_feeds.each { |feed| yield(feed) }
|
|
33
|
+
returned << Automatic::FeedMaker.create_pipeline(new_feeds) unless new_feeds.empty?
|
|
34
|
+
end
|
|
39
35
|
end
|
|
40
36
|
|
|
41
37
|
private
|
|
42
38
|
|
|
39
|
+
# Asked of the database rather than of every row loaded into memory, which
|
|
40
|
+
# is what this did before: a store whose database has grown to a year of
|
|
41
|
+
# links reads one index entry per item now instead of the whole table per
|
|
42
|
+
# run.
|
|
43
|
+
def stored?(feed)
|
|
44
|
+
scope = model_class.where(unique_keys[0] => feed.link)
|
|
45
|
+
scope = scope.or(model_class.where(unique_keys[1] => feed.title)) if unique_keys.length > 1
|
|
46
|
+
scope.exists?
|
|
47
|
+
end
|
|
48
|
+
|
|
43
49
|
def create_table
|
|
44
|
-
ActiveRecord::
|
|
45
|
-
column_definition.each_pair
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
ActiveRecord::Base.connection.create_table(model_class.table_name) do |table|
|
|
51
|
+
column_definition.each_pair do |name, type|
|
|
52
|
+
table.column name, type
|
|
53
|
+
end
|
|
54
|
+
end
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
def db_dir
|
|
52
|
-
dir =
|
|
53
|
-
if File.directory?(dir)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
File.join(File.dirname(__FILE__), '..', '..', 'db')
|
|
57
|
-
end
|
|
58
|
+
dir = File.expand_path('~/.automatic/db')
|
|
59
|
+
return dir if File.directory?(dir)
|
|
60
|
+
|
|
61
|
+
File.expand_path('../../db', __dir__)
|
|
58
62
|
end
|
|
59
63
|
|
|
60
64
|
def prepare_database
|
|
61
|
-
db = File.join(db_dir, @config['db'])
|
|
62
|
-
Automatic::Log.puts(
|
|
63
|
-
ActiveRecord::Base.establish_connection(
|
|
64
|
-
|
|
65
|
-
:database => db)
|
|
66
|
-
create_table unless model_class.table_exists?
|
|
65
|
+
db = File.join(db_dir, @config['db'].to_s)
|
|
66
|
+
Automatic::Log.puts('info', "Using Database: #{db}")
|
|
67
|
+
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: db)
|
|
68
|
+
create_table unless ActiveRecord::Base.connection.table_exists?(model_class.table_name)
|
|
67
69
|
end
|
|
68
70
|
end
|
|
69
71
|
end
|