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 +7 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +170 -0
- data/LICENSE +21 -0
- data/README.md +60 -0
- data/bin/google.rb +99 -0
- data/bin/skiptorium +117 -0
- data/config.yml.example +27 -0
- data/lib/skiptorium/article.rb +114 -0
- data/lib/skiptorium/base_destination.rb +27 -0
- data/lib/skiptorium/base_source.rb +40 -0
- data/lib/skiptorium/blogger/destination.rb +98 -0
- data/lib/skiptorium/config.rb +18 -0
- data/lib/skiptorium/dev_to/destination.rb +62 -0
- data/lib/skiptorium/dev_to/source.rb +71 -0
- data/lib/skiptorium/github_pages/destination.rb +69 -0
- data/lib/skiptorium/hashnode/destination.rb +188 -0
- data/lib/skiptorium/local/destination.rb +34 -0
- data/lib/skiptorium/rss/source.rb +50 -0
- data/lib/skiptorium/version.rb +3 -0
- data/lib/skiptorium.rb +83 -0
- data/test/fixtures/dev_to_me_article_1.json +31 -0
- data/test/fixtures/dev_to_me_article_2.json +38 -0
- data/test/fixtures/dev_to_me_articles.json +1211 -0
- data/test/fixtures/rss_teletype.xml +150 -0
- metadata +227 -0
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
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
|
+
[](https://rubygems.org/gems/skiptorium)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
[](https://github.com/kinnalru/skiptorium/commits/master)
|
|
10
|
+
[](https://www.ruby-lang.org)
|
|
11
|
+
[](spec/)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
[](./AGENTS.md)
|
|
15
|
+
[](https://opencode.ai)
|
|
16
|
+
[](https://gitlab.com/kinnalru/skiptorium)
|
|
17
|
+
|
|
18
|
+
[](https://gitlab.com/kinnalru/wannasee/-/releases)
|
|
19
|
+
[](https://gitlab.com/kinnalru/wannasee)
|
|
20
|
+
[](https://gitlab.com/kinnalru/wannasee/-/graphs/master)
|
|
21
|
+
[](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)
|
data/config.yml.example
ADDED
|
@@ -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
|