pinterest-url-normalizer 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5293ed706c039755df8a8f487ffb434813eaf3a7c26bdd35b18f24b22decb4d9
4
+ data.tar.gz: e058bc0992ee1da9af2470a04fec5b148bf2dbb756f0666fad6db4ff76d64f1d
5
+ SHA512:
6
+ metadata.gz: 03b33feb90157133ee8dc29fa422d1800694f4a24ed2662642f9034150214f8a83f8f97a7b75a4de00b965a2516ff9cc49572528ad7a2ed9310c64b14539d8bd
7
+ data.tar.gz: a593d71cded361ce53a460ea4b7226c757bf4e7899d35b182404b6380e2d0c0274855aed1947c686206397c7614b6ce1cc114f2019b7b7eeda91097a662ec765
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SavePinner
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,62 @@
1
+ # pinterest-url-normalizer
2
+
3
+ Parse, classify, and normalize Pinterest URLs in Ruby without making network requests.
4
+
5
+ [SavePinner](https://savepinner.com/pinterest-downloader/) · [RubyGems](https://rubygems.org/gems/pinterest-url-normalizer) · [RubyDoc.info](https://www.rubydoc.info/gems/pinterest-url-normalizer) · [npm](https://www.npmjs.com/package/pinterest-url-normalizer) · [PyPI](https://pypi.org/project/pinterest-url-normalizer/) · [crates.io](https://crates.io/crates/pinterest-url-normalizer) · [Packagist](https://packagist.org/packages/savepinner/pinterest-url-normalizer) · [Go](https://pkg.go.dev/github.com/jiankn/pinterest-url-normalizer-go)
6
+
7
+ The gem recognizes Pin, `pin.it`, profile, board, and Ideas URLs across Pinterest country domains. It uses an exact host allow list, rejects HTTP URLs and lookalike domains, and removes query parameters and fragments from normalized output.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ gem install pinterest-url-normalizer
13
+ ```
14
+
15
+ ## Library
16
+
17
+ ```ruby
18
+ require "pinterest_url_normalizer"
19
+
20
+ parsed = PinterestURLNormalizer.parse(
21
+ "https://de.pinterest.com/pin/987654321/?utm_source=share"
22
+ )
23
+
24
+ parsed.pin_id
25
+ # => "987654321"
26
+
27
+ parsed.normalized_url
28
+ # => "https://www.pinterest.com/pin/987654321/"
29
+ ```
30
+
31
+ ## CLI
32
+
33
+ ```bash
34
+ pinterest-url-normalizer \
35
+ "https://de.pinterest.com/pin/987654321/?utm_source=share" \
36
+ "https://pin.it/AbC123"
37
+ ```
38
+
39
+ Read one URL per line and emit JSON Lines:
40
+
41
+ ```bash
42
+ printf '%s\n' 'https://www.pinterest.com/pin/123/' | \
43
+ pinterest-url-normalizer --json
44
+ ```
45
+
46
+ The process exits with status `1` when any input is invalid and status `2` for usage errors.
47
+
48
+ ## Supported URLs
49
+
50
+ | Kind | Example |
51
+ | --- | --- |
52
+ | Pin | `https://www.pinterest.com/pin/123456789/` |
53
+ | Short | `https://pin.it/AbC123` |
54
+ | Profile | `https://www.pinterest.com/savepinner/` |
55
+ | Board | `https://www.pinterest.com/savepinner/media-tools/` |
56
+ | Ideas | `https://www.pinterest.com/ideas/space-wallpaper/926295399832/` |
57
+
58
+ `pin.it` links are classified and normalized but are not followed. Resolving them requires a network request and belongs in the consuming application.
59
+
60
+ ## License
61
+
62
+ MIT
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "pinterest_url_normalizer/cli"
5
+
6
+ exit PinterestURLNormalizer::CLI.run(ARGV)
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../pinterest_url_normalizer"
6
+
7
+ module PinterestURLNormalizer
8
+ # Command-line interface for the gem executable.
9
+ module CLI
10
+ module_function
11
+
12
+ def run(arguments, input: $stdin, output: $stdout, error: $stderr)
13
+ json_output = false
14
+ parser = OptionParser.new do |options|
15
+ options.banner = "Usage: pinterest-url-normalizer [--json] [URL ...]"
16
+ options.on("--json", "Emit JSON Lines") { json_output = true }
17
+ end
18
+
19
+ urls = arguments.dup
20
+ begin
21
+ parser.parse!(urls)
22
+ rescue OptionParser::ParseError => exception
23
+ error.puts exception.message
24
+ error.puts parser
25
+ return 2
26
+ end
27
+
28
+ urls = input.each_line.map(&:strip).reject(&:empty?) if urls.empty?
29
+ if urls.empty?
30
+ error.puts parser
31
+ return 2
32
+ end
33
+
34
+ exit_code = 0
35
+ urls.each do |url|
36
+ begin
37
+ parsed = PinterestURLNormalizer.parse(url)
38
+ if json_output
39
+ output.puts JSON.generate(parsed.to_h.compact)
40
+ else
41
+ output.puts parsed.normalized_url
42
+ end
43
+ rescue Error => exception
44
+ exit_code = 1
45
+ if json_output
46
+ output.puts JSON.generate(
47
+ input: url,
48
+ error: { code: exception.code, message: exception.message }
49
+ )
50
+ else
51
+ error.puts "#{exception.code}: #{exception.message}: #{url}"
52
+ end
53
+ end
54
+ end
55
+ exit_code
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PinterestURLNormalizer
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "pinterest_url_normalizer/version"
5
+
6
+ # Parses, classifies, and normalizes supported Pinterest URLs without making
7
+ # network requests.
8
+ #
9
+ # Full Pinterest URLs are normalized to www.pinterest.com. Short pin.it links
10
+ # retain their host because resolving them requires a network request.
11
+ #
12
+ # SavePinner: https://savepinner.com/pinterest-downloader/
13
+ module PinterestURLNormalizer
14
+ CANONICAL_HOST = "www.pinterest.com"
15
+ SHORT_HOST = "pin.it"
16
+ MAX_URL_LENGTH = 2048
17
+
18
+ COUNTRY_HOSTS = %w[
19
+ pinterest.at pinterest.be pinterest.ca pinterest.ch pinterest.cl
20
+ pinterest.co pinterest.co.kr pinterest.co.nz pinterest.co.uk
21
+ pinterest.com.au pinterest.com.br pinterest.com.mx pinterest.com.pe
22
+ pinterest.com.tr pinterest.cz pinterest.de pinterest.dk pinterest.es
23
+ pinterest.fi pinterest.fr pinterest.gr pinterest.hu pinterest.id
24
+ pinterest.ie pinterest.it pinterest.jp pinterest.nl pinterest.no
25
+ pinterest.ph pinterest.pl pinterest.pt pinterest.ro pinterest.se
26
+ pinterest.sk
27
+ ].freeze
28
+
29
+ REGIONAL_SUBDOMAINS = %w[
30
+ at au be br ca ch cl co cz de dk es fi fr gr hu id ie it jp kr mx nl
31
+ no nz pe ph pl pt ro se sk tr uk
32
+ ].freeze
33
+
34
+ RESERVED_FIRST_SEGMENTS = %w[
35
+ business categories explore help ideas login logout oauth pin pin-builder
36
+ resource search settings signup today topics
37
+ ].freeze
38
+
39
+ # A supported Pinterest URL and its canonical fields.
40
+ ParsedURL = Struct.new(
41
+ :kind,
42
+ :original_url,
43
+ :normalized_url,
44
+ :host,
45
+ :pin_id,
46
+ :shortcode,
47
+ :username,
48
+ :board_slug,
49
+ :idea_slug,
50
+ :idea_id,
51
+ keyword_init: true
52
+ )
53
+
54
+ # An invalid URL or unsupported Pinterest path.
55
+ class Error < StandardError
56
+ attr_reader :code
57
+
58
+ def initialize(code, message)
59
+ @code = code
60
+ super(message)
61
+ end
62
+ end
63
+
64
+ module_function
65
+
66
+ # Parses a supported Pinterest URL and returns its canonical fields.
67
+ def parse(input)
68
+ original = input.to_s.strip
69
+ raise_invalid("URL is empty or too long") if original.empty? || original.bytesize > MAX_URL_LENGTH
70
+
71
+ uri = URI.parse(original)
72
+ raise_invalid("URL could not be parsed") unless uri.host
73
+ raise_invalid("only HTTPS URLs are supported") unless uri.scheme == "https"
74
+ raise_invalid("credentials are not supported") if uri.userinfo
75
+ raise_invalid("non-standard ports are not supported") unless uri.port == 443
76
+ raise_invalid("encoded paths are not supported") if uri.path.include?("%")
77
+
78
+ host = uri.host.downcase
79
+ segments = uri.path.split("/").reject(&:empty?)
80
+ return parse_short(original, segments) if host == SHORT_HOST
81
+
82
+ raise_invalid("host is not an allowed Pinterest domain") unless pinterest_host?(host)
83
+
84
+ parse_pin(original, segments) ||
85
+ parse_ideas(original, segments) ||
86
+ parse_profile_or_board(original, segments)
87
+ rescue URI::InvalidURIError, URI::InvalidComponentError, ArgumentError
88
+ raise_invalid("URL could not be parsed")
89
+ end
90
+
91
+ # Returns the canonical URL for a supported Pinterest URL.
92
+ def normalize(input)
93
+ parse(input).normalized_url
94
+ end
95
+
96
+ # Reports whether input is a supported Pinterest URL.
97
+ def pinterest_url?(input)
98
+ parse(input)
99
+ true
100
+ rescue Error
101
+ false
102
+ end
103
+
104
+ # Reports whether host is an exact supported Pinterest host.
105
+ def pinterest_host?(host)
106
+ host = host.to_s.downcase
107
+ return true if %w[pinterest.com www.pinterest.com m.pinterest.com].include?(host)
108
+ return true if COUNTRY_HOSTS.include?(host)
109
+ return true if host.start_with?("www.") && COUNTRY_HOSTS.include?(host.delete_prefix("www."))
110
+
111
+ region = host.delete_suffix(".pinterest.com")
112
+ region != host && REGIONAL_SUBDOMAINS.include?(region)
113
+ end
114
+
115
+ def parse_short(original, segments)
116
+ unless segments.length == 1 && segments.first.length >= 2 && ascii_alphanumeric?(segments.first)
117
+ raise_unsupported("unsupported pin.it path")
118
+ end
119
+
120
+ shortcode = segments.first
121
+ ParsedURL.new(
122
+ kind: "short",
123
+ original_url: original,
124
+ normalized_url: "https://#{SHORT_HOST}/#{shortcode}/",
125
+ host: SHORT_HOST,
126
+ shortcode: shortcode
127
+ )
128
+ end
129
+ private_class_method :parse_short
130
+
131
+ def parse_pin(original, segments)
132
+ return unless [2, 3].include?(segments.length) && segments.first == "pin"
133
+
134
+ pin_segment = segments[1]
135
+ pin_id = if numeric_id?(pin_segment)
136
+ pin_segment
137
+ else
138
+ candidate = pin_segment.rpartition("--").last
139
+ candidate if slug?(pin_segment) && numeric_id?(candidate)
140
+ end
141
+ return unless pin_id
142
+ return if segments.length == 3 && !slug?(segments[2])
143
+
144
+ ParsedURL.new(
145
+ kind: "pin",
146
+ original_url: original,
147
+ normalized_url: "https://#{CANONICAL_HOST}/pin/#{pin_id}/",
148
+ host: CANONICAL_HOST,
149
+ pin_id: pin_id
150
+ )
151
+ end
152
+ private_class_method :parse_pin
153
+
154
+ def parse_ideas(original, segments)
155
+ return unless segments.length == 3 && segments.first == "ideas"
156
+ return unless slug?(segments[1]) && numeric_id?(segments[2])
157
+
158
+ ParsedURL.new(
159
+ kind: "ideas",
160
+ original_url: original,
161
+ normalized_url: "https://#{CANONICAL_HOST}/ideas/#{segments[1]}/#{segments[2]}/",
162
+ host: CANONICAL_HOST,
163
+ idea_slug: segments[1],
164
+ idea_id: segments[2]
165
+ )
166
+ end
167
+ private_class_method :parse_ideas
168
+
169
+ def parse_profile_or_board(original, segments)
170
+ username = segments.first
171
+ raise_unsupported("unsupported Pinterest path") unless username
172
+ raise_unsupported("unsupported Pinterest path") if RESERVED_FIRST_SEGMENTS.include?(username.downcase)
173
+
174
+ if segments.length == 1 && username?(username)
175
+ return ParsedURL.new(
176
+ kind: "profile",
177
+ original_url: original,
178
+ normalized_url: "https://#{CANONICAL_HOST}/#{username}/",
179
+ host: CANONICAL_HOST,
180
+ username: username
181
+ )
182
+ end
183
+
184
+ if segments.length == 2 && username?(username) && slug?(segments[1])
185
+ return ParsedURL.new(
186
+ kind: "board",
187
+ original_url: original,
188
+ normalized_url: "https://#{CANONICAL_HOST}/#{username}/#{segments[1]}/",
189
+ host: CANONICAL_HOST,
190
+ username: username,
191
+ board_slug: segments[1]
192
+ )
193
+ end
194
+
195
+ raise_unsupported("unsupported Pinterest path")
196
+ end
197
+ private_class_method :parse_profile_or_board
198
+
199
+ def numeric_id?(value)
200
+ value.match?(/\A\d{1,20}\z/)
201
+ end
202
+ private_class_method :numeric_id?
203
+
204
+ def ascii_alphanumeric?(value)
205
+ value.match?(/\A[A-Za-z0-9]+\z/)
206
+ end
207
+ private_class_method :ascii_alphanumeric?
208
+
209
+ def slug?(value)
210
+ value.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
211
+ end
212
+ private_class_method :slug?
213
+
214
+ def username?(value)
215
+ value.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/)
216
+ end
217
+ private_class_method :username?
218
+
219
+ def raise_invalid(message)
220
+ raise Error.new("INVALID_URL", message)
221
+ end
222
+ private_class_method :raise_invalid
223
+
224
+ def raise_unsupported(message)
225
+ raise Error.new("UNSUPPORTED_URL", message)
226
+ end
227
+ private_class_method :raise_unsupported
228
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pinterest-url-normalizer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - SavePinner
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ description: A network-free Pinterest URL parser and normalizer with an exact host
41
+ allow list.
42
+ email:
43
+ - jiankn@users.noreply.github.com
44
+ executables:
45
+ - pinterest-url-normalizer
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE
50
+ - README.md
51
+ - exe/pinterest-url-normalizer
52
+ - lib/pinterest_url_normalizer.rb
53
+ - lib/pinterest_url_normalizer/cli.rb
54
+ - lib/pinterest_url_normalizer/version.rb
55
+ homepage: https://savepinner.com/pinterest-downloader/
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ allowed_push_host: https://rubygems.org
60
+ source_code_uri: https://github.com/jiankn/pinterest-url-normalizer-ruby
61
+ documentation_uri: https://www.rubydoc.info/gems/pinterest-url-normalizer
62
+ homepage_uri: https://savepinner.com/pinterest-downloader/
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '3.1'
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 4.0.16
78
+ specification_version: 4
79
+ summary: Parse, classify, and normalize Pinterest URLs
80
+ test_files: []