medium_to_webflow 0.1.0
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/LICENSE.txt +21 -0
- data/README.md +132 -0
- data/bin/console +11 -0
- data/bin/medium_to_webflow +13 -0
- data/bin/setup +8 -0
- data/lib/medium_to_webflow/cli.rb +124 -0
- data/lib/medium_to_webflow/config.rb +62 -0
- data/lib/medium_to_webflow/errors.rb +6 -0
- data/lib/medium_to_webflow/medium/client.rb +33 -0
- data/lib/medium_to_webflow/medium/post.rb +67 -0
- data/lib/medium_to_webflow/sync_service.rb +53 -0
- data/lib/medium_to_webflow/version.rb +5 -0
- data/lib/medium_to_webflow/webflow/client.rb +99 -0
- data/lib/medium_to_webflow.rb +46 -0
- metadata +120 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: d3abc7bf7338f6c7c0805da0e39734887dec08d43278091001bc3658b48b5186
|
4
|
+
data.tar.gz: e2f5a6ce102f3aed88ff2715ea616ffc9c54d4f980c2e5dd2de19fad6726ee99
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: b7694736f114a369626a92f03d6251f89eea3661fb320f57ea63ca565e13c67de913c37ba2df02fa8f3e0eb927e3630b4da3d48b8cff9916b9dd8af9956f5f23
|
7
|
+
data.tar.gz: '087aa51ca02af37dbaa4b2c39d89cd4cc13ba6cf420022e8259fabdfeea4063455b9f5419c7157d7f6a34a102e133c014c36540f92f5bf9cb2f315e5913e3a86'
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Paulo Santos
|
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
|
13
|
+
all 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
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
# Medium to Webflow
|
2
|
+
|
3
|
+
Sync your Medium posts to a Webflow CMS collection.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'medium_to_webflow'
|
11
|
+
```
|
12
|
+
|
13
|
+
Or install it yourself as:
|
14
|
+
|
15
|
+
```bash
|
16
|
+
$ gem install medium_to_webflow
|
17
|
+
```
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
There are two ways to configure and use the gem:
|
22
|
+
|
23
|
+
### 1. Using a Configuration File (Recommended)
|
24
|
+
|
25
|
+
First, generate a sample configuration file:
|
26
|
+
|
27
|
+
```bash
|
28
|
+
$ medium_to_webflow init
|
29
|
+
# Creates config/medium_to_webflow.rb with environment variables support
|
30
|
+
|
31
|
+
# Or specify a custom path
|
32
|
+
$ medium_to_webflow init --path=./config.rb
|
33
|
+
```
|
34
|
+
|
35
|
+
The configuration file will look like this:
|
36
|
+
|
37
|
+
```ruby
|
38
|
+
MediumToWebflow.configure do |config|
|
39
|
+
# Required settings
|
40
|
+
config.medium_username = ENV.fetch("MEDIUM_USERNAME", nil)
|
41
|
+
config.webflow_api_token = ENV.fetch("WEBFLOW_API_TOKEN", nil)
|
42
|
+
config.webflow_collection_id = ENV.fetch("WEBFLOW_COLLECTION_ID", nil)
|
43
|
+
|
44
|
+
# Field mappings from Medium post attributes to Webflow field names
|
45
|
+
config.field_mappings = {
|
46
|
+
# Required mappings
|
47
|
+
title: "name", # Maps to Webflow's name field
|
48
|
+
guid: "slug", # Maps to Webflow's slug field
|
49
|
+
|
50
|
+
# Optional mappings (customize based on your collection)
|
51
|
+
url: "source-url", # Maps to a custom field
|
52
|
+
published_at: "date", # Maps to a date field
|
53
|
+
author: "author", # Maps to an author field
|
54
|
+
image_url: "image", # Maps to an image field (converted to { url: value })
|
55
|
+
category: "category" # Maps to a category field
|
56
|
+
}
|
57
|
+
end
|
58
|
+
```
|
59
|
+
|
60
|
+
Then run the sync:
|
61
|
+
|
62
|
+
```bash
|
63
|
+
$ medium_to_webflow sync -c config/medium_to_webflow.rb
|
64
|
+
```
|
65
|
+
|
66
|
+
### 2. Using Command Line Options
|
67
|
+
|
68
|
+
You can also run the sync directly with command line options:
|
69
|
+
|
70
|
+
```bash
|
71
|
+
$ medium_to_webflow sync \
|
72
|
+
--medium-username=your-username \
|
73
|
+
--webflow-api-token=your-token \
|
74
|
+
--webflow-collection-id=your-collection-id \
|
75
|
+
--field-mappings=title:name,guid:slug,url:medium-url
|
76
|
+
```
|
77
|
+
|
78
|
+
### Available Options
|
79
|
+
|
80
|
+
```bash
|
81
|
+
$ medium_to_webflow sync [options]
|
82
|
+
-u, --medium-username=USERNAME Medium username (without the @ symbol)
|
83
|
+
-t, --webflow-api-token=TOKEN Webflow API token with CMS permissions
|
84
|
+
-l, --webflow-collection-id=ID The ID of the Webflow collection where posts will be imported
|
85
|
+
-m, --field-mappings=MAPPINGS Map Medium post fields to Webflow collection fields
|
86
|
+
-v, --verbose Enable verbose logging
|
87
|
+
-f, --force-update Force update existing posts (default: false)
|
88
|
+
-c, --config=PATH Path to a Ruby config file
|
89
|
+
-h, --help Show this help message
|
90
|
+
```
|
91
|
+
|
92
|
+
#### Logging Options
|
93
|
+
|
94
|
+
The verbose flag (`-v`) will output detailed debugging information during the sync process, which can be helpful for troubleshooting issues.
|
95
|
+
|
96
|
+
#### Update Behavior
|
97
|
+
|
98
|
+
The `--force-update` flag controls how existing posts are handled:
|
99
|
+
|
100
|
+
- When set to false (default), the sync will skip posts that already exist in Webflow
|
101
|
+
- When true, existing posts will be updated with the latest content from Medium
|
102
|
+
|
103
|
+
### Available Medium Post Attributes
|
104
|
+
|
105
|
+
When configuring field mappings, you can use any of these Medium post attributes:
|
106
|
+
|
107
|
+
- `title`: The post title
|
108
|
+
- `url`: The Medium post URL
|
109
|
+
- `published_at`: Publication date
|
110
|
+
- `author`: Post author
|
111
|
+
- `image_url`: Featured image URL
|
112
|
+
- `category`: Post category
|
113
|
+
- `guid`: The post's unique identifier
|
114
|
+
|
115
|
+
### Required Webflow Fields
|
116
|
+
|
117
|
+
Your field mappings must include these Webflow fields:
|
118
|
+
|
119
|
+
- `name`: The item name in Webflow
|
120
|
+
- `slug`: The URL slug in Webflow
|
121
|
+
|
122
|
+
## Development
|
123
|
+
|
124
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
|
125
|
+
|
126
|
+
## Contributing
|
127
|
+
|
128
|
+
Bug reports and pull requests are welcome on GitHub. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).
|
129
|
+
|
130
|
+
## License
|
131
|
+
|
132
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
data/bin/console
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "medium_to_webflow"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
require "irb"
|
11
|
+
IRB.start(__FILE__)
|
@@ -0,0 +1,13 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
require "medium_to_webflow"
|
6
|
+
|
7
|
+
begin
|
8
|
+
MediumToWebflow::CLI.start(ARGV)
|
9
|
+
rescue StandardError => e
|
10
|
+
puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m"
|
11
|
+
puts e.backtrace if ENV["VERBOSE"]
|
12
|
+
exit 1
|
13
|
+
end
|
data/bin/setup
ADDED
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MediumToWebflow
|
4
|
+
class CLI < Thor
|
5
|
+
class_option :config,
|
6
|
+
type: :string,
|
7
|
+
desc: "Path to a Ruby config file that sets up Medium and Webflow credentials",
|
8
|
+
aliases: "-c"
|
9
|
+
|
10
|
+
desc "sync", "Sync Medium posts to Webflow"
|
11
|
+
method_option :medium_username,
|
12
|
+
type: :string,
|
13
|
+
aliases: "-u",
|
14
|
+
desc: "Medium username (without the @ symbol)"
|
15
|
+
method_option :webflow_api_token,
|
16
|
+
type: :string,
|
17
|
+
aliases: "-t",
|
18
|
+
desc: "Webflow API token with CMS permissions"
|
19
|
+
method_option :webflow_collection_id,
|
20
|
+
type: :string,
|
21
|
+
aliases: "-l",
|
22
|
+
desc: "The ID of the Webflow collection where posts will be imported"
|
23
|
+
method_option :field_mappings,
|
24
|
+
type: :hash,
|
25
|
+
aliases: "-m",
|
26
|
+
desc: "Map Medium post fields to Webflow collection fields (e.g. title:name content:post-content)"
|
27
|
+
method_option :verbose,
|
28
|
+
type: :boolean,
|
29
|
+
aliases: "-v",
|
30
|
+
desc: "Enable verbose logging"
|
31
|
+
method_option :force_update,
|
32
|
+
type: :boolean,
|
33
|
+
aliases: "-f",
|
34
|
+
desc: "Force update existing posts (default: false)"
|
35
|
+
|
36
|
+
def sync
|
37
|
+
load_config if options[:config]
|
38
|
+
|
39
|
+
MediumToWebflow.configure do |config|
|
40
|
+
config.verbose = options[:verbose]
|
41
|
+
config.force_update = options[:force_update]
|
42
|
+
end
|
43
|
+
|
44
|
+
MediumToWebflow.sync(options)
|
45
|
+
rescue Error => e
|
46
|
+
error "Failed to sync: #{e.message}"
|
47
|
+
exit 1
|
48
|
+
end
|
49
|
+
|
50
|
+
desc "init", "Generate a sample config file"
|
51
|
+
method_option :path,
|
52
|
+
type: :string,
|
53
|
+
default: "config/medium_to_webflow.rb",
|
54
|
+
desc: "Path to generate the config file",
|
55
|
+
aliases: "-p"
|
56
|
+
def init
|
57
|
+
create_file(options[:path], config_template)
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def load_config
|
63
|
+
require File.expand_path(options[:config])
|
64
|
+
rescue LoadError => e
|
65
|
+
error "Could not load config file: #{e.message}"
|
66
|
+
exit 1
|
67
|
+
end
|
68
|
+
|
69
|
+
def create_file(path, content)
|
70
|
+
if File.exist?(path)
|
71
|
+
error "File already exists: #{path}"
|
72
|
+
exit 1
|
73
|
+
end
|
74
|
+
|
75
|
+
FileUtils.mkdir_p(File.dirname(path))
|
76
|
+
File.write(path, content)
|
77
|
+
|
78
|
+
say "Created config file at #{path}", :green
|
79
|
+
end
|
80
|
+
|
81
|
+
def error(message)
|
82
|
+
say "Error: #{message}", :red
|
83
|
+
end
|
84
|
+
|
85
|
+
def config_template
|
86
|
+
<<~RUBY
|
87
|
+
# frozen_string_literal: true
|
88
|
+
|
89
|
+
MediumToWebflow.configure do |config|
|
90
|
+
# Required settings
|
91
|
+
config.medium_username = ENV.fetch("MEDIUM_USERNAME", nil)
|
92
|
+
config.webflow_api_token = ENV.fetch("WEBFLOW_API_TOKEN", nil)
|
93
|
+
config.webflow_collection_id = ENV.fetch("WEBFLOW_COLLECTION_ID", nil)
|
94
|
+
|
95
|
+
# Field mappings from Medium post attributes to Webflow field names
|
96
|
+
# Available Medium post attributes:
|
97
|
+
# - title: The post title
|
98
|
+
# - url: The Medium post URL
|
99
|
+
# - published_at: Publication date
|
100
|
+
# - author: Post author
|
101
|
+
# - image_url: Featured image URL
|
102
|
+
# - category: Post category
|
103
|
+
# - guid: The post's unique identifier from Medium
|
104
|
+
#
|
105
|
+
# Required Webflow fields:
|
106
|
+
# - name: The item name in Webflow
|
107
|
+
# - slug: The URL slug in Webflow
|
108
|
+
#
|
109
|
+
# Example mapping (adjust according to your Webflow collection structure):
|
110
|
+
config.field_mappings = {
|
111
|
+
# Map Medium attributes to your Webflow collection fields
|
112
|
+
title: "name", # Required: maps to Webflow's name field
|
113
|
+
guid: "slug", # Required: maps to Webflow's slug field
|
114
|
+
url: "source-url", # Optional: maps to a custom field in your collection
|
115
|
+
published_at: "date", # Optional: maps to a date field
|
116
|
+
author: "author", # Optional: maps to an author field
|
117
|
+
image_url: "image", # Optional: maps to an image field (will be converted to { url: value })
|
118
|
+
category: "category" # Optional: maps to a category field
|
119
|
+
}
|
120
|
+
end
|
121
|
+
RUBY
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "logger"
|
4
|
+
|
5
|
+
module MediumToWebflow
|
6
|
+
class Config
|
7
|
+
REQUIRED_WEBFLOW_FIELDS = %w[name slug].freeze
|
8
|
+
REQUIRED_SETTINGS = %i[medium_username webflow_api_token webflow_collection_id].freeze
|
9
|
+
|
10
|
+
attr_accessor :medium_username, :webflow_api_token, :webflow_collection_id, :field_mappings,
|
11
|
+
:logger, :force_update
|
12
|
+
attr_reader :verbose
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@logger = Logger.new($stdout)
|
16
|
+
@logger.level = Logger::INFO
|
17
|
+
@verbose = false
|
18
|
+
@force_update = false
|
19
|
+
end
|
20
|
+
|
21
|
+
def verbose=(value)
|
22
|
+
@verbose = value
|
23
|
+
@logger.level = value ? Logger::DEBUG : Logger::INFO
|
24
|
+
end
|
25
|
+
|
26
|
+
def validate!
|
27
|
+
validate_required_settings!
|
28
|
+
validate_required_field_mappings!
|
29
|
+
end
|
30
|
+
|
31
|
+
def to_h
|
32
|
+
{
|
33
|
+
medium_username: medium_username,
|
34
|
+
webflow_api_token: webflow_api_token,
|
35
|
+
webflow_collection_id: webflow_collection_id,
|
36
|
+
field_mappings: field_mappings
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def validate_required_settings!
|
43
|
+
missing_settings = REQUIRED_SETTINGS.select { |setting| send(setting).nil? }
|
44
|
+
return if missing_settings.empty?
|
45
|
+
|
46
|
+
raise ConfigError, "Missing required configuration: #{missing_settings.join(", ")}"
|
47
|
+
end
|
48
|
+
|
49
|
+
def validate_required_field_mappings!
|
50
|
+
unless field_mappings.is_a?(Hash)
|
51
|
+
raise ConfigError, "field_mappings must be a Hash mapping Medium attributes to Webflow fields"
|
52
|
+
end
|
53
|
+
|
54
|
+
missing_fields = REQUIRED_WEBFLOW_FIELDS.reject { |field| field_mappings.values.include?(field) }
|
55
|
+
return if missing_fields.empty?
|
56
|
+
|
57
|
+
raise ConfigError,
|
58
|
+
"Required Webflow fields must be mapped to: #{missing_fields.join(", ")}. " \
|
59
|
+
"Ensure you map Medium attributes to these required Webflow fields."
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "httparty"
|
4
|
+
require "rss"
|
5
|
+
|
6
|
+
module MediumToWebflow
|
7
|
+
module Medium
|
8
|
+
class Client
|
9
|
+
include HTTParty
|
10
|
+
base_uri "https://medium.com/feed"
|
11
|
+
|
12
|
+
def initialize(username:)
|
13
|
+
@username = username
|
14
|
+
end
|
15
|
+
|
16
|
+
def fetch_posts
|
17
|
+
response = self.class.get("/#{@username}")
|
18
|
+
raise Error, "Failed to fetch Medium posts: #{response.code}" unless response.success?
|
19
|
+
|
20
|
+
parse_feed(response.body)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def parse_feed(xml)
|
26
|
+
feed = RSS::Parser.parse(xml)
|
27
|
+
feed.items.map { |item| Post.from_rss(item) }
|
28
|
+
rescue RSS::Error => e
|
29
|
+
raise Error, "Failed to parse Medium RSS feed: #{e.message}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "nokogiri"
|
4
|
+
|
5
|
+
module MediumToWebflow
|
6
|
+
module Medium
|
7
|
+
class Post
|
8
|
+
attr_reader :title, :url, :published_at, :author, :image_url, :category, :guid
|
9
|
+
|
10
|
+
def initialize(attributes)
|
11
|
+
@title = attributes[:title]
|
12
|
+
@url = attributes[:url]
|
13
|
+
@published_at = attributes[:published_at]
|
14
|
+
@author = attributes[:author]
|
15
|
+
@image_url = attributes[:image_url]
|
16
|
+
@category = attributes[:category]
|
17
|
+
@guid = attributes[:guid]
|
18
|
+
end
|
19
|
+
|
20
|
+
class << self
|
21
|
+
def from_rss(item)
|
22
|
+
new(
|
23
|
+
title: item.title,
|
24
|
+
url: item.link,
|
25
|
+
published_at: item.pubDate,
|
26
|
+
author: item.author || item.dc_creator,
|
27
|
+
image_url: extract_image_url(item.content_encoded, item.description),
|
28
|
+
category: humanize_category(item.categories&.first&.content),
|
29
|
+
guid: extract_guid(item.guid.content)
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def extract_image_url(content_encoded, description)
|
36
|
+
# Try to get image from content_encoded first
|
37
|
+
if content_encoded
|
38
|
+
doc = Nokogiri::HTML(content_encoded)
|
39
|
+
img = doc.at_css("img")
|
40
|
+
return img["src"] if img && img["src"]
|
41
|
+
end
|
42
|
+
|
43
|
+
# Fallback to description
|
44
|
+
return nil if description.nil?
|
45
|
+
|
46
|
+
doc = Nokogiri::HTML(description)
|
47
|
+
img = doc.at_css("img")
|
48
|
+
img["src"] if img && img["src"]
|
49
|
+
end
|
50
|
+
|
51
|
+
def extract_guid(guid_content)
|
52
|
+
guid_content.split("/").last.downcase
|
53
|
+
end
|
54
|
+
|
55
|
+
def humanize_category(category)
|
56
|
+
return if category.nil?
|
57
|
+
|
58
|
+
category
|
59
|
+
.tr("-", " ")
|
60
|
+
.split
|
61
|
+
.map(&:capitalize)
|
62
|
+
.join(" ")
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MediumToWebflow
|
4
|
+
class SyncService
|
5
|
+
def self.call(**args)
|
6
|
+
new(**args).call
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize(medium_username:, webflow_api_token:, webflow_collection_id:, field_mappings:)
|
10
|
+
@medium_username = medium_username
|
11
|
+
@webflow_api_token = webflow_api_token
|
12
|
+
@webflow_collection_id = webflow_collection_id
|
13
|
+
@field_mappings = field_mappings
|
14
|
+
@logger = MediumToWebflow.configuration.logger
|
15
|
+
end
|
16
|
+
|
17
|
+
def call
|
18
|
+
@logger.info "Starting Medium to Webflow sync..."
|
19
|
+
@logger.debug "Fetching posts from Medium..."
|
20
|
+
|
21
|
+
medium_posts = fetch_medium_posts
|
22
|
+
@logger.info "Found #{medium_posts.count} posts to sync"
|
23
|
+
|
24
|
+
sync_to_webflow(medium_posts)
|
25
|
+
|
26
|
+
@logger.info "Sync completed successfully!"
|
27
|
+
rescue StandardError => e
|
28
|
+
@logger.error "Sync failed: #{e.message}"
|
29
|
+
@logger.debug e.backtrace.join("\n") if MediumToWebflow.configuration.verbose
|
30
|
+
raise
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def fetch_medium_posts
|
36
|
+
Medium::Client.new(username: @medium_username).fetch_posts
|
37
|
+
end
|
38
|
+
|
39
|
+
def sync_to_webflow(posts)
|
40
|
+
webflow_client = Webflow::Client.new(
|
41
|
+
api_token: @webflow_api_token,
|
42
|
+
collection_id: @webflow_collection_id,
|
43
|
+
field_mappings: @field_mappings
|
44
|
+
)
|
45
|
+
|
46
|
+
posts.each_with_index do |post, index|
|
47
|
+
@logger.debug "Processing post: #{post.title}"
|
48
|
+
webflow_client.upsert_post(post)
|
49
|
+
@logger.info "Successfully synced: #{post.title} (#{index + 1}/#{posts.count})"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MediumToWebflow
|
4
|
+
module Webflow
|
5
|
+
class Client
|
6
|
+
include HTTParty
|
7
|
+
base_uri "https://api.webflow.com/v2"
|
8
|
+
headers "Accept" => "application/json"
|
9
|
+
headers "Content-Type" => "application/json"
|
10
|
+
|
11
|
+
def initialize(api_token:, collection_id:, field_mappings:)
|
12
|
+
@api_token = api_token
|
13
|
+
@collection_id = collection_id
|
14
|
+
@field_mappings = field_mappings
|
15
|
+
self.class.headers "Authorization" => "Bearer #{api_token}"
|
16
|
+
@logger = MediumToWebflow.configuration.logger
|
17
|
+
end
|
18
|
+
|
19
|
+
def upsert_post(medium_post)
|
20
|
+
fields = build_fields(medium_post)
|
21
|
+
medium_slug_field = @field_mappings.key("slug")
|
22
|
+
existing_item = find_item(slug: medium_post.send(medium_slug_field))
|
23
|
+
|
24
|
+
handle_existing_or_create_item(existing_item, fields, medium_post)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def handle_existing_or_create_item(existing_item, fields, medium_post)
|
30
|
+
if existing_item
|
31
|
+
handle_existing_item(existing_item, fields, medium_post)
|
32
|
+
else
|
33
|
+
create_item(fields: fields)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def handle_existing_item(existing_item, fields, medium_post)
|
38
|
+
if MediumToWebflow.configuration.force_update
|
39
|
+
@logger.debug "Forcing update of existing item: #{existing_item["id"]}"
|
40
|
+
update_item(item_id: existing_item["id"], fields: fields)
|
41
|
+
else
|
42
|
+
@logger.info "Skipping existing item: #{medium_post.title} (use --force-update to override)"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def find_item(slug:)
|
47
|
+
response = self.class.get("/collections/#{@collection_id}/items/live", query: { slug: slug })
|
48
|
+
|
49
|
+
handle_response(response)["items"]&.first
|
50
|
+
end
|
51
|
+
|
52
|
+
def create_item(fields:)
|
53
|
+
@logger.debug "Creating Webflow item in collection: #{@collection_id}"
|
54
|
+
@logger.debug "Fields: #{fields.inspect}" if MediumToWebflow.configuration.verbose
|
55
|
+
|
56
|
+
response = self.class.post("/collections/#{@collection_id}/items/live", body: {
|
57
|
+
fieldData: fields
|
58
|
+
}.to_json)
|
59
|
+
handle_response(response)
|
60
|
+
end
|
61
|
+
|
62
|
+
def update_item(item_id:, fields:)
|
63
|
+
@logger.debug "Updating Webflow item: #{item_id} in collection: #{@collection_id}"
|
64
|
+
@logger.debug "Fields: #{fields.inspect}" if MediumToWebflow.configuration.verbose
|
65
|
+
|
66
|
+
response = self.class.patch("/collections/#{@collection_id}/items/#{item_id}/live", body: {
|
67
|
+
fieldData: fields
|
68
|
+
}.to_json)
|
69
|
+
handle_response(response)
|
70
|
+
end
|
71
|
+
|
72
|
+
def build_fields(medium_post)
|
73
|
+
@field_mappings.each_with_object({}) do |(medium_field, webflow_field), fields|
|
74
|
+
value = medium_post.public_send(medium_field)
|
75
|
+
next if value.nil?
|
76
|
+
|
77
|
+
fields[webflow_field] = process_field_value(medium_field, value)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def process_field_value(field, value)
|
82
|
+
# Handle the image field by converting it to Webflow's expected format { url: "image_url" }
|
83
|
+
return { url: value } if field == :image_url
|
84
|
+
|
85
|
+
# Convert DateTime/Time objects to ISO8601 format for Webflow's date fields
|
86
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
87
|
+
|
88
|
+
# Return value as-is for all other field types
|
89
|
+
value
|
90
|
+
end
|
91
|
+
|
92
|
+
def handle_response(response)
|
93
|
+
return response.parsed_response if response.success?
|
94
|
+
|
95
|
+
raise Error, "Webflow API error: #{response.code} - #{response.body}"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thor"
|
4
|
+
require "httparty"
|
5
|
+
|
6
|
+
require_relative "medium_to_webflow/version"
|
7
|
+
require_relative "medium_to_webflow/config"
|
8
|
+
require_relative "medium_to_webflow/errors"
|
9
|
+
|
10
|
+
require_relative "medium_to_webflow/medium/client"
|
11
|
+
require_relative "medium_to_webflow/medium/post"
|
12
|
+
require_relative "medium_to_webflow/webflow/client"
|
13
|
+
require_relative "medium_to_webflow/sync_service"
|
14
|
+
require_relative "medium_to_webflow/cli"
|
15
|
+
|
16
|
+
module MediumToWebflow
|
17
|
+
class << self
|
18
|
+
def sync(config)
|
19
|
+
apply_configuration!(config)
|
20
|
+
|
21
|
+
SyncService.call(**configuration.to_h)
|
22
|
+
end
|
23
|
+
|
24
|
+
def configuration
|
25
|
+
@configuration ||= Config.new
|
26
|
+
end
|
27
|
+
|
28
|
+
def configure
|
29
|
+
yield(configuration)
|
30
|
+
end
|
31
|
+
|
32
|
+
def reset_configuration!
|
33
|
+
@configuration = Config.new
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def apply_configuration!(config)
|
39
|
+
%i[medium_username webflow_api_token webflow_collection_id field_mappings].each do |option|
|
40
|
+
configuration.send("#{option}=", config[option]) if config[option]
|
41
|
+
end
|
42
|
+
|
43
|
+
configuration.validate!
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
metadata
ADDED
@@ -0,0 +1,120 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: medium_to_webflow
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Paulo Santos
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-12-13 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: httparty
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.22.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.22.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: nokogiri
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.17'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.17'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rss
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 0.3.1
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 0.3.1
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: thor
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.3'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.3'
|
69
|
+
description: A library and CLI tool to fetch posts from Medium and sync them to Webflow
|
70
|
+
CMS collections
|
71
|
+
email:
|
72
|
+
- paulo.santos@deemaze.com
|
73
|
+
executables:
|
74
|
+
- medium_to_webflow
|
75
|
+
extensions: []
|
76
|
+
extra_rdoc_files: []
|
77
|
+
files:
|
78
|
+
- LICENSE.txt
|
79
|
+
- README.md
|
80
|
+
- bin/console
|
81
|
+
- bin/medium_to_webflow
|
82
|
+
- bin/setup
|
83
|
+
- lib/medium_to_webflow.rb
|
84
|
+
- lib/medium_to_webflow/cli.rb
|
85
|
+
- lib/medium_to_webflow/config.rb
|
86
|
+
- lib/medium_to_webflow/errors.rb
|
87
|
+
- lib/medium_to_webflow/medium/client.rb
|
88
|
+
- lib/medium_to_webflow/medium/post.rb
|
89
|
+
- lib/medium_to_webflow/sync_service.rb
|
90
|
+
- lib/medium_to_webflow/version.rb
|
91
|
+
- lib/medium_to_webflow/webflow/client.rb
|
92
|
+
homepage: https://github.com/deemaze/medium_to_webflow
|
93
|
+
licenses:
|
94
|
+
- MIT
|
95
|
+
metadata:
|
96
|
+
allowed_push_host: https://rubygems.org
|
97
|
+
homepage_uri: https://github.com/deemaze/medium_to_webflow
|
98
|
+
source_code_uri: https://github.com/deemaze/medium_to_webflow
|
99
|
+
changelog_uri: https://github.com/deemaze/medium_to_webflow/blob/main/CHANGELOG.md
|
100
|
+
rubygems_mfa_required: 'true'
|
101
|
+
post_install_message:
|
102
|
+
rdoc_options: []
|
103
|
+
require_paths:
|
104
|
+
- lib
|
105
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - ">="
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: 3.0.0
|
110
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
111
|
+
requirements:
|
112
|
+
- - ">="
|
113
|
+
- !ruby/object:Gem::Version
|
114
|
+
version: '0'
|
115
|
+
requirements: []
|
116
|
+
rubygems_version: 3.5.22
|
117
|
+
signing_key:
|
118
|
+
specification_version: 4
|
119
|
+
summary: Sync Medium posts to Webflow CMS collections
|
120
|
+
test_files: []
|