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.
@@ -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
@@ -0,0 +1,3 @@
1
+ module Skriptorium
2
+ VERSION = '0.1.1'
3
+ end
@@ -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