skiptorium 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9f55da0dce83c05e1705857dd06833929ce49eaab3cb448b22e2783ad23a73fe
4
+ data.tar.gz: e953be5ae65220e8e5c5321bd84edf368c23c805da2887fc361d8b9c23ec3c17
5
+ SHA512:
6
+ metadata.gz: 179db2b8fd616b7da27c6a9c6047bdd105018c29f0a856deb098637fa2d2f21610c6cde4b08e8f1cc19b38ca52d3a348d0a5764652781e526ed383aa9151ff32
7
+ data.tar.gz: fc4714b810902f092eef0da40c931cd7efe59a334e69b2e70eb738d4fdb06275daf24c955b2a301eb2e370254cfef3f085f39a3f5399db7488a7f4a8a2f5a1f6
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,170 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ skiptorium (0.1.1)
5
+ activesupport (~> 8.1)
6
+ base64 (~> 0.3)
7
+ faraday (~> 2.14)
8
+ front_matter_parser (~> 1.0)
9
+ redcarpet (~> 3.6)
10
+ reverse_markdown (~> 3.0)
11
+ rss (~> 0.3)
12
+ russian (~> 1.0)
13
+
14
+ GEM
15
+ remote: https://rubygems.org/
16
+ specs:
17
+ activesupport (8.1.3.1)
18
+ base64
19
+ bigdecimal
20
+ concurrent-ruby (~> 1.0, >= 1.3.1)
21
+ connection_pool (>= 2.2.5)
22
+ drb
23
+ i18n (>= 1.6, < 2)
24
+ json
25
+ logger (>= 1.4.2)
26
+ minitest (>= 5.1)
27
+ securerandom (>= 0.3)
28
+ tzinfo (~> 2.0, >= 2.0.5)
29
+ uri (>= 0.13.1)
30
+ addressable (2.9.0)
31
+ public_suffix (>= 2.0.2, < 8.0)
32
+ base64 (0.3.0)
33
+ bigdecimal (4.1.2)
34
+ concurrent-ruby (1.3.8)
35
+ connection_pool (3.0.2)
36
+ crack (1.0.1)
37
+ bigdecimal
38
+ rexml
39
+ diff-lcs (1.6.2)
40
+ drb (2.2.3)
41
+ faraday (2.14.3)
42
+ faraday-net_http (>= 2.0, < 3.5)
43
+ json
44
+ logger
45
+ faraday-net_http (3.4.4)
46
+ net-http (~> 0.5)
47
+ front_matter_parser (1.0.1)
48
+ hashdiff (1.2.1)
49
+ i18n (1.15.2)
50
+ concurrent-ruby (~> 1.0)
51
+ json (2.21.2)
52
+ logger (1.7.0)
53
+ minitest (6.0.6)
54
+ drb (~> 2.0)
55
+ prism (~> 1.5)
56
+ net-http (0.9.1)
57
+ uri (>= 0.11.1)
58
+ nokogiri (1.19.4-aarch64-linux-gnu)
59
+ racc (~> 1.4)
60
+ nokogiri (1.19.4-aarch64-linux-musl)
61
+ racc (~> 1.4)
62
+ nokogiri (1.19.4-arm-linux-gnu)
63
+ racc (~> 1.4)
64
+ nokogiri (1.19.4-arm-linux-musl)
65
+ racc (~> 1.4)
66
+ nokogiri (1.19.4-arm64-darwin)
67
+ racc (~> 1.4)
68
+ nokogiri (1.19.4-x86_64-darwin)
69
+ racc (~> 1.4)
70
+ nokogiri (1.19.4-x86_64-linux-gnu)
71
+ racc (~> 1.4)
72
+ nokogiri (1.19.4-x86_64-linux-musl)
73
+ racc (~> 1.4)
74
+ prism (1.9.0)
75
+ public_suffix (7.0.5)
76
+ racc (1.8.1)
77
+ redcarpet (3.6.1)
78
+ reverse_markdown (3.0.2)
79
+ nokogiri
80
+ rexml (3.4.4)
81
+ rspec (3.13.2)
82
+ rspec-core (~> 3.13.0)
83
+ rspec-expectations (~> 3.13.0)
84
+ rspec-mocks (~> 3.13.0)
85
+ rspec-core (3.13.6)
86
+ rspec-support (~> 3.13.0)
87
+ rspec-expectations (3.13.5)
88
+ diff-lcs (>= 1.2.0, < 2.0)
89
+ rspec-support (~> 3.13.0)
90
+ rspec-mocks (3.13.8)
91
+ diff-lcs (>= 1.2.0, < 2.0)
92
+ rspec-support (~> 3.13.0)
93
+ rspec-support (3.13.7)
94
+ rss (0.3.3)
95
+ rexml
96
+ russian (1.0.0)
97
+ i18n (>= 1.14.8, < 2)
98
+ securerandom (0.4.1)
99
+ tzinfo (2.0.6)
100
+ concurrent-ruby (~> 1.0)
101
+ uri (1.1.1)
102
+ webmock (3.26.2)
103
+ addressable (>= 2.8.0)
104
+ crack (>= 0.3.2)
105
+ hashdiff (>= 0.4.0, < 2.0.0)
106
+
107
+ PLATFORMS
108
+ aarch64-linux-gnu
109
+ aarch64-linux-musl
110
+ arm-linux-gnu
111
+ arm-linux-musl
112
+ arm64-darwin
113
+ x86_64-darwin
114
+ x86_64-linux-gnu
115
+ x86_64-linux-musl
116
+
117
+ DEPENDENCIES
118
+ bundler (~> 2.0, >= 2.0.1)
119
+ rspec (~> 3.13)
120
+ skiptorium!
121
+ webmock (~> 3.26)
122
+
123
+ CHECKSUMS
124
+ activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376
125
+ addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
126
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
127
+ bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
128
+ concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
129
+ connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
130
+ crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
131
+ diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
132
+ drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
133
+ faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278
134
+ faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
135
+ front_matter_parser (1.0.1) sha256=bae298bda01db95788a4d6452f1670a3d198c6716c8d3727db9a95533deb7b7b
136
+ hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
137
+ i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
138
+ json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
139
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
140
+ minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
141
+ net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
142
+ nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f
143
+ nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af
144
+ nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca
145
+ nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb
146
+ nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527
147
+ nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551
148
+ nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a
149
+ nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b
150
+ prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
151
+ public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
152
+ racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
153
+ redcarpet (3.6.1) sha256=d444910e6aa55480c6bcdc0cdb057626e8a32c054c29e793fa642ba2f155f445
154
+ reverse_markdown (3.0.2) sha256=818ebb92ce39dbb1a291690dd1ec9a6d62530d4725296b17e9c8f668f9a5b8af
155
+ rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
156
+ rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
157
+ rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
158
+ rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
159
+ rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
160
+ rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
161
+ rss (0.3.3) sha256=37ef1ec4d691d67edbe92159079a4e89a0965e6a6b32246009ae3d734d12d1ab
162
+ russian (1.0.0) sha256=cb9234d03c535b193b7933d77b855653d0037d05fe442aaac43f1c28d5d9b54a
163
+ securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
164
+ skiptorium (0.1.1)
165
+ tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
166
+ uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
167
+ webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90
168
+
169
+ BUNDLED WITH
170
+ 2.7.2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samoilenko Yuri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # Skiptorium
2
+
3
+ > Personal CLI tool for automatic article publishing across multiple platforms.
4
+
5
+ <div align="center">
6
+
7
+ [![Gem Version](https://img.shields.io/gem/v/skiptorium?style=flat-square&logo=ruby&logoColor=white)](https://rubygems.org/gems/skiptorium)
8
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square&logo=open-source-initiative&logoColor=white)](LICENSE)
9
+ [![Commits](https://img.shields.io/github/last-commit/kinnalru/skiptorium?style=flat-square&logo=git&logoColor=white)](https://github.com/kinnalru/skiptorium/commits/master)
10
+ [![Ruby](https://img.shields.io/badge/ruby-3.0+-cc342d?style=flat-square&logo=ruby&logoColor=white)](https://www.ruby-lang.org)
11
+ [![RSpec](https://img.shields.io/badge/tests-rspec-brightgreen?style=flat-square&logo=checkmarx&logoColor=white)](spec/)
12
+
13
+
14
+ [![AI-Assisted](https://img.shields.io/badge/AI-Assisted-10b981?style=flat-square&logo=robotframework&logoColor=white)](./AGENTS.md)
15
+ [![OpenCode](https://img.shields.io/badge/Built_with-OpenCode-6366f1?style=flat-square&logo=opencode&logoColor=white)](https://opencode.ai)
16
+ [![Primary](https://img.shields.io/badge/primary-Gitlab-fc6d26?style=flat-square&logo=git&logoColor=white)](https://gitlab.com/kinnalru/skiptorium)
17
+
18
+ [![Version](https://img.shields.io/badge/version-0.1.1-blue?style=flat-square)](https://gitlab.com/kinnalru/wannasee/-/releases)
19
+ [![License](https://img.shields.io/gitlab/license/kinnalru/wannasee?style=flat-square)](https://gitlab.com/kinnalru/wannasee)
20
+ [![Contributors](https://img.shields.io/gitlab/contributors/kinnalru/wannasee?style=flat-square)](https://gitlab.com/kinnalru/wannasee/-/graphs/master)
21
+ [![Last Commit](https://img.shields.io/gitlab/last-commit/kinnalru/wannasee?style=flat-square&logo=gitlab)](https://gitlab.com/kinnalru/wannasee/-/commits/master)
22
+
23
+
24
+ </div>
25
+
26
+ ---
27
+
28
+ ## Features
29
+
30
+ - **Multi-source ingestion** — fetch articles from
31
+ - **Dev.to** (Markdown)
32
+ - **RSS feeds** (HTML)
33
+ - **Multi-platform publishing** — publish to **dev.to**, **hashnode**, **GitHub Pages**, **Blogger**, and **local files**
34
+ - **Idempotent sync** — re-runs do nothing when no new posts are available
35
+ - **Environment variable interpolation** — keep secrets out of config files with `${VAR}` syntax
36
+ - **Stable article identifiers** — detect already-published articles across all destinations
37
+ - **Dry-run mode** — preview what would be published without making changes
38
+ - **Built-in validation** — validate configuration before running sync
39
+
40
+ ## Quick Start
41
+
42
+ ```bash
43
+ # Run in dry-run mode to preview
44
+ skiptorium sync --config config.yml --dry-run
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ All settings live in a single YAML file (default: `config.yml`).
50
+ See [`config.yml.example`](config.yml.example) for a complete template.
51
+
52
+ Key points:
53
+ - Only **one** source can be configured
54
+ - Multiple destinations are supported
55
+ - Secrets use `${ENV_VAR}` interpolation — never hardcode API keys
56
+
57
+
58
+ ## License
59
+
60
+ [MIT](LICENSE)
data/bin/google.rb ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'socket'
4
+ require 'uri'
5
+ require 'faraday'
6
+ require 'json'
7
+
8
+ CLIENT_ID = ENV.fetch('GOOGLE_CLIENT_ID')
9
+ CLIENT_SECRET = ENV.fetch('GOOGLE_SECRET')
10
+ SCOPE = 'https://www.googleapis.com/auth/blogger'
11
+ REDIRECT_URI = 'http://localhost:8085'
12
+ AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
13
+ TOKEN_URL = 'https://oauth2.googleapis.com/token'
14
+
15
+ # Step 1: Build authorization URL
16
+ # access_type=offline -> request refresh token
17
+ # prompt=consent -> force consent screen so refresh_token is always returned
18
+ auth_params = {
19
+ 'client_id' => CLIENT_ID,
20
+ 'redirect_uri' => REDIRECT_URI,
21
+ 'scope' => SCOPE,
22
+ 'response_type' => 'code',
23
+ 'access_type' => 'offline',
24
+ 'prompt' => 'consent'
25
+ }
26
+ auth_uri = "#{AUTH_URL}?#{URI.encode_www_form(auth_params)}"
27
+
28
+ puts <<~MSG
29
+
30
+ ============================================================
31
+ Open this URL in your browser and authorize:
32
+ #{auth_uri}
33
+ ============================================================
34
+
35
+ Waiting for authorization on #{REDIRECT_URI}...
36
+ MSG
37
+
38
+ # Step 2: Start a tiny HTTP server to catch the redirect
39
+ server = TCPServer.new(8085)
40
+
41
+ client = server.accept
42
+ request = client.gets
43
+
44
+
45
+ puts request
46
+
47
+ # Parse authorization code from request line, e.g.:
48
+ # GET /?code=4/abc...&scope=... HTTP/1.1
49
+ match = request.match(/code=([^&\s]+)/)
50
+ puts match
51
+
52
+ code = match[1] if match
53
+
54
+ # Send a simple response back to the browser
55
+ client.print <<~HTTP
56
+ HTTP/1.1 200 OK\r\n
57
+ Content-Type: text/html; charset=utf-8\r\n
58
+ Connection: close\r\n
59
+ \r\n
60
+ <h1>Authorization complete!</h1>
61
+ <p>You can close this tab and return to the terminal.</p>
62
+ HTTP
63
+
64
+ client.close
65
+ server.close
66
+
67
+ unless code
68
+ puts "\nError: no authorization code received."
69
+ exit 1
70
+ end
71
+
72
+ puts "Authorization code received. Exchanging for tokens..."
73
+
74
+ # Step 3: Exchange code for tokens
75
+ response = Faraday.post(TOKEN_URL, {
76
+ 'grant_type' => 'authorization_code',
77
+ 'code' => code,
78
+ 'redirect_uri' => REDIRECT_URI,
79
+ 'client_id' => CLIENT_ID,
80
+ 'client_secret' => CLIENT_SECRET
81
+ })
82
+
83
+ unless response.success?
84
+ puts "Token exchange failed: #{response.status} #{response.body}"
85
+ exit 1
86
+ end
87
+
88
+ data = JSON.parse(response.body)
89
+
90
+ if data['error']
91
+ puts "Error: #{data['error']} - #{data['error_description']}"
92
+ exit 1
93
+ end
94
+
95
+ puts "\nSuccess!"
96
+ puts "access_token: #{data['access_token']}"
97
+ puts "refresh_token: #{data['refresh_token']}" if data['refresh_token']
98
+ puts "expires_in: #{data['expires_in']}"
99
+ puts "token_type: #{data['token_type']}"
data/bin/skiptorium ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+ require 'skiptorium'
5
+
6
+ class SkiptoriumCLI
7
+
8
+ def self.run(args)
9
+ command = args.shift || 'sync'
10
+
11
+ case command
12
+ when 'sync', 's'
13
+ run_sync(args)
14
+ when 'validate', 'v'
15
+ run_validate(args)
16
+ when 'version'
17
+ puts "Skiptorium #{Skiptorium::VERSION}"
18
+ when 'help', '-h', '--help'
19
+ print_help
20
+ else
21
+ $stderr.puts "Unknown command: #{command}"
22
+ print_help
23
+ exit 1
24
+ end
25
+ rescue Skiptorium::Error => e
26
+ $stderr.puts "Error: #{e.message}"
27
+ exit 1
28
+ end
29
+
30
+ def self.run_sync(args)
31
+ options = { config: 'config.yml', dry_run: false, verbose: false }
32
+
33
+ OptionParser.new do |opts|
34
+ opts.banner = "Usage: skiptorium sync [options]"
35
+ opts.separator ""
36
+ opts.separator "Options:"
37
+
38
+ opts.on("-c", "--config PATH", "Path to configuration file (default: config.yml)") do |path|
39
+ options[:config] = path
40
+ end
41
+
42
+ opts.on("-n", "--dry-run", "Show what would be published without actually doing it") do
43
+ options[:dry_run] = true
44
+ end
45
+
46
+ opts.on("-v", "--verbose", "Enable verbose output") do
47
+ options[:verbose] = true
48
+ end
49
+
50
+ opts.on("-h", "--help", "Show this help") do
51
+ puts opts
52
+ exit
53
+ end
54
+ end.parse!(args)
55
+
56
+ unless File.exist?(options[:config])
57
+ $stderr.puts "Config file not found: #{options[:config]}"
58
+ exit 1
59
+ end
60
+
61
+ Skiptorium.run(options[:config], dry_run: options[:dry_run], verbose: options[:verbose])
62
+ end
63
+
64
+ def self.run_validate(args)
65
+ options = { config: 'config.yml' }
66
+
67
+ OptionParser.new do |opts|
68
+ opts.banner = "Usage: skiptorium validate [options]"
69
+ opts.separator ""
70
+ opts.separator "Options:"
71
+
72
+ opts.on("-c", "--config PATH", "Path to configuration file (default: config.yml)") do |path|
73
+ options[:config] = path
74
+ end
75
+
76
+ opts.on("-h", "--help", "Show this help") do
77
+ puts opts
78
+ exit
79
+ end
80
+ end.parse!(args)
81
+
82
+ unless File.exist?(options[:config])
83
+ $stderr.puts "Config file not found: #{options[:config]}"
84
+ exit 1
85
+ end
86
+
87
+ config = Skiptorium::Config.load(options[:config])
88
+ puts "Configuration is valid."
89
+ puts "Source: #{config.source['type']}"
90
+ puts "Destinations: #{config.destinations.map { |d| d['type'] }.join(', ')}"
91
+ rescue => e
92
+ $stderr.puts "Invalid configuration: #{e.message}"
93
+ exit 1
94
+ end
95
+
96
+ def self.print_help
97
+ puts <<~HELP
98
+ Skiptorium #{Skiptorium::VERSION}
99
+
100
+ Usage: skiptorium COMMAND [options]
101
+
102
+ Commands:
103
+ sync Synchronize latest articles from source to destinations
104
+ validate Validate configuration file
105
+ version Show version
106
+ help Show this help
107
+
108
+ Global options:
109
+ -c, --config PATH Path to configuration file (default: config.yml)
110
+ -n, --dry-run Show what would be published without actually doing it
111
+ -v, --verbose Enable verbose output
112
+ -h, --help Show help for a command
113
+ HELP
114
+ end
115
+ end
116
+
117
+ SkiptoriumCLI.run(ARGV)
@@ -0,0 +1,27 @@
1
+ source:
2
+ type: dev_to
3
+ username: your_username
4
+ limit: 10
5
+
6
+ destinations:
7
+ - type: dev_to
8
+ api_key: ${DEV_TO_API_KEY}
9
+ organization_id: null
10
+
11
+ - type: hashnode
12
+ username: ${HASHNODE_USERNAME}
13
+ storage_state_path: ${HASHNODE_STORAGE_STATE_PATH}
14
+ playwright_cli_executable_path: ./node_modules/.bin/playwright-core
15
+
16
+ - type: github_pages
17
+ repo: ${GITHUB_REPO}
18
+ token: ${GITHUB_TOKEN}
19
+ branch: master
20
+ path: _posts
21
+
22
+ - type: blogger
23
+ blog_id: ${BLOGGER_BLOG_ID}
24
+ api_key: ${BLOGGER_API_KEY}
25
+ client_id: ${BLOGGER_CLIENT_ID}
26
+ client_secret: ${BLOGGER_CLIENT_SECRET}
27
+ refresh_token: ${BLOGGER_REFRESH_TOKEN}
@@ -0,0 +1,114 @@
1
+ require 'active_support/core_ext/hash'
2
+ require 'redcarpet'
3
+ require 'russian'
4
+
5
+ module Skiptorium
6
+ class Article
7
+ attr_reader :id, :title, :content, :tags, :canonical_url, :published_at, :cover_image, :slug
8
+
9
+ FRONT_MATTER_RX = /---([\s\S]*?)---/.freeze
10
+ FRONT_MATTER_LINE_RX = /^(?<key>\w+)\s*:\s*(?<value>.*)/.freeze
11
+
12
+ def initialize(attrs)
13
+ @id = attrs[:id]
14
+ @title = attrs.fetch(:title).to_s.strip
15
+ @tags = attrs[:tags] || []
16
+ @canonical_url = attrs[:canonical_url]&.strip
17
+ @published_at = attrs[:published_at] || DateTime.now
18
+ @cover_image = attrs[:cover_image]
19
+
20
+ @slug = attrs[:slug]&.strip || stable_slug
21
+
22
+ @markdown = attrs[:markdown]
23
+ @html = attrs[:html]
24
+ raw = attrs[:content].to_s.strip
25
+ @content = if markdown?
26
+ @front_matter = parse_front_matter(raw) || {}.with_indifferent_access
27
+ raw.gsub(/---([\s\S]*?)---/, '').strip
28
+ else
29
+ @front_matter = {}.with_indifferent_access
30
+ raw
31
+ end
32
+ end
33
+
34
+ def same?(other)
35
+ (@slug && @slug == other.slug) ||
36
+ (@title && @title == other.title) ||
37
+ (@canonical_url && @canonical_url == other.canonical_url)
38
+ end
39
+
40
+ def front_matter(key = nil)
41
+ key ? @front_matter&.fetch(key, nil) : @front_matter
42
+ end
43
+
44
+ def stable_slug
45
+ sl = Russian.transliterate(@title)
46
+ sl.downcase.strip.gsub(/[\W\-_]+/, '_').gsub(/_$/, '').gsub(/^_/, '')
47
+ end
48
+
49
+ def markdown?
50
+ @markdown
51
+ end
52
+
53
+ def html?
54
+ @html
55
+ end
56
+
57
+ def to_html
58
+ html? ? @content : markdown_to_html(@content)
59
+ end
60
+
61
+ def to_markdown(fm = {})
62
+ c = if markdown?
63
+ @content
64
+ else
65
+ html_to_markdown(@content)
66
+ end.strip
67
+
68
+ header = front_matter.merge(
69
+ {
70
+ title: title,
71
+ tags: [tags].flatten.compact,
72
+ canonical_url: canonical_url,
73
+ slug: stable_slug,
74
+ cover_image: cover_image,
75
+ image: cover_image,
76
+ published_at: published_at&.strftime('%Y-%m-%d %H:%M:%S %z'),
77
+ date: published_at&.strftime('%Y-%m-%d %H:%M:%S %z')
78
+ }.compact.with_indifferent_access
79
+ ).merge(fm.with_indifferent_access)
80
+
81
+ [
82
+ header.to_hash.sort.to_h.to_yaml.strip,
83
+ '---',
84
+ c
85
+ ].join("\n")
86
+ end
87
+
88
+ def markdown_to_html(data)
89
+ renderer = Redcarpet::Render::HTML.new(prettify: true)
90
+ md = Redcarpet::Markdown.new(renderer, fenced_code_blocks: true)
91
+ md.render(data)
92
+ end
93
+
94
+ def html_to_markdown(data)
95
+ ReverseMarkdown.convert(data)
96
+ end
97
+
98
+ def parse_front_matter(data)
99
+ if (md_fm = data.match(FRONT_MATTER_RX))
100
+ front_matter = md_fm[1].strip.split("\n").map do |line|
101
+ md = line.match(FRONT_MATTER_LINE_RX)
102
+ "#{md[:key]}: '#{md[:value]}'"
103
+ end.join("\n")
104
+ YAML.safe_load(front_matter).with_indifferent_access
105
+ end
106
+ end
107
+
108
+ def self.parse_date(str)
109
+ DateTime.parse(str) if str
110
+ rescue StandardError
111
+ nil
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,27 @@
1
+ require 'active_support/core_ext/hash'
2
+ require 'active_support/notifications'
3
+ require 'active_support/cache'
4
+ require 'active_support/cache/memory_store'
5
+
6
+ module Skiptorium
7
+ class BaseDestination
8
+ attr_reader :config, :cache
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ @cache = ActiveSupport::Cache::MemoryStore.new
13
+ end
14
+
15
+ def with_cache(*args, **kwargs, &block)
16
+ @cache.fetch([self.class.to_s, *args], **kwargs, &block)
17
+ end
18
+
19
+ def exists?(article)
20
+ raise NotImplementedError, "#{self.class} must implement #exists?"
21
+ end
22
+
23
+ def publish(article)
24
+ raise NotImplementedError, "#{self.class} must implement #publish"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ module Skiptorium
2
+ class BaseSource
3
+ attr_reader :config, :limit
4
+
5
+ def initialize(config)
6
+ @config = config
7
+ @limit = config['limit'] || 10
8
+ @filter = config['filter']
9
+ end
10
+
11
+ # @return [Array<Article>]
12
+ def fetch
13
+ raise NotImplementedError, "#{self.class} must implement #fetch_latest"
14
+ end
15
+
16
+ def fetch_latest
17
+ filtered = apply_filter(fetch)
18
+ filtered.first(@limit)
19
+ end
20
+
21
+ def apply_filter(stream)
22
+ return stream unless @filter
23
+
24
+ stream.select do |article|
25
+ pass = @filter.all? do |name, opts|
26
+ case name
27
+ when 'front_matter'
28
+ opts.all? do |k, v|
29
+ article.front_matter(k.strip) == v.to_s.strip
30
+ end
31
+ else
32
+ raise "Invalid filter #{name}: #{opts.inspect}"
33
+ end
34
+ end
35
+ # puts " article \"#{article.title}\" pass=#{pass}"
36
+ pass
37
+ end
38
+ end
39
+ end
40
+ end