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.
- pinterest_url_normalizer-0.1.0/.gitignore +10 -0
- pinterest_url_normalizer-0.1.0/LICENSE +21 -0
- pinterest_url_normalizer-0.1.0/PKG-INFO +108 -0
- pinterest_url_normalizer-0.1.0/README.md +80 -0
- pinterest_url_normalizer-0.1.0/pyproject.toml +49 -0
- pinterest_url_normalizer-0.1.0/src/pinterest_url_normalizer/__init__.py +27 -0
- pinterest_url_normalizer-0.1.0/src/pinterest_url_normalizer/__main__.py +3 -0
- pinterest_url_normalizer-0.1.0/src/pinterest_url_normalizer/cli.py +76 -0
- pinterest_url_normalizer-0.1.0/src/pinterest_url_normalizer/core.py +283 -0
- pinterest_url_normalizer-0.1.0/src/pinterest_url_normalizer/py.typed +1 -0
- pinterest_url_normalizer-0.1.0/tests/test_cli.py +42 -0
- pinterest_url_normalizer-0.1.0/tests/test_core.py +86 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|