pinterest-url-normalizer 0.1.0__tar.gz

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,10 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .coverage
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .venv/
8
+ build/
9
+ dist/
10
+ verified-dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SavePinner contributors
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.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: pinterest-url-normalizer
3
+ Version: 0.1.0
4
+ Summary: Parse, classify, and normalize Pinterest URLs without network requests
5
+ Project-URL: Homepage, https://savepinner.com/pinterest-downloader/
6
+ Project-URL: Source, https://github.com/jiankn/pinterest-url-normalizer-python
7
+ Project-URL: Issues, https://github.com/jiankn/pinterest-url-normalizer-python/issues
8
+ Project-URL: TypeScript package, https://jsr.io/@savepinner/pinterest-url-normalizer
9
+ Author: SavePinner contributors
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: normalizer,parser,pinterest,url,validation
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Internet :: WWW/HTTP
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+
29
+ # pinterest-url-normalizer
30
+
31
+ Parse, classify, and normalize Pinterest URLs without making network requests.
32
+
33
+ [SavePinner](https://savepinner.com/pinterest-downloader/) · [TypeScript package on JSR](https://jsr.io/@savepinner/pinterest-url-normalizer) · [Source code](https://github.com/jiankn/pinterest-url-normalizer-python)
34
+
35
+ The package 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 tracking parameters from normalized output.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ python -m pip install pinterest-url-normalizer
41
+ ```
42
+
43
+ ## Python API
44
+
45
+ ```python
46
+ from pinterest_url_normalizer import (
47
+ is_pinterest_url,
48
+ normalize_pinterest_url,
49
+ parse_pinterest_url,
50
+ )
51
+
52
+ parsed = parse_pinterest_url("https://de.pinterest.com/pin/987654321/?utm_source=share")
53
+
54
+ print(parsed.kind) # pin
55
+ print(parsed.pin_id) # 987654321
56
+ print(parsed.normalized_url) # https://www.pinterest.com/pin/987654321/
57
+
58
+ is_pinterest_url("https://pin.it/AbC123")
59
+ normalize_pinterest_url("https://pinterest.co.uk/savepinner/media-tools/")
60
+ ```
61
+
62
+ ## Command line
63
+
64
+ Normalize URL arguments:
65
+
66
+ ```bash
67
+ pinterest-url-normalizer \
68
+ "https://pinterest.co.uk/pin/123/?utm_source=share" \
69
+ "https://pin.it/AbC123"
70
+ ```
71
+
72
+ Or pipe one URL per line and emit JSON Lines:
73
+
74
+ ```bash
75
+ printf '%s\n' 'https://www.pinterest.com/pin/123/' | \
76
+ pinterest-url-normalizer --json
77
+ ```
78
+
79
+ The command exits with status `1` when any input is invalid and `2` when no input is provided.
80
+
81
+ ## Supported URL kinds
82
+
83
+ | Kind | Example |
84
+ | --- | --- |
85
+ | `pin` | `https://www.pinterest.com/pin/123456789/` |
86
+ | `short` | `https://pin.it/AbC123` |
87
+ | `profile` | `https://www.pinterest.com/savepinner/` |
88
+ | `board` | `https://www.pinterest.com/savepinner/media-tools/` |
89
+ | `ideas` | `https://www.pinterest.com/ideas/space-wallpaper/926295399832/` |
90
+
91
+ `pin.it` links are classified and normalized but are not followed. Resolving them requires a network request and belongs in the consuming application.
92
+
93
+ ## API
94
+
95
+ - `parse_pinterest_url(value)` returns an immutable `ParsedPinterestUrl`. It raises `PinterestUrlError` with code `INVALID_URL` or `UNSUPPORTED_URL` on failure.
96
+ - `normalize_pinterest_url(value)` returns the canonical URL.
97
+ - `is_pinterest_url(value)` validates a supported URL form.
98
+ - `is_pinterest_host(host)` checks a hostname against the exact country-domain allow list.
99
+
100
+ ## Why this package exists
101
+
102
+ This parser is maintained by the team behind [SavePinner](https://savepinner.com/pinterest-downloader/), a browser tool for inspecting media exposed by public Pinterest Pin URLs. The package contains no downloader, tracking, browser automation, or remote code.
103
+
104
+ Pinterest is a trademark of Pinterest, Inc. This project is independent and is not affiliated with or endorsed by Pinterest.
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,80 @@
1
+ # pinterest-url-normalizer
2
+
3
+ Parse, classify, and normalize Pinterest URLs without making network requests.
4
+
5
+ [SavePinner](https://savepinner.com/pinterest-downloader/) · [TypeScript package on JSR](https://jsr.io/@savepinner/pinterest-url-normalizer) · [Source code](https://github.com/jiankn/pinterest-url-normalizer-python)
6
+
7
+ The package 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 tracking parameters from normalized output.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ python -m pip install pinterest-url-normalizer
13
+ ```
14
+
15
+ ## Python API
16
+
17
+ ```python
18
+ from pinterest_url_normalizer import (
19
+ is_pinterest_url,
20
+ normalize_pinterest_url,
21
+ parse_pinterest_url,
22
+ )
23
+
24
+ parsed = parse_pinterest_url("https://de.pinterest.com/pin/987654321/?utm_source=share")
25
+
26
+ print(parsed.kind) # pin
27
+ print(parsed.pin_id) # 987654321
28
+ print(parsed.normalized_url) # https://www.pinterest.com/pin/987654321/
29
+
30
+ is_pinterest_url("https://pin.it/AbC123")
31
+ normalize_pinterest_url("https://pinterest.co.uk/savepinner/media-tools/")
32
+ ```
33
+
34
+ ## Command line
35
+
36
+ Normalize URL arguments:
37
+
38
+ ```bash
39
+ pinterest-url-normalizer \
40
+ "https://pinterest.co.uk/pin/123/?utm_source=share" \
41
+ "https://pin.it/AbC123"
42
+ ```
43
+
44
+ Or pipe one URL per line and emit JSON Lines:
45
+
46
+ ```bash
47
+ printf '%s\n' 'https://www.pinterest.com/pin/123/' | \
48
+ pinterest-url-normalizer --json
49
+ ```
50
+
51
+ The command exits with status `1` when any input is invalid and `2` when no input is provided.
52
+
53
+ ## Supported URL kinds
54
+
55
+ | Kind | Example |
56
+ | --- | --- |
57
+ | `pin` | `https://www.pinterest.com/pin/123456789/` |
58
+ | `short` | `https://pin.it/AbC123` |
59
+ | `profile` | `https://www.pinterest.com/savepinner/` |
60
+ | `board` | `https://www.pinterest.com/savepinner/media-tools/` |
61
+ | `ideas` | `https://www.pinterest.com/ideas/space-wallpaper/926295399832/` |
62
+
63
+ `pin.it` links are classified and normalized but are not followed. Resolving them requires a network request and belongs in the consuming application.
64
+
65
+ ## API
66
+
67
+ - `parse_pinterest_url(value)` returns an immutable `ParsedPinterestUrl`. It raises `PinterestUrlError` with code `INVALID_URL` or `UNSUPPORTED_URL` on failure.
68
+ - `normalize_pinterest_url(value)` returns the canonical URL.
69
+ - `is_pinterest_url(value)` validates a supported URL form.
70
+ - `is_pinterest_host(host)` checks a hostname against the exact country-domain allow list.
71
+
72
+ ## Why this package exists
73
+
74
+ This parser is maintained by the team behind [SavePinner](https://savepinner.com/pinterest-downloader/), a browser tool for inspecting media exposed by public Pinterest Pin URLs. The package contains no downloader, tracking, browser automation, or remote code.
75
+
76
+ Pinterest is a trademark of Pinterest, Inc. This project is independent and is not affiliated with or endorsed by Pinterest.
77
+
78
+ ## License
79
+
80
+ MIT
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pinterest-url-normalizer"
7
+ version = "0.1.0"
8
+ description = "Parse, classify, and normalize Pinterest URLs without network requests"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "SavePinner contributors" }]
13
+ keywords = ["pinterest", "url", "normalizer", "parser", "validation"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Internet :: WWW/HTTP",
27
+ "Typing :: Typed",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://savepinner.com/pinterest-downloader/"
32
+ Source = "https://github.com/jiankn/pinterest-url-normalizer-python"
33
+ Issues = "https://github.com/jiankn/pinterest-url-normalizer-python/issues"
34
+ "TypeScript package" = "https://jsr.io/@savepinner/pinterest-url-normalizer"
35
+
36
+ [project.scripts]
37
+ pinterest-url-normalizer = "pinterest_url_normalizer.cli:main"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/pinterest_url_normalizer"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = [
44
+ "/src",
45
+ "/tests",
46
+ "/LICENSE",
47
+ "/README.md",
48
+ "/pyproject.toml",
49
+ ]
@@ -0,0 +1,27 @@
1
+ """Public API for pinterest-url-normalizer."""
2
+
3
+ from .core import (
4
+ PINTEREST_HOSTS,
5
+ ParsedPinterestUrl,
6
+ PinterestUrlError,
7
+ PinterestUrlErrorCode,
8
+ PinterestUrlKind,
9
+ is_pinterest_host,
10
+ is_pinterest_url,
11
+ normalize_pinterest_url,
12
+ parse_pinterest_url,
13
+ )
14
+
15
+ __all__ = [
16
+ "PINTEREST_HOSTS",
17
+ "ParsedPinterestUrl",
18
+ "PinterestUrlError",
19
+ "PinterestUrlErrorCode",
20
+ "PinterestUrlKind",
21
+ "is_pinterest_host",
22
+ "is_pinterest_url",
23
+ "normalize_pinterest_url",
24
+ "parse_pinterest_url",
25
+ ]
26
+
27
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,76 @@
1
+ """Command-line interface for pinterest-url-normalizer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from dataclasses import asdict
10
+ from typing import TextIO
11
+
12
+ from .core import PinterestUrlError, parse_pinterest_url
13
+
14
+
15
+ def _parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(
17
+ prog="pinterest-url-normalizer",
18
+ description="Normalize Pinterest URLs without making network requests.",
19
+ allow_abbrev=False,
20
+ )
21
+ parser.add_argument(
22
+ "urls",
23
+ metavar="URL",
24
+ nargs="*",
25
+ help="URL to normalize; reads one URL per stdin line when omitted",
26
+ )
27
+ parser.add_argument(
28
+ "--json",
29
+ action="store_true",
30
+ help="write one JSON object per input URL",
31
+ )
32
+ return parser
33
+
34
+
35
+ def _stdin_urls(stream: TextIO) -> list[str]:
36
+ return [line.strip() for line in stream if line.strip()]
37
+
38
+
39
+ def main(argv: Sequence[str] | None = None) -> int:
40
+ """Run the command-line interface and return its process exit code."""
41
+
42
+ args = _parser().parse_args(argv)
43
+ urls = args.urls or _stdin_urls(sys.stdin)
44
+ if not urls:
45
+ print("error: provide at least one URL or pipe URLs on stdin", file=sys.stderr)
46
+ return 2
47
+
48
+ exit_code = 0
49
+ for value in urls:
50
+ try:
51
+ parsed = parse_pinterest_url(value)
52
+ except PinterestUrlError as error:
53
+ exit_code = 1
54
+ if args.json:
55
+ print(
56
+ json.dumps(
57
+ {
58
+ "valid": False,
59
+ "input": value,
60
+ "error": str(error),
61
+ "error_code": error.code,
62
+ },
63
+ separators=(",", ":"),
64
+ )
65
+ )
66
+ else:
67
+ print(f"{value}: {error.code}: {error}", file=sys.stderr)
68
+ continue
69
+
70
+ if args.json:
71
+ payload = {"valid": True, **asdict(parsed)}
72
+ print(json.dumps(payload, separators=(",", ":")))
73
+ else:
74
+ print(parsed.normalized_url)
75
+
76
+ return exit_code
@@ -0,0 +1,283 @@
1
+ """Parse and normalize supported Pinterest URL forms without network access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+ from urllib.parse import urlsplit
9
+
10
+ COUNTRY_HOSTS = (
11
+ "pinterest.at",
12
+ "pinterest.be",
13
+ "pinterest.ca",
14
+ "pinterest.ch",
15
+ "pinterest.cl",
16
+ "pinterest.co",
17
+ "pinterest.co.kr",
18
+ "pinterest.co.nz",
19
+ "pinterest.co.uk",
20
+ "pinterest.com.au",
21
+ "pinterest.com.br",
22
+ "pinterest.com.mx",
23
+ "pinterest.com.pe",
24
+ "pinterest.com.tr",
25
+ "pinterest.cz",
26
+ "pinterest.de",
27
+ "pinterest.dk",
28
+ "pinterest.es",
29
+ "pinterest.fi",
30
+ "pinterest.fr",
31
+ "pinterest.gr",
32
+ "pinterest.hu",
33
+ "pinterest.id",
34
+ "pinterest.ie",
35
+ "pinterest.it",
36
+ "pinterest.jp",
37
+ "pinterest.nl",
38
+ "pinterest.no",
39
+ "pinterest.ph",
40
+ "pinterest.pl",
41
+ "pinterest.pt",
42
+ "pinterest.ro",
43
+ "pinterest.se",
44
+ "pinterest.sk",
45
+ )
46
+
47
+ REGIONAL_SUBDOMAINS = (
48
+ "at",
49
+ "au",
50
+ "be",
51
+ "br",
52
+ "ca",
53
+ "ch",
54
+ "cl",
55
+ "co",
56
+ "cz",
57
+ "de",
58
+ "dk",
59
+ "es",
60
+ "fi",
61
+ "fr",
62
+ "gr",
63
+ "hu",
64
+ "id",
65
+ "ie",
66
+ "it",
67
+ "jp",
68
+ "kr",
69
+ "mx",
70
+ "nl",
71
+ "no",
72
+ "nz",
73
+ "pe",
74
+ "ph",
75
+ "pl",
76
+ "pt",
77
+ "ro",
78
+ "se",
79
+ "sk",
80
+ "tr",
81
+ "uk",
82
+ )
83
+
84
+ PINTEREST_HOSTS = frozenset(
85
+ (
86
+ "pinterest.com",
87
+ "www.pinterest.com",
88
+ "m.pinterest.com",
89
+ *COUNTRY_HOSTS,
90
+ *(f"www.{host}" for host in COUNTRY_HOSTS),
91
+ *(f"{region}.pinterest.com" for region in REGIONAL_SUBDOMAINS),
92
+ )
93
+ )
94
+
95
+ RESERVED_FIRST_SEGMENTS = frozenset(
96
+ {
97
+ "business",
98
+ "categories",
99
+ "explore",
100
+ "help",
101
+ "ideas",
102
+ "login",
103
+ "logout",
104
+ "oauth",
105
+ "pin",
106
+ "pin-builder",
107
+ "resource",
108
+ "search",
109
+ "settings",
110
+ "signup",
111
+ "today",
112
+ "topics",
113
+ }
114
+ )
115
+
116
+ CANONICAL_HOST = "www.pinterest.com"
117
+ SHORT_HOST = "pin.it"
118
+ PIN_PATH_RE = re.compile(
119
+ r"^/pin/(?:(\d{1,20})|[A-Za-z0-9][A-Za-z0-9_-]*--(\d{1,20}))(?:/[A-Za-z0-9_-]*)?/?$"
120
+ )
121
+ SHORT_PATH_RE = re.compile(r"^/([A-Za-z0-9]{2,})/?$")
122
+ IDEAS_PATH_RE = re.compile(r"^/ideas/([A-Za-z0-9][A-Za-z0-9_-]*)/(\d{1,20})/?$")
123
+ USERNAME_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$")
124
+ BOARD_SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
125
+
126
+ PinterestUrlKind = Literal["pin", "short", "profile", "board", "ideas"]
127
+ PinterestUrlErrorCode = Literal["INVALID_URL", "UNSUPPORTED_URL"]
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class ParsedPinterestUrl:
132
+ """A supported Pinterest URL and its canonical representation."""
133
+
134
+ kind: PinterestUrlKind
135
+ original_url: str
136
+ normalized_url: str
137
+ host: str
138
+ pin_id: str | None = None
139
+ shortcode: str | None = None
140
+ username: str | None = None
141
+ board_slug: str | None = None
142
+ idea_slug: str | None = None
143
+ idea_id: str | None = None
144
+
145
+
146
+ class PinterestUrlError(ValueError):
147
+ """Raised when a URL is invalid or has an unsupported Pinterest path."""
148
+
149
+ def __init__(self, code: PinterestUrlErrorCode, message: str) -> None:
150
+ super().__init__(message)
151
+ self.code = code
152
+
153
+
154
+ def _parse_https_url(value: str) -> tuple[str, str, str]:
155
+ if not isinstance(value, str):
156
+ raise PinterestUrlError("INVALID_URL", "URL must be a string")
157
+
158
+ original_url = value.strip()
159
+ if not original_url or len(original_url) > 2048:
160
+ raise PinterestUrlError("INVALID_URL", "URL is empty or too long")
161
+
162
+ try:
163
+ parsed = urlsplit(original_url)
164
+ host = parsed.hostname.lower() if parsed.hostname else ""
165
+ port = parsed.port
166
+ except ValueError as error:
167
+ raise PinterestUrlError("INVALID_URL", "URL could not be parsed") from error
168
+
169
+ if parsed.scheme.lower() != "https" or not host:
170
+ raise PinterestUrlError("INVALID_URL", "Only HTTPS URLs are supported")
171
+ if (
172
+ port not in (None, 443)
173
+ or parsed.username is not None
174
+ or parsed.password is not None
175
+ ):
176
+ raise PinterestUrlError(
177
+ "INVALID_URL", "Credentials and non-standard ports are not supported"
178
+ )
179
+
180
+ return original_url, host, parsed.path
181
+
182
+
183
+ def is_pinterest_host(host: str) -> bool:
184
+ """Return whether *host* is in the exact Pinterest host allow list."""
185
+
186
+ return isinstance(host, str) and host.lower() in PINTEREST_HOSTS
187
+
188
+
189
+ def parse_pinterest_url(value: str) -> ParsedPinterestUrl:
190
+ """Parse a supported Pinterest URL and return its normalized fields."""
191
+
192
+ original_url, host, path = _parse_https_url(value)
193
+
194
+ if host == SHORT_HOST:
195
+ match = SHORT_PATH_RE.fullmatch(path)
196
+ if not match:
197
+ raise PinterestUrlError("UNSUPPORTED_URL", "Unsupported pin.it path")
198
+ shortcode = match.group(1)
199
+ return ParsedPinterestUrl(
200
+ kind="short",
201
+ original_url=original_url,
202
+ normalized_url=f"https://{SHORT_HOST}/{shortcode}/",
203
+ host=SHORT_HOST,
204
+ shortcode=shortcode,
205
+ )
206
+
207
+ if not is_pinterest_host(host):
208
+ raise PinterestUrlError(
209
+ "INVALID_URL", "Host is not an allowed Pinterest domain"
210
+ )
211
+
212
+ pin_match = PIN_PATH_RE.fullmatch(path)
213
+ if pin_match:
214
+ pin_id = pin_match.group(1) or pin_match.group(2)
215
+ return ParsedPinterestUrl(
216
+ kind="pin",
217
+ original_url=original_url,
218
+ normalized_url=f"https://{CANONICAL_HOST}/pin/{pin_id}/",
219
+ host=CANONICAL_HOST,
220
+ pin_id=pin_id,
221
+ )
222
+
223
+ ideas_match = IDEAS_PATH_RE.fullmatch(path)
224
+ if ideas_match:
225
+ idea_slug, idea_id = ideas_match.groups()
226
+ return ParsedPinterestUrl(
227
+ kind="ideas",
228
+ original_url=original_url,
229
+ normalized_url=f"https://{CANONICAL_HOST}/ideas/{idea_slug}/{idea_id}/",
230
+ host=CANONICAL_HOST,
231
+ idea_slug=idea_slug,
232
+ idea_id=idea_id,
233
+ )
234
+
235
+ segments = [segment for segment in path.split("/") if segment]
236
+ first_segment = segments[0].lower() if segments else ""
237
+ if not first_segment or first_segment in RESERVED_FIRST_SEGMENTS:
238
+ raise PinterestUrlError("UNSUPPORTED_URL", "Unsupported Pinterest path")
239
+
240
+ if len(segments) == 1 and USERNAME_RE.fullmatch(segments[0]):
241
+ username = segments[0]
242
+ return ParsedPinterestUrl(
243
+ kind="profile",
244
+ original_url=original_url,
245
+ normalized_url=f"https://{CANONICAL_HOST}/{username}/",
246
+ host=CANONICAL_HOST,
247
+ username=username,
248
+ )
249
+
250
+ if (
251
+ len(segments) == 2
252
+ and USERNAME_RE.fullmatch(segments[0])
253
+ and BOARD_SLUG_RE.fullmatch(segments[1])
254
+ ):
255
+ username, board_slug = segments
256
+ return ParsedPinterestUrl(
257
+ kind="board",
258
+ original_url=original_url,
259
+ normalized_url=f"https://{CANONICAL_HOST}/{username}/{board_slug}/",
260
+ host=CANONICAL_HOST,
261
+ username=username,
262
+ board_slug=board_slug,
263
+ )
264
+
265
+ raise PinterestUrlError("UNSUPPORTED_URL", "Unsupported Pinterest path")
266
+
267
+
268
+ def normalize_pinterest_url(value: str) -> str:
269
+ """Return the canonical representation of a supported Pinterest URL."""
270
+
271
+ return parse_pinterest_url(value).normalized_url
272
+
273
+
274
+ def is_pinterest_url(value: object) -> bool:
275
+ """Return whether *value* is a supported Pinterest URL."""
276
+
277
+ if not isinstance(value, str):
278
+ return False
279
+ try:
280
+ parse_pinterest_url(value)
281
+ except PinterestUrlError:
282
+ return False
283
+ return True
@@ -0,0 +1,42 @@
1
+ import io
2
+ import json
3
+ import unittest
4
+ from contextlib import redirect_stderr, redirect_stdout
5
+ from unittest.mock import patch
6
+
7
+ from pinterest_url_normalizer.cli import main
8
+
9
+
10
+ class CliTests(unittest.TestCase):
11
+ def test_normalizes_positional_urls(self) -> None:
12
+ stdout = io.StringIO()
13
+ with redirect_stdout(stdout):
14
+ exit_code = main(["https://pinterest.co.uk/pin/123/?utm_source=test"])
15
+
16
+ self.assertEqual(exit_code, 0)
17
+ self.assertEqual(stdout.getvalue(), "https://www.pinterest.com/pin/123/\n")
18
+
19
+ def test_reads_stdin_and_reports_json_lines(self) -> None:
20
+ stdout = io.StringIO()
21
+ stdin = io.StringIO("https://pin.it/AbC123\nhttps://example.com/pin/123/\n")
22
+ with patch("sys.stdin", stdin), redirect_stdout(stdout):
23
+ exit_code = main(["--json"])
24
+
25
+ rows = [json.loads(line) for line in stdout.getvalue().splitlines()]
26
+ self.assertEqual(exit_code, 1)
27
+ self.assertEqual(rows[0]["valid"], True)
28
+ self.assertEqual(rows[0]["normalized_url"], "https://pin.it/AbC123/")
29
+ self.assertEqual(rows[1]["valid"], False)
30
+ self.assertEqual(rows[1]["error_code"], "INVALID_URL")
31
+
32
+ def test_requires_input(self) -> None:
33
+ stderr = io.StringIO()
34
+ with patch("sys.stdin", io.StringIO()), redirect_stderr(stderr):
35
+ exit_code = main([])
36
+
37
+ self.assertEqual(exit_code, 2)
38
+ self.assertIn("provide at least one URL", stderr.getvalue())
39
+
40
+
41
+ if __name__ == "__main__":
42
+ unittest.main()
@@ -0,0 +1,86 @@
1
+ import unittest
2
+
3
+ from pinterest_url_normalizer import (
4
+ PinterestUrlError,
5
+ is_pinterest_host,
6
+ is_pinterest_url,
7
+ normalize_pinterest_url,
8
+ parse_pinterest_url,
9
+ )
10
+
11
+
12
+ class PinterestUrlNormalizerTests(unittest.TestCase):
13
+ def test_parses_and_normalizes_pin_urls(self) -> None:
14
+ parsed = parse_pinterest_url(
15
+ "https://de.pinterest.com/pin/987654321/?utm_source=test"
16
+ )
17
+
18
+ self.assertEqual(parsed.kind, "pin")
19
+ self.assertEqual(parsed.pin_id, "987654321")
20
+ self.assertEqual(
21
+ parsed.original_url,
22
+ "https://de.pinterest.com/pin/987654321/?utm_source=test",
23
+ )
24
+ self.assertEqual(
25
+ parsed.normalized_url, "https://www.pinterest.com/pin/987654321/"
26
+ )
27
+ self.assertEqual(parsed.host, "www.pinterest.com")
28
+
29
+ def test_extracts_id_from_slugged_pin_urls(self) -> None:
30
+ parsed = parse_pinterest_url(
31
+ "https://www.pinterest.com/pin/roasted-pineapple-chicken--68746366275/"
32
+ )
33
+
34
+ self.assertEqual(parsed.kind, "pin")
35
+ self.assertEqual(parsed.pin_id, "68746366275")
36
+
37
+ def test_recognizes_short_urls_without_resolving_them(self) -> None:
38
+ parsed = parse_pinterest_url("https://pin.it/AbC123?source=share")
39
+
40
+ self.assertEqual(parsed.kind, "short")
41
+ self.assertEqual(parsed.shortcode, "AbC123")
42
+ self.assertEqual(parsed.normalized_url, "https://pin.it/AbC123/")
43
+
44
+ def test_classifies_profile_board_and_ideas_urls(self) -> None:
45
+ profile = parse_pinterest_url("https://pinterest.com/savepinner/")
46
+ board = parse_pinterest_url("https://www.pinterest.com/savepinner/media-tools/")
47
+ ideas = parse_pinterest_url(
48
+ "https://www.pinterest.com/ideas/space-wallpaper-4k/926295399832/"
49
+ )
50
+
51
+ self.assertEqual(profile.kind, "profile")
52
+ self.assertEqual(profile.username, "savepinner")
53
+ self.assertEqual(board.kind, "board")
54
+ self.assertEqual(board.username, "savepinner")
55
+ self.assertEqual(board.board_slug, "media-tools")
56
+ self.assertEqual(ideas.kind, "ideas")
57
+ self.assertEqual(ideas.idea_slug, "space-wallpaper-4k")
58
+ self.assertEqual(ideas.idea_id, "926295399832")
59
+
60
+ def test_rejects_lookalikes_http_credentials_ports_and_reserved_paths(self) -> None:
61
+ invalid = (
62
+ "https://www.pinterest.com.evil.example/pin/123/",
63
+ "http://www.pinterest.com/pin/123/",
64
+ "https://user:pass@www.pinterest.com/pin/123/",
65
+ "https://www.pinterest.com:8443/pin/123/",
66
+ "https://www.pinterest.com/search/pins/?q=cats",
67
+ )
68
+
69
+ for value in invalid:
70
+ with self.subTest(value=value):
71
+ self.assertFalse(is_pinterest_url(value))
72
+ with self.assertRaises(PinterestUrlError):
73
+ parse_pinterest_url(value)
74
+
75
+ def test_exposes_normalization_and_host_helpers(self) -> None:
76
+ self.assertEqual(
77
+ normalize_pinterest_url("https://pinterest.co.uk/pin/123/?foo=bar"),
78
+ "https://www.pinterest.com/pin/123/",
79
+ )
80
+ self.assertTrue(is_pinterest_host("PINTEREST.CO.UK"))
81
+ self.assertFalse(is_pinterest_host("pinterest.co.uk.evil.example"))
82
+ self.assertFalse(is_pinterest_url(None))
83
+
84
+
85
+ if __name__ == "__main__":
86
+ unittest.main()