skriptorium 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 +124 -0
- data/LICENSE +21 -0
- data/README.md +53 -0
- data/bin/google.rb +99 -0
- data/bin/skriptorium +117 -0
- data/config.yml.example +27 -0
- data/lib/skriptorium/article.rb +114 -0
- data/lib/skriptorium/base_destination.rb +27 -0
- data/lib/skriptorium/base_source.rb +40 -0
- data/lib/skriptorium/blogger/destination.rb +98 -0
- data/lib/skriptorium/config.rb +18 -0
- data/lib/skriptorium/dev_to/destination.rb +62 -0
- data/lib/skriptorium/dev_to/source.rb +71 -0
- data/lib/skriptorium/github_pages/destination.rb +69 -0
- data/lib/skriptorium/hashnode/destination.rb +188 -0
- data/lib/skriptorium/local/destination.rb +34 -0
- data/lib/skriptorium/rss/source.rb +50 -0
- data/lib/skriptorium/version.rb +3 -0
- data/lib/skriptorium.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 +230 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
|
|
3
|
+
module Skriptorium
|
|
4
|
+
module Blogger
|
|
5
|
+
class Destination < BaseDestination
|
|
6
|
+
API_BASE = 'https://www.googleapis.com/blogger/v3'
|
|
7
|
+
TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
|
8
|
+
|
|
9
|
+
def initialize(config)
|
|
10
|
+
super
|
|
11
|
+
@blog_id = config['blog_id']
|
|
12
|
+
@api_key = config['api_key']
|
|
13
|
+
@client_id = config['client_id']
|
|
14
|
+
@client_secret = config['client_secret']
|
|
15
|
+
@refresh_token = config['refresh_token']
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def exists?(article, force: false)
|
|
19
|
+
response = with_cache(:articles, force: force) do
|
|
20
|
+
with_client(url: API_BASE) do |client|
|
|
21
|
+
client.get("blogs/#{@blog_id}/posts", {
|
|
22
|
+
'key' => @api_key,
|
|
23
|
+
'fetchBodies' => 'false',
|
|
24
|
+
'maxResults' => 50
|
|
25
|
+
})
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
return false unless response.success?
|
|
30
|
+
|
|
31
|
+
items = JSON.parse(response.body)['items'] || []
|
|
32
|
+
|
|
33
|
+
articles = items.map(&:with_indifferent_access).map(&self.class.method(:to_article))
|
|
34
|
+
|
|
35
|
+
articles.any? do |a|
|
|
36
|
+
article.same?(a)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def publish(article)
|
|
41
|
+
access_token = refresh_access_token
|
|
42
|
+
html_content = article.to_html
|
|
43
|
+
|
|
44
|
+
client = Faraday.new(url: API_BASE) do |builder|
|
|
45
|
+
builder.headers['Authorization'] = "Bearer #{access_token}"
|
|
46
|
+
builder.headers['Content-Type'] = 'application/json'
|
|
47
|
+
builder.adapter Faraday.default_adapter
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
payload = {
|
|
51
|
+
'kind' => 'blogger#post',
|
|
52
|
+
'blog' => { 'id' => @blog_id },
|
|
53
|
+
'title' => article.title,
|
|
54
|
+
'content' => html_content
|
|
55
|
+
}
|
|
56
|
+
payload['labels'] = article.tags if article.tags&.any?
|
|
57
|
+
|
|
58
|
+
response = client.post("blogs/#{@blog_id}/posts", JSON.generate(payload))
|
|
59
|
+
|
|
60
|
+
raise "Blogger publish error: #{response.status} - #{response.body}" unless response.success?
|
|
61
|
+
|
|
62
|
+
post = JSON.parse(response.body)
|
|
63
|
+
puts " Published to Blogger: #{post['url']}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def refresh_access_token
|
|
69
|
+
response = Faraday.post(TOKEN_URL, {
|
|
70
|
+
'grant_type' => 'refresh_token',
|
|
71
|
+
'refresh_token' => @refresh_token,
|
|
72
|
+
'client_id' => @client_id,
|
|
73
|
+
'client_secret' => @client_secret
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
raise "Blogger OAuth refresh error: #{response.status} - #{response.body}" unless response.success?
|
|
77
|
+
|
|
78
|
+
JSON.parse(response.body)['access_token']
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def with_client(*args, **kwargs, &block)
|
|
82
|
+
Faraday.new(*args, **kwargs) do |builder|
|
|
83
|
+
builder.headers['key'] = @api_key
|
|
84
|
+
builder.adapter Faraday.default_adapter
|
|
85
|
+
end.then(&block)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.to_article(item)
|
|
89
|
+
Article.new(
|
|
90
|
+
id: item['id'].to_s,
|
|
91
|
+
title: item['title'],
|
|
92
|
+
published_at: Article.parse_date(item['published']),
|
|
93
|
+
markdown: false
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Skriptorium
|
|
2
|
+
class Config
|
|
3
|
+
attr_reader :source, :destinations
|
|
4
|
+
|
|
5
|
+
def initialize(source, destinations)
|
|
6
|
+
@source = source
|
|
7
|
+
@destinations = destinations
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.load(path)
|
|
11
|
+
content = File.read(path)
|
|
12
|
+
content = content.gsub(/\$\{(\w+)\}/) { ENV[$1] || raise("Environment variable #{$1} not set") }
|
|
13
|
+
data = YAML.safe_load(content, permitted_classes: [Date, DateTime, Time, Symbol])
|
|
14
|
+
|
|
15
|
+
new(data['source'], data['destinations'])
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
|
|
3
|
+
module Skriptorium
|
|
4
|
+
module DevTo
|
|
5
|
+
class Destination < BaseDestination
|
|
6
|
+
def initialize(config)
|
|
7
|
+
super
|
|
8
|
+
@api_key = config['api_key']
|
|
9
|
+
@organization_id = config['organization_id']
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def exists?(article, force: false)
|
|
13
|
+
response = with_cache(:articles, force: force) do
|
|
14
|
+
with_client(url: 'https://dev.to/api/') do |client|
|
|
15
|
+
client.get('articles/me.json', { 'per_page' => 20 })
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
return false unless response.success?
|
|
20
|
+
|
|
21
|
+
articles = JSON.parse(response.body).map(&:with_indifferent_access).map(&DevTo::Source.method(:to_article))
|
|
22
|
+
|
|
23
|
+
articles.any? do |a|
|
|
24
|
+
article.same?(a)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def publish(article)
|
|
29
|
+
raise 'Article already published!' if exists?(article, force: true)
|
|
30
|
+
|
|
31
|
+
with_client(url: 'https://dev.to/api/') do |client|
|
|
32
|
+
payload = {
|
|
33
|
+
article: {
|
|
34
|
+
title: article.title,
|
|
35
|
+
body_markdown: article.to_markdown(published: true),
|
|
36
|
+
tags: article.tags,
|
|
37
|
+
canonical_url: article.canonical_url,
|
|
38
|
+
slug: article.stable_slug,
|
|
39
|
+
published: true
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
payload[:article][:organization_id] = @organization_id if @organization_id
|
|
44
|
+
|
|
45
|
+
response = client.post('articles.json', JSON.generate(payload))
|
|
46
|
+
|
|
47
|
+
raise "Dev.to publish error: #{response.status} - #{response.body}" unless response.success?
|
|
48
|
+
|
|
49
|
+
puts " Published to Dev.to: #{JSON.parse(response.body)['url']}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def with_client(*args, **kwargs, &block)
|
|
54
|
+
Faraday.new(*args, **kwargs) do |builder|
|
|
55
|
+
builder.headers['api-key'] = @api_key
|
|
56
|
+
builder.headers['Content-Type'] = 'application/json'
|
|
57
|
+
builder.adapter Faraday.default_adapter
|
|
58
|
+
end.then(&block)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
|
|
3
|
+
module Skriptorium
|
|
4
|
+
module DevTo
|
|
5
|
+
class Source < BaseSource
|
|
6
|
+
def initialize(config)
|
|
7
|
+
super
|
|
8
|
+
@username = config['username']
|
|
9
|
+
@api_key = config['api_key']
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def fetch
|
|
13
|
+
items = if @api_key
|
|
14
|
+
fetch_me_articles
|
|
15
|
+
else
|
|
16
|
+
fetch_public_articles
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Enumerator.new do |y|
|
|
20
|
+
items.each do |item|
|
|
21
|
+
y << self.class.to_article(item)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def fetch_me_articles
|
|
29
|
+
client = Faraday.new(url: 'https://dev.to/api/') do |builder|
|
|
30
|
+
builder.headers['api-key'] = @api_key
|
|
31
|
+
builder.adapter Faraday.default_adapter
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
response = client.get('articles/me.json', { 'per_page' => 20 })
|
|
35
|
+
|
|
36
|
+
raise "Dev.to API error: #{response.status} - #{response.body}" unless response.success?
|
|
37
|
+
|
|
38
|
+
JSON.parse(response.body)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fetch_public_articles
|
|
42
|
+
client = Faraday.new(url: 'https://dev.to/api/')
|
|
43
|
+
|
|
44
|
+
list_response = client.get('articles.json', { 'username' => @username, 'per_page' => 20 })
|
|
45
|
+
raise "Dev.to API error: #{list_response.status} - #{list_response.body}" unless list_response.success?
|
|
46
|
+
|
|
47
|
+
list = JSON.parse(list_response.body)
|
|
48
|
+
list.map do |item|
|
|
49
|
+
full_response = client.get("articles/#{item['id']}.json")
|
|
50
|
+
raise "Dev.to API error fetching article #{item['id']}: #{full_response.status}" unless full_response.success?
|
|
51
|
+
|
|
52
|
+
JSON.parse(full_response.body)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.to_article(item)
|
|
57
|
+
Article.new(
|
|
58
|
+
id: item['id'].to_s,
|
|
59
|
+
title: item['title'],
|
|
60
|
+
content: item['body_markdown'],
|
|
61
|
+
tags: item['tags'] || item['tag_list'] || [],
|
|
62
|
+
canonical_url: item['canonical_url'] || item['url'],
|
|
63
|
+
published_at: Article.parse_date(item['published_at'] || item['published_timestamp']),
|
|
64
|
+
cover_image: item['cover_image'],
|
|
65
|
+
slug: item['slug'],
|
|
66
|
+
markdown: true
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
require 'base64'
|
|
3
|
+
|
|
4
|
+
module Skriptorium
|
|
5
|
+
module GitHubPages
|
|
6
|
+
class Destination < BaseDestination
|
|
7
|
+
def initialize(config)
|
|
8
|
+
super
|
|
9
|
+
@repo = config['repo']
|
|
10
|
+
@token = config['token']
|
|
11
|
+
@branch = config['branch'] || 'master'
|
|
12
|
+
@path = config['path'] || '_posts'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def exists?(article)
|
|
16
|
+
filename = generate_filename(article)
|
|
17
|
+
|
|
18
|
+
with_client(url: "https://api.github.com/repos/#{@repo}/contents/#{@path}/#{filename}") do |client|
|
|
19
|
+
client.get('').success?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def publish(article)
|
|
24
|
+
filename = generate_filename(article)
|
|
25
|
+
content = article.to_markdown(layout: 'post', date: article.published_at.strftime('%Y-%m-%d %H:%M:%S %z'))
|
|
26
|
+
|
|
27
|
+
with_client(url: "https://api.github.com/repos/#{@repo}/contents/#{@path}/#{filename}") do |client|
|
|
28
|
+
payload = {
|
|
29
|
+
message: "Add post: #{article.title}",
|
|
30
|
+
content: Base64.strict_encode64(content),
|
|
31
|
+
branch: @branch
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
response = client.put('', JSON.generate(payload))
|
|
35
|
+
|
|
36
|
+
# Если файл уже существует — GitHub требует sha для обновления
|
|
37
|
+
if !response.success? && response.status == 422
|
|
38
|
+
get_resp = client.get('')
|
|
39
|
+
if get_resp.success?
|
|
40
|
+
sha = JSON.parse(get_resp.body)['sha']
|
|
41
|
+
payload[:sha] = sha
|
|
42
|
+
payload[:message] = "Update post: #{article.title}"
|
|
43
|
+
response = client.put('', JSON.generate(payload))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
raise "GitHub publish error: #{response.status} - #{response.body}" unless response.success?
|
|
48
|
+
|
|
49
|
+
puts " Published to GitHub Pages: #{JSON.parse(response.body)['html_url']}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def generate_filename(article)
|
|
56
|
+
"#{article.published_at.strftime('%Y-%m-%d')}-#{article.stable_slug}.md"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def with_client(*args, **kwargs, &block)
|
|
60
|
+
Faraday.new(*args, **kwargs) do |builder|
|
|
61
|
+
builder.headers['Authorization'] = "token #{@token}"
|
|
62
|
+
builder.headers['Content-Type'] = 'application/json'
|
|
63
|
+
builder.headers['Accept'] = 'application/vnd.github.v3+json'
|
|
64
|
+
builder.adapter Faraday.default_adapter
|
|
65
|
+
end.then(&block)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require 'playwright'
|
|
3
|
+
rescue LoadError
|
|
4
|
+
# playwright-ruby-client is an optional dependency
|
|
5
|
+
end
|
|
6
|
+
require 'faraday'
|
|
7
|
+
|
|
8
|
+
module Skriptorium
|
|
9
|
+
module Hashnode
|
|
10
|
+
class Destination < BaseDestination
|
|
11
|
+
HASHNODE_DRAFTS_URL = 'https://hashnode.com/drafts'.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
super
|
|
15
|
+
@storage_state_path = config['storage_state_path']
|
|
16
|
+
@username = config['username']
|
|
17
|
+
@playwright_cli_path = config['playwright_cli_executable_path']
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def exists?(article)
|
|
21
|
+
url = article_url(article)
|
|
22
|
+
puts 1111
|
|
23
|
+
puts url
|
|
24
|
+
response = Faraday.head(url, nil, { 'User-Agent' => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36' })
|
|
25
|
+
puts response.body
|
|
26
|
+
puts response.status
|
|
27
|
+
puts 2222
|
|
28
|
+
response.success? && response.status == 200
|
|
29
|
+
rescue StandardError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def publish(article)
|
|
34
|
+
raise 'Article already published!' if exists?(article)
|
|
35
|
+
|
|
36
|
+
with_page do |page|
|
|
37
|
+
publish_via_browser(page, article)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def article_url(article)
|
|
44
|
+
slug = article.slug || article.stable_slug
|
|
45
|
+
"https://#{@username}.hashnode.dev/#{slug}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def with_page
|
|
49
|
+
Playwright.create(playwright_cli_executable_path: @playwright_cli_path) do |playwright|
|
|
50
|
+
playwright.chromium.launch(headless: true) do |browser|
|
|
51
|
+
context = browser.new_context(storageState: @storage_state_path)
|
|
52
|
+
page = context.new_page
|
|
53
|
+
yield page
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def publish_via_browser(page, article)
|
|
59
|
+
page.goto(HASHNODE_DRAFTS_URL)
|
|
60
|
+
page.wait_for_load_state(state: 'networkidle')
|
|
61
|
+
|
|
62
|
+
click_new(page)
|
|
63
|
+
page.wait_for_url(/\/draft\//, timeout: 15000)
|
|
64
|
+
wait_for_editor(page)
|
|
65
|
+
|
|
66
|
+
fill_title(page, article.title)
|
|
67
|
+
fill_content(page, article.content)
|
|
68
|
+
|
|
69
|
+
click_publish(page)
|
|
70
|
+
|
|
71
|
+
# Configure Discovery settings
|
|
72
|
+
configure_discovery(page, article)
|
|
73
|
+
|
|
74
|
+
puts " Published to Hashnode: #{article_url(article)}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def click_new(page)
|
|
78
|
+
new_btn = page.locator('button:has-text("New")').first
|
|
79
|
+
new_btn.wait_for(state: 'visible', timeout: 10000)
|
|
80
|
+
new_btn.click
|
|
81
|
+
rescue Playwright::TimeoutError
|
|
82
|
+
raise 'Hashnode: Could not find "New" button on drafts page. Check if you are logged in.'
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def wait_for_editor(page)
|
|
86
|
+
page.locator('textarea[placeholder="Article Title..."]').wait_for(state: 'visible', timeout: 15000)
|
|
87
|
+
rescue Playwright::TimeoutError
|
|
88
|
+
raise 'Hashnode: Editor did not load after clicking New. Check your connection.'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def fill_title(page, title)
|
|
92
|
+
title_input = page.locator('textarea[placeholder="Article Title..."]').first
|
|
93
|
+
title_input.wait_for(state: 'visible', timeout: 10000)
|
|
94
|
+
title_input.fill(title)
|
|
95
|
+
rescue Playwright::TimeoutError
|
|
96
|
+
raise 'Hashnode: Could not find title input field. Check if you are logged in and the editor loaded correctly.'
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def fill_content(page, content)
|
|
100
|
+
editor = page.locator('textarea[placeholder="Start writing markdown..."]').first
|
|
101
|
+
editor.wait_for(state: 'visible', timeout: 10000)
|
|
102
|
+
editor.fill(content)
|
|
103
|
+
rescue Playwright::TimeoutError
|
|
104
|
+
raise 'Hashnode: Could not find content editor. Check if the studio page loaded correctly.'
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def click_publish(page)
|
|
108
|
+
# Click toolbar Publish to open Draft settings panel
|
|
109
|
+
toolbar_publish = page.locator('header button:has-text("Publish")').first
|
|
110
|
+
toolbar_publish.wait_for(state: 'visible', timeout: 10000)
|
|
111
|
+
toolbar_publish.click
|
|
112
|
+
|
|
113
|
+
page.wait_for_timeout(2000)
|
|
114
|
+
rescue Playwright::TimeoutError
|
|
115
|
+
raise 'Hashnode: Could not find or click publish button.'
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def configure_discovery(page, article)
|
|
119
|
+
# Click Discovery tab
|
|
120
|
+
discovery_tab = page.locator('button:has-text("Discovery")').first
|
|
121
|
+
discovery_tab.wait_for(state: 'visible', timeout: 10000)
|
|
122
|
+
discovery_tab.click
|
|
123
|
+
page.wait_for_timeout(1500)
|
|
124
|
+
|
|
125
|
+
# Set slug
|
|
126
|
+
fill_slug(page, article.slug || article.stable_slug)
|
|
127
|
+
|
|
128
|
+
# Fill tags
|
|
129
|
+
fill_tags_in_panel(page, article.tags) if article.tags&.any?
|
|
130
|
+
|
|
131
|
+
# Click final Publish in panel
|
|
132
|
+
panel_publish = page.locator('button:has-text("Publish")').last
|
|
133
|
+
panel_publish.wait_for(state: 'visible', timeout: 10000)
|
|
134
|
+
panel_publish.click
|
|
135
|
+
|
|
136
|
+
# Wait for post-publish navigation
|
|
137
|
+
page.wait_for_timeout(5000)
|
|
138
|
+
rescue Playwright::TimeoutError
|
|
139
|
+
raise 'Hashnode: Could not complete Discovery/Publish flow.'
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def fill_slug(page, slug)
|
|
143
|
+
# Click Edit next to slug
|
|
144
|
+
edit_btn = page.locator('button:has-text("Edit")').first
|
|
145
|
+
edit_btn.wait_for(state: 'visible', timeout: 5000)
|
|
146
|
+
edit_btn.click
|
|
147
|
+
page.wait_for_timeout(1000)
|
|
148
|
+
|
|
149
|
+
# Find slug input via JavaScript (CSS selectors don't match default type="text")
|
|
150
|
+
page.evaluate(<<~JS)
|
|
151
|
+
(function() {
|
|
152
|
+
var inputs = Array.from(document.querySelectorAll('input'));
|
|
153
|
+
var slugInput = inputs.find(function(i) {
|
|
154
|
+
return i.type === 'text' && !i.placeholder && i.offsetParent !== null;
|
|
155
|
+
});
|
|
156
|
+
if (slugInput) {
|
|
157
|
+
slugInput.setAttribute('data-testid', 'slug-input');
|
|
158
|
+
}
|
|
159
|
+
})()
|
|
160
|
+
JS
|
|
161
|
+
|
|
162
|
+
slug_input = page.locator('[data-testid="slug-input"]').first
|
|
163
|
+
slug_input.wait_for(state: 'visible', timeout: 5000)
|
|
164
|
+
slug_input.fill(slug)
|
|
165
|
+
|
|
166
|
+
# Click Save
|
|
167
|
+
save_btn = page.locator('button:has-text("Save")').first
|
|
168
|
+
save_btn.click
|
|
169
|
+
page.wait_for_timeout(1000)
|
|
170
|
+
rescue Playwright::TimeoutError
|
|
171
|
+
# Slug is optional, ignore errors
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def fill_tags_in_panel(page, tags)
|
|
175
|
+
tags_input = page.locator('input[placeholder="Type to add tags"]').first
|
|
176
|
+
return unless tags_input.visible?
|
|
177
|
+
|
|
178
|
+
tags.each do |tag|
|
|
179
|
+
tags_input.fill(tag)
|
|
180
|
+
tags_input.press('Enter')
|
|
181
|
+
page.wait_for_timeout(500)
|
|
182
|
+
end
|
|
183
|
+
rescue Playwright::TimeoutError
|
|
184
|
+
# Tags are optional, ignore errors
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
|
|
3
|
+
module Skriptorium
|
|
4
|
+
module Local
|
|
5
|
+
class Destination < BaseDestination
|
|
6
|
+
def initialize(config)
|
|
7
|
+
super
|
|
8
|
+
@path = config['path']
|
|
9
|
+
@format = config.fetch('format', 'html')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def exists?(article)
|
|
13
|
+
File.exist?(filepath(article))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def publish(article)
|
|
17
|
+
FileUtils.mkdir_p(@path)
|
|
18
|
+
|
|
19
|
+
if @format == 'html'
|
|
20
|
+
File.write(filepath(article), article.to_html)
|
|
21
|
+
else
|
|
22
|
+
File.write(filepath(article), article.to_markdown)
|
|
23
|
+
end
|
|
24
|
+
puts " Saved to Local: #{filepath(article)}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def filepath(article)
|
|
30
|
+
File.join(@path, "#{article.stable_slug}.#{@format}")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
require 'reverse_markdown'
|
|
3
|
+
|
|
4
|
+
module Skriptorium
|
|
5
|
+
module RSS
|
|
6
|
+
class Source < BaseSource
|
|
7
|
+
def initialize(config)
|
|
8
|
+
super
|
|
9
|
+
@url = config['url']
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def fetch
|
|
13
|
+
client = Faraday.new
|
|
14
|
+
response = client.get(@url)
|
|
15
|
+
raise "RSS fetch error: #{response.status} - #{response.body}" unless response.success?
|
|
16
|
+
|
|
17
|
+
feed = ::RSS::Parser.parse(response.body)
|
|
18
|
+
|
|
19
|
+
Enumerator.new do |y|
|
|
20
|
+
feed.items.each do |item|
|
|
21
|
+
y << self.class.to_article(item)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.to_article(item)
|
|
27
|
+
content = if item.respond_to?(:content_encoded) && item.content_encoded
|
|
28
|
+
item.content_encoded
|
|
29
|
+
elsif item.respond_to?(:description) && item.description
|
|
30
|
+
item.description
|
|
31
|
+
else
|
|
32
|
+
''
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Article.new(
|
|
36
|
+
id: item.guid&.content || item.link,
|
|
37
|
+
title: item.title,
|
|
38
|
+
content: content,
|
|
39
|
+
tags: [],
|
|
40
|
+
canonical_url: item.link,
|
|
41
|
+
published_at: Article.parse_date(item.pubDate || item.dc_date),
|
|
42
|
+
cover_image: nil,
|
|
43
|
+
slug: nil,
|
|
44
|
+
markdown: false,
|
|
45
|
+
html: true
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/skriptorium.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
require 'date'
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'reverse_markdown'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'base64'
|
|
7
|
+
require 'rss'
|
|
8
|
+
|
|
9
|
+
require_relative 'skriptorium/version'
|
|
10
|
+
require_relative 'skriptorium/config'
|
|
11
|
+
require_relative 'skriptorium/article'
|
|
12
|
+
require_relative 'skriptorium/base_source'
|
|
13
|
+
require_relative 'skriptorium/base_destination'
|
|
14
|
+
require_relative 'skriptorium/dev_to/source'
|
|
15
|
+
require_relative 'skriptorium/dev_to/destination'
|
|
16
|
+
require_relative 'skriptorium/rss/source'
|
|
17
|
+
require_relative 'skriptorium/github_pages/destination'
|
|
18
|
+
require_relative 'skriptorium/blogger/destination'
|
|
19
|
+
require_relative 'skriptorium/local/destination'
|
|
20
|
+
require_relative 'skriptorium/hashnode/destination'
|
|
21
|
+
|
|
22
|
+
module Skriptorium
|
|
23
|
+
class Error < StandardError; end
|
|
24
|
+
|
|
25
|
+
def self.run(config_path, dry_run: false, verbose: false)
|
|
26
|
+
config = Config.load(config_path)
|
|
27
|
+
|
|
28
|
+
source = build_source(config.source)
|
|
29
|
+
destinations = config.destinations.map { |d| build_destination(d) }.compact
|
|
30
|
+
|
|
31
|
+
articles = source.fetch_latest
|
|
32
|
+
|
|
33
|
+
if verbose
|
|
34
|
+
puts "Found #{articles.length} article(s) from source:"
|
|
35
|
+
articles.each do |article|
|
|
36
|
+
puts " => #{article.title}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
destinations.each do |destination|
|
|
41
|
+
puts "[SCAN] #{destination.class.name}"
|
|
42
|
+
|
|
43
|
+
to_publish = articles.take_while do |a|
|
|
44
|
+
if !destination.exists?(a)
|
|
45
|
+
puts " [TAKE] Article '#{a.title}'"
|
|
46
|
+
true
|
|
47
|
+
else
|
|
48
|
+
puts " [SKIP] Article '#{a.title}' already exists in #{destination.class.name}"
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
to_publish.sort_by(&:published_at).each do |article|
|
|
54
|
+
puts " [PUBLISH] Publishing '#{article.title}' to #{destination.class.name}"
|
|
55
|
+
if dry_run
|
|
56
|
+
puts ' (dry-run mode, not actually publishing)'
|
|
57
|
+
else
|
|
58
|
+
destination.publish(article)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.build_source(source_config)
|
|
65
|
+
case source_config['type']
|
|
66
|
+
when 'dev_to' then DevTo::Source.new(source_config)
|
|
67
|
+
when 'rss' then RSS::Source.new(source_config)
|
|
68
|
+
else raise Error, "Unknown source type: #{source_config['type']}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.build_destination(dest_config)
|
|
73
|
+
case dest_config['type']
|
|
74
|
+
when 'dev_to' then DevTo::Destination.new(dest_config)
|
|
75
|
+
when 'hashnode' then Hashnode::Destination.new(dest_config)
|
|
76
|
+
when 'github_pages' then GitHubPages::Destination.new(dest_config)
|
|
77
|
+
when 'blogger' then Blogger::Destination.new(dest_config)
|
|
78
|
+
when 'local' then Local::Destination.new(dest_config)
|
|
79
|
+
#else raise Error, "Unknown destination type: #{dest_config['type']}"
|
|
80
|
+
else nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|