utmkit 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.
- utmkit-0.1.0/LICENSE +21 -0
- utmkit-0.1.0/PKG-INFO +115 -0
- utmkit-0.1.0/README.md +98 -0
- utmkit-0.1.0/pyproject.toml +27 -0
- utmkit-0.1.0/setup.cfg +4 -0
- utmkit-0.1.0/src/utmkit/__init__.py +6 -0
- utmkit-0.1.0/src/utmkit/batch.py +121 -0
- utmkit-0.1.0/src/utmkit/cli.py +130 -0
- utmkit-0.1.0/src/utmkit/core.py +183 -0
- utmkit-0.1.0/src/utmkit/qr.py +42 -0
- utmkit-0.1.0/src/utmkit.egg-info/PKG-INFO +115 -0
- utmkit-0.1.0/src/utmkit.egg-info/SOURCES.txt +16 -0
- utmkit-0.1.0/src/utmkit.egg-info/dependency_links.txt +1 -0
- utmkit-0.1.0/src/utmkit.egg-info/entry_points.txt +2 -0
- utmkit-0.1.0/src/utmkit.egg-info/requires.txt +3 -0
- utmkit-0.1.0/src/utmkit.egg-info/top_level.txt +1 -0
- utmkit-0.1.0/tests/test_batch.py +199 -0
- utmkit-0.1.0/tests/test_core.py +95 -0
utmkit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 calasdan-sketch
|
|
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.
|
utmkit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: utmkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Batch UTM campaign links with consistent slugs, validation, QR codes and a static index page
|
|
5
|
+
Author: calasdan-sketch
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: utm,campaign,tracking,qr,marketing,csv,links
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: qr
|
|
15
|
+
Requires-Dist: qrcode[pil]>=7; extra == "qr"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# utmkit
|
|
19
|
+
|
|
20
|
+
**Campaign links, done consistently.** Batch-build UTM-tagged URLs from a CSV spec, with deterministic file slugs, validation that catches the mistakes that silently wreck analytics, optional QR codes, and a static index page you can host or print.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install utmkit # links, slugs, validation
|
|
26
|
+
pip install 'utmkit[qr]' # + QR code generation
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## One link
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
utmkit gen https://calasautomations.com/onfile \
|
|
33
|
+
--source instagram --medium social --campaign launch26
|
|
34
|
+
# https://calasautomations.com/onfile?utm_source=instagram&utm_medium=social&utm_campaign=launch26
|
|
35
|
+
|
|
36
|
+
utmkit gen ... --qr launch26-ig.png # QR for the same URL (needs [qr] extra)
|
|
37
|
+
utmkit gen ... --qr launch26-ig.svg # vector SVG for print: needs only the qrcode package, no Pillow
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Batch
|
|
41
|
+
|
|
42
|
+
`spec.csv`:
|
|
43
|
+
|
|
44
|
+
```csv
|
|
45
|
+
url,source,medium,campaign,name
|
|
46
|
+
https://calasautomations.com/onfile,instagram,social,launch26,Spring Ad
|
|
47
|
+
https://calasautomations.com/quote,newsletter,email,launch26,Quote Nudge
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
utmkit batch spec.csv --out-dir out/ --qr --html
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Produces `out/links.csv` (slug, final URL, QR filename), one QR code per row when enabled (PNG, or vector SVG with `--qr-format svg`), and `out/index.html` — a table of every link with clickable URLs and QR images.
|
|
55
|
+
|
|
56
|
+
## Validation
|
|
57
|
+
|
|
58
|
+
`utmkit validate spec.csv` flags the classics without building anything:
|
|
59
|
+
|
|
60
|
+
- **error** — missing `utm_source`, empty URL
|
|
61
|
+
- **warn** (`batch` only) — `duplicate_url`: two rows build the exact same final URL, so their clicks can't be told apart in analytics; the warning names both rows
|
|
62
|
+
- **warn** — uppercase values (analytics tools split `Instagram` from `instagram`), spaces in values, unusual `utm_medium` values, URLs without a scheme
|
|
63
|
+
|
|
64
|
+
`--fix` on `gen` and `batch` cleans the five UTM values before validation: lowercase, trim, and inner spaces to `-` (`" Instagram Ads "` becomes `instagram-ads`). Each change is reported as an `info` finding naming the old and new value. Without `--fix`, nothing is changed.
|
|
65
|
+
|
|
66
|
+
Warnings don't fail the build unless `--strict`. In `batch`, a row with an error is reported and skipped (no link built) and the run exits 1.
|
|
67
|
+
|
|
68
|
+
## Audit an existing link
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
utmkit parse "https://x.com/?utm_source=IG&utm_medium=social"
|
|
72
|
+
# url: https://x.com/
|
|
73
|
+
# utm_source: IG
|
|
74
|
+
# utm_medium: social
|
|
75
|
+
# WARN not_lowercase: utm_source='IG' should be lowercase (analytics tools split cases)
|
|
76
|
+
# WARN no_campaign: utm_campaign is missing
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Splits a tagged URL someone else made into its base URL and UTM values, then runs the same checks as `validate` (plus `--rules` if given). `parse` also warns when `utm_campaign` is missing (`gen`/`batch` don't). Exit 1 on an error such as a missing `utm_source`; `--json` for automation.
|
|
80
|
+
|
|
81
|
+
## Naming rules (allow-list)
|
|
82
|
+
|
|
83
|
+
Lock the values your team may use, so nobody invents `Insta` next to `instagram`:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{"source": ["instagram", "facebook"], "medium": ["social", "email", "cpc", "qr"], "campaign_pattern": "^[a-z0-9-]+$"}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
utmkit validate spec.csv --rules rules.json # also on gen and batch
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
All three keys are optional. A `source` or `medium` outside its list, or a `campaign` that doesn't match the regex, is an **error** (`not_allowed`): the row is skipped in `batch` and the run exits 1. When you give a `medium` list, it replaces the built-in "unusual medium" warning. A missing or malformed rules file (bad JSON, a list that isn't a list of strings, an invalid regex, an unknown key) exits 2 with a message. Use `--fix` together with `--rules` to lowercase values before they are checked.
|
|
94
|
+
|
|
95
|
+
utmkit builds links; it doesn't track clicks. Pair it with your analytics tool.
|
|
96
|
+
|
|
97
|
+
## Design notes
|
|
98
|
+
|
|
99
|
+
- **Deterministic slugs**: the `name` column (else the final tagged URL) becomes the filename slug; collisions get `-2`, `-3` suffixes — same input, same output names, every run.
|
|
100
|
+
- **Existing params survive**: `?id=7&ref=nav` stays intact; stale values for the five standard keys (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`) are replaced by the campaign (the campaign is the source of truth); other params such as `utm_id` are kept.
|
|
101
|
+
- **QR is optional**: the core has zero dependencies; `qrcode` is only imported when you pass `--qr` and installs via the `[qr]` extra. PNG needs Pillow (the extra pulls it in); SVG output uses qrcode's own SVG writer and works without Pillow. Custom backends can be injected through the Python API.
|
|
102
|
+
- **Static `index.html`**: host it, print it, or hand it to whoever is running the campaign — no JS, no build step.
|
|
103
|
+
|
|
104
|
+
## Python API
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from utmkit import Campaign, build_url, slugify
|
|
108
|
+
|
|
109
|
+
url = build_url(Campaign("https://ex.com", source="ig", medium="social", campaign="c1"))
|
|
110
|
+
assert slugify("Spring Sale — 20% OFF!") == "spring-sale-20-off"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT-style: use it, ship it, no warranty.
|
utmkit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# utmkit
|
|
2
|
+
|
|
3
|
+
**Campaign links, done consistently.** Batch-build UTM-tagged URLs from a CSV spec, with deterministic file slugs, validation that catches the mistakes that silently wreck analytics, optional QR codes, and a static index page you can host or print.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install utmkit # links, slugs, validation
|
|
9
|
+
pip install 'utmkit[qr]' # + QR code generation
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## One link
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
utmkit gen https://calasautomations.com/onfile \
|
|
16
|
+
--source instagram --medium social --campaign launch26
|
|
17
|
+
# https://calasautomations.com/onfile?utm_source=instagram&utm_medium=social&utm_campaign=launch26
|
|
18
|
+
|
|
19
|
+
utmkit gen ... --qr launch26-ig.png # QR for the same URL (needs [qr] extra)
|
|
20
|
+
utmkit gen ... --qr launch26-ig.svg # vector SVG for print: needs only the qrcode package, no Pillow
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Batch
|
|
24
|
+
|
|
25
|
+
`spec.csv`:
|
|
26
|
+
|
|
27
|
+
```csv
|
|
28
|
+
url,source,medium,campaign,name
|
|
29
|
+
https://calasautomations.com/onfile,instagram,social,launch26,Spring Ad
|
|
30
|
+
https://calasautomations.com/quote,newsletter,email,launch26,Quote Nudge
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
utmkit batch spec.csv --out-dir out/ --qr --html
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Produces `out/links.csv` (slug, final URL, QR filename), one QR code per row when enabled (PNG, or vector SVG with `--qr-format svg`), and `out/index.html` — a table of every link with clickable URLs and QR images.
|
|
38
|
+
|
|
39
|
+
## Validation
|
|
40
|
+
|
|
41
|
+
`utmkit validate spec.csv` flags the classics without building anything:
|
|
42
|
+
|
|
43
|
+
- **error** — missing `utm_source`, empty URL
|
|
44
|
+
- **warn** (`batch` only) — `duplicate_url`: two rows build the exact same final URL, so their clicks can't be told apart in analytics; the warning names both rows
|
|
45
|
+
- **warn** — uppercase values (analytics tools split `Instagram` from `instagram`), spaces in values, unusual `utm_medium` values, URLs without a scheme
|
|
46
|
+
|
|
47
|
+
`--fix` on `gen` and `batch` cleans the five UTM values before validation: lowercase, trim, and inner spaces to `-` (`" Instagram Ads "` becomes `instagram-ads`). Each change is reported as an `info` finding naming the old and new value. Without `--fix`, nothing is changed.
|
|
48
|
+
|
|
49
|
+
Warnings don't fail the build unless `--strict`. In `batch`, a row with an error is reported and skipped (no link built) and the run exits 1.
|
|
50
|
+
|
|
51
|
+
## Audit an existing link
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
utmkit parse "https://x.com/?utm_source=IG&utm_medium=social"
|
|
55
|
+
# url: https://x.com/
|
|
56
|
+
# utm_source: IG
|
|
57
|
+
# utm_medium: social
|
|
58
|
+
# WARN not_lowercase: utm_source='IG' should be lowercase (analytics tools split cases)
|
|
59
|
+
# WARN no_campaign: utm_campaign is missing
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Splits a tagged URL someone else made into its base URL and UTM values, then runs the same checks as `validate` (plus `--rules` if given). `parse` also warns when `utm_campaign` is missing (`gen`/`batch` don't). Exit 1 on an error such as a missing `utm_source`; `--json` for automation.
|
|
63
|
+
|
|
64
|
+
## Naming rules (allow-list)
|
|
65
|
+
|
|
66
|
+
Lock the values your team may use, so nobody invents `Insta` next to `instagram`:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{"source": ["instagram", "facebook"], "medium": ["social", "email", "cpc", "qr"], "campaign_pattern": "^[a-z0-9-]+$"}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
utmkit validate spec.csv --rules rules.json # also on gen and batch
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
All three keys are optional. A `source` or `medium` outside its list, or a `campaign` that doesn't match the regex, is an **error** (`not_allowed`): the row is skipped in `batch` and the run exits 1. When you give a `medium` list, it replaces the built-in "unusual medium" warning. A missing or malformed rules file (bad JSON, a list that isn't a list of strings, an invalid regex, an unknown key) exits 2 with a message. Use `--fix` together with `--rules` to lowercase values before they are checked.
|
|
77
|
+
|
|
78
|
+
utmkit builds links; it doesn't track clicks. Pair it with your analytics tool.
|
|
79
|
+
|
|
80
|
+
## Design notes
|
|
81
|
+
|
|
82
|
+
- **Deterministic slugs**: the `name` column (else the final tagged URL) becomes the filename slug; collisions get `-2`, `-3` suffixes — same input, same output names, every run.
|
|
83
|
+
- **Existing params survive**: `?id=7&ref=nav` stays intact; stale values for the five standard keys (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`) are replaced by the campaign (the campaign is the source of truth); other params such as `utm_id` are kept.
|
|
84
|
+
- **QR is optional**: the core has zero dependencies; `qrcode` is only imported when you pass `--qr` and installs via the `[qr]` extra. PNG needs Pillow (the extra pulls it in); SVG output uses qrcode's own SVG writer and works without Pillow. Custom backends can be injected through the Python API.
|
|
85
|
+
- **Static `index.html`**: host it, print it, or hand it to whoever is running the campaign — no JS, no build step.
|
|
86
|
+
|
|
87
|
+
## Python API
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from utmkit import Campaign, build_url, slugify
|
|
91
|
+
|
|
92
|
+
url = build_url(Campaign("https://ex.com", source="ig", medium="social", campaign="c1"))
|
|
93
|
+
assert slugify("Spring Sale — 20% OFF!") == "spring-sale-20-off"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT-style: use it, ship it, no warranty.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "utmkit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Batch UTM campaign links with consistent slugs, validation, QR codes and a static index page"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "calasdan-sketch" }]
|
|
13
|
+
keywords = ["utm", "campaign", "tracking", "qr", "marketing", "csv", "links"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
utmkit = "utmkit.cli:main"
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
qr = ["qrcode[pil]>=7"] # qrcode 8 no longer pulls in an image backend by itself
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
utmkit-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""utmkit — batch UTM campaign links, slugs, validation and QR codes."""
|
|
2
|
+
|
|
3
|
+
from .core import Campaign, Finding, build_url, slugify, validate_campaign
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__all__ = ["Campaign", "Finding", "build_url", "slugify", "validate_campaign", "__version__"]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Batch processing: campaign spec CSV -> tracked links, slugs, optional QR + HTML."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
import html
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from .core import Campaign, Finding, build_url, normalize, slugify, unique_slug, validate_campaign
|
|
11
|
+
from .qr import Backend, QrUnavailable, get_backend
|
|
12
|
+
|
|
13
|
+
COLUMNS = ("url", "source", "medium", "campaign", "term", "content", "name")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_campaigns(path: str) -> List[tuple]:
|
|
17
|
+
"""Read the spec CSV; returns [(row_number, Campaign)]."""
|
|
18
|
+
out = []
|
|
19
|
+
with open(path, newline="", encoding="utf-8-sig") as fh:
|
|
20
|
+
reader = csv.DictReader(fh)
|
|
21
|
+
if not reader.fieldnames or "url" not in {f.strip().lower() for f in reader.fieldnames}:
|
|
22
|
+
raise ValueError("spec CSV needs a 'url' column")
|
|
23
|
+
for i, row in enumerate(reader, start=2):
|
|
24
|
+
norm = {(k or "").strip().lower(): (v or "").strip() for k, v in row.items()}
|
|
25
|
+
out.append((i, Campaign(
|
|
26
|
+
url=norm.get("url", ""),
|
|
27
|
+
source=norm.get("source", ""),
|
|
28
|
+
medium=norm.get("medium", ""),
|
|
29
|
+
campaign=norm.get("campaign", ""),
|
|
30
|
+
term=norm.get("term", ""),
|
|
31
|
+
content=norm.get("content", ""),
|
|
32
|
+
name=norm.get("name", ""),
|
|
33
|
+
)))
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class BatchResult:
|
|
39
|
+
rows: List[dict] = field(default_factory=list)
|
|
40
|
+
findings: List[Finding] = field(default_factory=list)
|
|
41
|
+
qr_written: List[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_batch(
|
|
45
|
+
campaigns: List[tuple],
|
|
46
|
+
out_dir: str,
|
|
47
|
+
qr: bool = False,
|
|
48
|
+
html_index: bool = False,
|
|
49
|
+
qr_backend: Optional[Backend] = None,
|
|
50
|
+
fix: bool = False,
|
|
51
|
+
rules: Optional[dict] = None,
|
|
52
|
+
qr_format: str = "png",
|
|
53
|
+
) -> BatchResult:
|
|
54
|
+
result = BatchResult()
|
|
55
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
56
|
+
backend = None
|
|
57
|
+
if qr:
|
|
58
|
+
try:
|
|
59
|
+
backend = get_backend(qr_backend)
|
|
60
|
+
except QrUnavailable as e:
|
|
61
|
+
result.findings.append(Finding("warn", "qr_unavailable", "", str(e)))
|
|
62
|
+
taken: List[str] = []
|
|
63
|
+
seen: dict = {} # final_url -> first row number
|
|
64
|
+
for row_no, campaign in campaigns:
|
|
65
|
+
where = f"row {row_no}"
|
|
66
|
+
if fix:
|
|
67
|
+
campaign, fixed = normalize(campaign, where=where)
|
|
68
|
+
result.findings.extend(fixed)
|
|
69
|
+
row_findings = validate_campaign(campaign, where=where, rules=rules)
|
|
70
|
+
result.findings.extend(row_findings)
|
|
71
|
+
if any(f.severity == "error" for f in row_findings):
|
|
72
|
+
continue # reported above; don't build a broken link (or crash on no url)
|
|
73
|
+
url = build_url(campaign)
|
|
74
|
+
if url in seen: # same tracked link twice: clicks can't be told apart
|
|
75
|
+
result.findings.append(Finding("warn", "duplicate_url", where,
|
|
76
|
+
f"same final URL as row {seen[url]}: {url}"))
|
|
77
|
+
else:
|
|
78
|
+
seen[url] = row_no
|
|
79
|
+
base = slugify(campaign.name or url)
|
|
80
|
+
slug = unique_slug(base, taken)
|
|
81
|
+
taken.append(slug)
|
|
82
|
+
entry = {"slug": slug, "url": campaign.url, "final_url": url, "qr": ""}
|
|
83
|
+
if backend is not None:
|
|
84
|
+
qr_path = os.path.join(out_dir, f"{slug}.{qr_format}")
|
|
85
|
+
try:
|
|
86
|
+
backend(url, qr_path)
|
|
87
|
+
entry["qr"] = os.path.basename(qr_path)
|
|
88
|
+
result.qr_written.append(qr_path)
|
|
89
|
+
except Exception as e: # backend failure shouldn't kill the batch
|
|
90
|
+
result.findings.append(Finding("warn", "qr_failed", where, str(e)))
|
|
91
|
+
result.rows.append(entry)
|
|
92
|
+
if html_index:
|
|
93
|
+
write_html_index(result, os.path.join(out_dir, "index.html"))
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write_csv(result: BatchResult, path: str) -> None:
|
|
98
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
99
|
+
writer = csv.DictWriter(fh, fieldnames=["slug", "final_url", "qr"])
|
|
100
|
+
writer.writeheader()
|
|
101
|
+
for row in result.rows:
|
|
102
|
+
writer.writerow({k: row[k] for k in ("slug", "final_url", "qr")})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def write_html_index(result: BatchResult, path: str) -> None:
|
|
106
|
+
lines = ["<!doctype html><meta charset='utf-8'><title>Campaign links</title>",
|
|
107
|
+
"<table border='1' cellpadding='6'><tr><th>slug</th><th>link</th><th>QR</th></tr>"]
|
|
108
|
+
for row in result.rows:
|
|
109
|
+
qr_cell = (f"<img src='{html.escape(row['qr'])}' width='120' alt='QR'>"
|
|
110
|
+
if row["qr"] else "")
|
|
111
|
+
link = html.escape(row["final_url"])
|
|
112
|
+
if row["final_url"].lower().startswith(("http://", "https://")): # no javascript: hrefs
|
|
113
|
+
link = f"<a href='{link}'>{link}</a>"
|
|
114
|
+
lines.append(
|
|
115
|
+
f"<tr><td>{html.escape(row['slug'])}</td>"
|
|
116
|
+
f"<td>{link}</td>"
|
|
117
|
+
f"<td>{qr_cell}</td></tr>"
|
|
118
|
+
)
|
|
119
|
+
lines.append("</table>")
|
|
120
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
121
|
+
fh.write("\n".join(lines))
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""utmkit CLI: build single links or batches from a spec CSV."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .batch import load_campaigns, run_batch, write_csv
|
|
10
|
+
from .core import (Campaign, Finding, build_url, campaign_from_url, load_rules, normalize,
|
|
11
|
+
validate_campaign)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: list[str] | None = None) -> int:
|
|
15
|
+
for stream in (sys.stdout, sys.stderr): # Windows cp1252 consoles choke on non-Latin values
|
|
16
|
+
if hasattr(stream, "reconfigure"):
|
|
17
|
+
stream.reconfigure(encoding="utf-8")
|
|
18
|
+
ap = argparse.ArgumentParser(prog="utmkit", description="Batch UTM campaign links + QR codes.")
|
|
19
|
+
ap.add_argument("--version", action="version", version=f"utmkit {__version__}")
|
|
20
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
21
|
+
|
|
22
|
+
g = sub.add_parser("gen", help="build one tracked URL")
|
|
23
|
+
g.add_argument("url")
|
|
24
|
+
g.add_argument("--source", default="")
|
|
25
|
+
g.add_argument("--medium", default="")
|
|
26
|
+
g.add_argument("--campaign", default="")
|
|
27
|
+
g.add_argument("--term", default="")
|
|
28
|
+
g.add_argument("--content", default="")
|
|
29
|
+
g.add_argument("--qr", metavar="PATH", help="also write a QR code here (.svg = vector, else PNG)")
|
|
30
|
+
g.add_argument("--fix", action="store_true", help="lowercase, trim, spaces to '-' in UTM values")
|
|
31
|
+
g.add_argument("--rules", metavar="JSON", help="JSON allow-list: source/medium lists, campaign_pattern regex")
|
|
32
|
+
|
|
33
|
+
b = sub.add_parser("batch", help="build many tracked URLs from a CSV spec")
|
|
34
|
+
b.add_argument("spec", help="CSV with columns: url, source, medium, campaign[, term, content, name]")
|
|
35
|
+
b.add_argument("--out-dir", default="utmkit-out")
|
|
36
|
+
b.add_argument("--qr", action="store_true", help="write QR codes (needs utmkit[qr])")
|
|
37
|
+
b.add_argument("--qr-format", choices=["png", "svg"], default="png",
|
|
38
|
+
help="svg is vector/print-ready and needs no Pillow")
|
|
39
|
+
b.add_argument("--html", action="store_true", help="also write index.html linking everything")
|
|
40
|
+
b.add_argument("--strict", action="store_true", help="validation warnings exit non-zero")
|
|
41
|
+
b.add_argument("--fix", action="store_true", help="lowercase, trim, spaces to '-' in UTM values")
|
|
42
|
+
b.add_argument("--rules", metavar="JSON", help="JSON allow-list: source/medium lists, campaign_pattern regex")
|
|
43
|
+
|
|
44
|
+
v = sub.add_parser("validate", help="check a spec CSV without building anything")
|
|
45
|
+
v.add_argument("spec")
|
|
46
|
+
v.add_argument("--strict", action="store_true")
|
|
47
|
+
v.add_argument("--rules", metavar="JSON", help="JSON allow-list: source/medium lists, campaign_pattern regex")
|
|
48
|
+
|
|
49
|
+
pa = sub.add_parser("parse", help="decode and check an existing tagged URL")
|
|
50
|
+
pa.add_argument("url")
|
|
51
|
+
pa.add_argument("--json", action="store_true")
|
|
52
|
+
pa.add_argument("--rules", metavar="JSON", help="JSON allow-list: source/medium lists, campaign_pattern regex")
|
|
53
|
+
|
|
54
|
+
args = ap.parse_args(argv)
|
|
55
|
+
try:
|
|
56
|
+
rules = load_rules(args.rules) if args.rules else None
|
|
57
|
+
except ValueError as e:
|
|
58
|
+
print(f"utmkit: {e}", file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
|
|
61
|
+
if args.cmd == "parse":
|
|
62
|
+
campaign = campaign_from_url(args.url)
|
|
63
|
+
findings = validate_campaign(campaign, where="parse", rules=rules)
|
|
64
|
+
if campaign.url and not campaign.campaign: # auditing someone else's link: say it's missing
|
|
65
|
+
findings.append(Finding("warn", "no_campaign", "parse", "utm_campaign is missing"))
|
|
66
|
+
if args.json:
|
|
67
|
+
print(json.dumps({"url": campaign.url, "utm": campaign.utm(),
|
|
68
|
+
"findings": [vars(f) for f in findings]}, indent=2))
|
|
69
|
+
else:
|
|
70
|
+
print(f"url: {campaign.url}")
|
|
71
|
+
for key, value in campaign.utm().items():
|
|
72
|
+
print(f"{key}: {value}")
|
|
73
|
+
for f in findings:
|
|
74
|
+
print(f"{f.severity.upper():5} {f.code}: {f.detail}")
|
|
75
|
+
return 1 if any(f.severity == "error" for f in findings) else 0
|
|
76
|
+
|
|
77
|
+
if args.cmd == "gen":
|
|
78
|
+
campaign = Campaign(url=args.url, source=args.source, medium=args.medium,
|
|
79
|
+
campaign=args.campaign, term=args.term, content=args.content)
|
|
80
|
+
fixed = []
|
|
81
|
+
if args.fix:
|
|
82
|
+
campaign, fixed = normalize(campaign, where="gen")
|
|
83
|
+
findings = fixed + validate_campaign(campaign, where="gen", rules=rules)
|
|
84
|
+
errors = [f for f in findings if f.severity == "error"]
|
|
85
|
+
for f in findings:
|
|
86
|
+
print(f"{f.severity.upper():5} {f.code}: {f.detail}", file=sys.stderr)
|
|
87
|
+
if errors:
|
|
88
|
+
return 1
|
|
89
|
+
url = build_url(campaign)
|
|
90
|
+
print(url)
|
|
91
|
+
if args.qr:
|
|
92
|
+
from .qr import QrUnavailable, get_backend
|
|
93
|
+
try:
|
|
94
|
+
get_backend()(url, args.qr)
|
|
95
|
+
print(f"QR written: {args.qr}", file=sys.stderr)
|
|
96
|
+
except QrUnavailable as e:
|
|
97
|
+
print(f"{e}", file=sys.stderr)
|
|
98
|
+
return 1
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
campaigns = load_campaigns(args.spec)
|
|
103
|
+
except (FileNotFoundError, ValueError) as e:
|
|
104
|
+
print(f"utmkit: {e}", file=sys.stderr)
|
|
105
|
+
return 2
|
|
106
|
+
|
|
107
|
+
if args.cmd == "validate":
|
|
108
|
+
findings = [f for row, c in campaigns for f in validate_campaign(c, where=f"row {row}", rules=rules)]
|
|
109
|
+
for f in findings:
|
|
110
|
+
print(f"{f.severity.upper():5} {f.where}: {f.code}: {f.detail}")
|
|
111
|
+
print(f"{len(findings)} finding(s)")
|
|
112
|
+
return 1 if any(f.severity == "error" for f in findings) or (args.strict and findings) else 0
|
|
113
|
+
|
|
114
|
+
result = run_batch(campaigns, out_dir=args.out_dir, qr=args.qr, html_index=args.html,
|
|
115
|
+
fix=args.fix, rules=rules, qr_format=args.qr_format)
|
|
116
|
+
out_csv = f"{args.out_dir}/links.csv"
|
|
117
|
+
write_csv(result, out_csv)
|
|
118
|
+
errors = [f for f in result.findings if f.severity == "error"]
|
|
119
|
+
warns = [f for f in result.findings if f.severity == "warn"]
|
|
120
|
+
for f in result.findings:
|
|
121
|
+
print(f"{f.severity.upper():5} {f.where}: {f.code}: {f.detail}", file=sys.stderr)
|
|
122
|
+
print(f"{len(result.rows)} link(s) -> {out_csv}"
|
|
123
|
+
+ (f", {len(result.qr_written)} QR code(s)" if result.qr_written else ""))
|
|
124
|
+
if errors or (args.strict and warns):
|
|
125
|
+
return 1
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__": # pragma: no cover
|
|
130
|
+
sys.exit(main())
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""UTM link building, deterministic slugs and campaign validation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
UTM_KEYS = ("utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content")
|
|
12
|
+
_SLUG_CLEAN = re.compile(r"[^a-z0-9]+")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Campaign:
|
|
17
|
+
url: str
|
|
18
|
+
source: str = ""
|
|
19
|
+
medium: str = ""
|
|
20
|
+
campaign: str = ""
|
|
21
|
+
term: str = ""
|
|
22
|
+
content: str = ""
|
|
23
|
+
name: str = ""
|
|
24
|
+
extra: Dict[str, str] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
def utm(self) -> Dict[str, str]:
|
|
27
|
+
pairs = {
|
|
28
|
+
"utm_source": self.source,
|
|
29
|
+
"utm_medium": self.medium,
|
|
30
|
+
"utm_campaign": self.campaign,
|
|
31
|
+
"utm_term": self.term,
|
|
32
|
+
"utm_content": self.content,
|
|
33
|
+
}
|
|
34
|
+
return {k: v for k, v in pairs.items() if v}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Finding:
|
|
39
|
+
severity: str # error | warn | info
|
|
40
|
+
code: str
|
|
41
|
+
where: str
|
|
42
|
+
detail: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_UTM_FIELDS = (("utm_source", "source"), ("utm_medium", "medium"), ("utm_campaign", "campaign"),
|
|
46
|
+
("utm_term", "term"), ("utm_content", "content"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalize(c: Campaign, where: str = "") -> Tuple[Campaign, List[Finding]]:
|
|
50
|
+
"""Lowercase, trim and turn inner whitespace into '-' in the five UTM values.
|
|
51
|
+
|
|
52
|
+
Returns a new Campaign plus one ``info`` finding per value that changed.
|
|
53
|
+
"""
|
|
54
|
+
where = where or c.name or c.url
|
|
55
|
+
changes, findings = {}, []
|
|
56
|
+
for key, attr in _UTM_FIELDS:
|
|
57
|
+
old = getattr(c, attr)
|
|
58
|
+
new = re.sub(r"\s+", "-", old.strip().lower())
|
|
59
|
+
if new != old:
|
|
60
|
+
changes[attr] = new
|
|
61
|
+
findings.append(Finding("info", "fixed", where, f"{key}: {old!r} -> {new!r}"))
|
|
62
|
+
return replace(c, **changes), findings
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_url(campaign: Campaign) -> str:
|
|
66
|
+
"""Merge UTM parameters into the URL, preserving existing query params.
|
|
67
|
+
|
|
68
|
+
Existing values for the five standard UTM keys are replaced by the campaign
|
|
69
|
+
(the campaign is the source of truth); every other param survives, including
|
|
70
|
+
non-standard ones such as GA4's utm_id.
|
|
71
|
+
"""
|
|
72
|
+
if not campaign.url:
|
|
73
|
+
raise ValueError("campaign url is empty")
|
|
74
|
+
parts = urlsplit(campaign.url)
|
|
75
|
+
kept = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
|
|
76
|
+
if k.lower() not in UTM_KEYS]
|
|
77
|
+
merged = kept + list(campaign.utm().items()) + list(campaign.extra.items())
|
|
78
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path,
|
|
79
|
+
urlencode(merged), parts.fragment))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def slugify(text: str, fallback: str = "link") -> str:
|
|
83
|
+
"""URL-safe, lowercase, deterministic slug."""
|
|
84
|
+
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
|
|
85
|
+
slug = _SLUG_CLEAN.sub("-", text.lower()).strip("-")
|
|
86
|
+
return slug or fallback
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def unique_slug(base: str, taken: List[str]) -> str:
|
|
90
|
+
"""first-wins with -2, -3 ... suffixes for collisions."""
|
|
91
|
+
if base not in taken:
|
|
92
|
+
return base
|
|
93
|
+
n = 2
|
|
94
|
+
while f"{base}-{n}" in taken:
|
|
95
|
+
n += 1
|
|
96
|
+
return f"{base}-{n}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_rules(path: str) -> dict:
|
|
100
|
+
"""Read a rules JSON: optional "source"/"medium" allow-lists and a "campaign_pattern" regex.
|
|
101
|
+
|
|
102
|
+
Raises ValueError with a readable message for a missing file, bad JSON or wrong shapes.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
with open(path, encoding="utf-8-sig") as fh:
|
|
106
|
+
rules = json.load(fh)
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
raise ValueError(f"rules file not found: {path}") from None
|
|
109
|
+
except json.JSONDecodeError as e:
|
|
110
|
+
raise ValueError(f"rules file {path} is not valid JSON: {e}") from None
|
|
111
|
+
if not isinstance(rules, dict):
|
|
112
|
+
raise ValueError("rules file must hold a JSON object")
|
|
113
|
+
unknown = set(rules) - {"source", "medium", "campaign_pattern"}
|
|
114
|
+
if unknown:
|
|
115
|
+
raise ValueError(f"unknown rules key(s): {', '.join(sorted(unknown))}")
|
|
116
|
+
for key in ("source", "medium"):
|
|
117
|
+
if key in rules and not (isinstance(rules[key], list)
|
|
118
|
+
and all(isinstance(v, str) for v in rules[key])):
|
|
119
|
+
raise ValueError(f"rules '{key}' must be a list of strings")
|
|
120
|
+
if "campaign_pattern" in rules:
|
|
121
|
+
if not isinstance(rules["campaign_pattern"], str):
|
|
122
|
+
raise ValueError("rules 'campaign_pattern' must be a string")
|
|
123
|
+
try:
|
|
124
|
+
re.compile(rules["campaign_pattern"])
|
|
125
|
+
except re.error as e:
|
|
126
|
+
raise ValueError(f"rules 'campaign_pattern' is not a valid regex: {e}") from None
|
|
127
|
+
return rules
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def validate_campaign(c: Campaign, where: str = "", rules: Optional[dict] = None) -> List[Finding]:
|
|
131
|
+
"""Flag the mistakes that silently wreck campaign data.
|
|
132
|
+
|
|
133
|
+
``rules`` (see load_rules) turn values outside the allow-lists into errors.
|
|
134
|
+
"""
|
|
135
|
+
rules = rules or {}
|
|
136
|
+
out: List[Finding] = []
|
|
137
|
+
where = where or c.name or c.url
|
|
138
|
+
if not c.url:
|
|
139
|
+
out.append(Finding("error", "no_url", where, "row has no url"))
|
|
140
|
+
return out
|
|
141
|
+
if not c.source:
|
|
142
|
+
out.append(Finding("error", "no_source", where, "utm_source is required"))
|
|
143
|
+
for key, value in (("utm_source", c.source), ("utm_medium", c.medium),
|
|
144
|
+
("utm_campaign", c.campaign), ("utm_term", c.term),
|
|
145
|
+
("utm_content", c.content)):
|
|
146
|
+
if value and value != value.lower():
|
|
147
|
+
out.append(Finding("warn", "not_lowercase", where,
|
|
148
|
+
f"{key}={value!r} should be lowercase (analytics tools split cases)"))
|
|
149
|
+
if " " in value:
|
|
150
|
+
out.append(Finding("warn", "has_spaces", where,
|
|
151
|
+
f"{key}={value!r} has spaces; prefer dashes"))
|
|
152
|
+
for key, value in (("source", c.source), ("medium", c.medium)):
|
|
153
|
+
if value and key in rules and value not in rules[key]:
|
|
154
|
+
out.append(Finding("error", "not_allowed", where,
|
|
155
|
+
f"utm_{key}={value!r} is not in the rules list {rules[key]}"))
|
|
156
|
+
pattern = rules.get("campaign_pattern")
|
|
157
|
+
if c.campaign and pattern and not re.search(pattern, c.campaign):
|
|
158
|
+
out.append(Finding("error", "not_allowed", where,
|
|
159
|
+
f"utm_campaign={c.campaign!r} does not match {pattern!r}"))
|
|
160
|
+
if "medium" not in rules and c.medium and c.medium not in (
|
|
161
|
+
"email", "social", "cpc", "ppc", "affiliate",
|
|
162
|
+
"organic", "referral", "display", "qr", "print"):
|
|
163
|
+
out.append(Finding("warn", "unusual_medium", where,
|
|
164
|
+
f"utm_medium={c.medium!r} is not a common value "
|
|
165
|
+
"(email/social/cpc/...): fine if intentional"))
|
|
166
|
+
if c.url and not c.url.lower().startswith(("http://", "https://")):
|
|
167
|
+
out.append(Finding("warn", "no_scheme", where, f"url {c.url!r} has no http(s) scheme"))
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def campaign_from_url(url: str) -> Campaign:
|
|
172
|
+
"""Split a tagged URL back into a Campaign (the inverse of build_url).
|
|
173
|
+
|
|
174
|
+
The five standard UTM keys become fields; every other param stays on ``url``.
|
|
175
|
+
"""
|
|
176
|
+
parts = urlsplit(url)
|
|
177
|
+
pairs = parse_qsl(parts.query, keep_blank_values=True)
|
|
178
|
+
utm = {k.lower(): v for k, v in pairs if k.lower() in UTM_KEYS}
|
|
179
|
+
rest = [(k, v) for k, v in pairs if k.lower() not in UTM_KEYS]
|
|
180
|
+
base = urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(rest), parts.fragment))
|
|
181
|
+
return Campaign(url=base, source=utm.get("utm_source", ""), medium=utm.get("utm_medium", ""),
|
|
182
|
+
campaign=utm.get("utm_campaign", ""), term=utm.get("utm_term", ""),
|
|
183
|
+
content=utm.get("utm_content", ""))
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""QR generation through an optional backend.
|
|
2
|
+
|
|
3
|
+
The core kit never depends on ``qrcode``; QR is enabled when it's installed
|
|
4
|
+
(``pip install utmkit[qr]``) or when a custom backend is injected.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class QrUnavailable(RuntimeError):
|
|
12
|
+
"""No QR backend present."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
Backend = Callable[[str, str], None] # (data, output_path) -> None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def default_backend() -> Optional[Backend]:
|
|
19
|
+
try:
|
|
20
|
+
import qrcode # type: ignore
|
|
21
|
+
except ImportError:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
def render(data: str, out_path: str) -> None:
|
|
25
|
+
if out_path.lower().endswith(".svg"): # vector, print-ready; needs no Pillow
|
|
26
|
+
from qrcode.image.svg import SvgPathImage # type: ignore
|
|
27
|
+
img = qrcode.make(data, image_factory=SvgPathImage)
|
|
28
|
+
else:
|
|
29
|
+
img = qrcode.make(data) # PNG via Pillow (the [qr] extra)
|
|
30
|
+
img.save(out_path)
|
|
31
|
+
|
|
32
|
+
return render
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_backend(injected: Optional[Backend] = None) -> Backend:
|
|
36
|
+
backend = injected or default_backend()
|
|
37
|
+
if backend is None:
|
|
38
|
+
raise QrUnavailable(
|
|
39
|
+
"QR support needs the 'qrcode' package: pip install 'utmkit[qr]' "
|
|
40
|
+
"(or omit --qr)"
|
|
41
|
+
)
|
|
42
|
+
return backend
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: utmkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Batch UTM campaign links with consistent slugs, validation, QR codes and a static index page
|
|
5
|
+
Author: calasdan-sketch
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: utm,campaign,tracking,qr,marketing,csv,links
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: qr
|
|
15
|
+
Requires-Dist: qrcode[pil]>=7; extra == "qr"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# utmkit
|
|
19
|
+
|
|
20
|
+
**Campaign links, done consistently.** Batch-build UTM-tagged URLs from a CSV spec, with deterministic file slugs, validation that catches the mistakes that silently wreck analytics, optional QR codes, and a static index page you can host or print.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install utmkit # links, slugs, validation
|
|
26
|
+
pip install 'utmkit[qr]' # + QR code generation
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## One link
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
utmkit gen https://calasautomations.com/onfile \
|
|
33
|
+
--source instagram --medium social --campaign launch26
|
|
34
|
+
# https://calasautomations.com/onfile?utm_source=instagram&utm_medium=social&utm_campaign=launch26
|
|
35
|
+
|
|
36
|
+
utmkit gen ... --qr launch26-ig.png # QR for the same URL (needs [qr] extra)
|
|
37
|
+
utmkit gen ... --qr launch26-ig.svg # vector SVG for print: needs only the qrcode package, no Pillow
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Batch
|
|
41
|
+
|
|
42
|
+
`spec.csv`:
|
|
43
|
+
|
|
44
|
+
```csv
|
|
45
|
+
url,source,medium,campaign,name
|
|
46
|
+
https://calasautomations.com/onfile,instagram,social,launch26,Spring Ad
|
|
47
|
+
https://calasautomations.com/quote,newsletter,email,launch26,Quote Nudge
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
utmkit batch spec.csv --out-dir out/ --qr --html
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Produces `out/links.csv` (slug, final URL, QR filename), one QR code per row when enabled (PNG, or vector SVG with `--qr-format svg`), and `out/index.html` — a table of every link with clickable URLs and QR images.
|
|
55
|
+
|
|
56
|
+
## Validation
|
|
57
|
+
|
|
58
|
+
`utmkit validate spec.csv` flags the classics without building anything:
|
|
59
|
+
|
|
60
|
+
- **error** — missing `utm_source`, empty URL
|
|
61
|
+
- **warn** (`batch` only) — `duplicate_url`: two rows build the exact same final URL, so their clicks can't be told apart in analytics; the warning names both rows
|
|
62
|
+
- **warn** — uppercase values (analytics tools split `Instagram` from `instagram`), spaces in values, unusual `utm_medium` values, URLs without a scheme
|
|
63
|
+
|
|
64
|
+
`--fix` on `gen` and `batch` cleans the five UTM values before validation: lowercase, trim, and inner spaces to `-` (`" Instagram Ads "` becomes `instagram-ads`). Each change is reported as an `info` finding naming the old and new value. Without `--fix`, nothing is changed.
|
|
65
|
+
|
|
66
|
+
Warnings don't fail the build unless `--strict`. In `batch`, a row with an error is reported and skipped (no link built) and the run exits 1.
|
|
67
|
+
|
|
68
|
+
## Audit an existing link
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
utmkit parse "https://x.com/?utm_source=IG&utm_medium=social"
|
|
72
|
+
# url: https://x.com/
|
|
73
|
+
# utm_source: IG
|
|
74
|
+
# utm_medium: social
|
|
75
|
+
# WARN not_lowercase: utm_source='IG' should be lowercase (analytics tools split cases)
|
|
76
|
+
# WARN no_campaign: utm_campaign is missing
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Splits a tagged URL someone else made into its base URL and UTM values, then runs the same checks as `validate` (plus `--rules` if given). `parse` also warns when `utm_campaign` is missing (`gen`/`batch` don't). Exit 1 on an error such as a missing `utm_source`; `--json` for automation.
|
|
80
|
+
|
|
81
|
+
## Naming rules (allow-list)
|
|
82
|
+
|
|
83
|
+
Lock the values your team may use, so nobody invents `Insta` next to `instagram`:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{"source": ["instagram", "facebook"], "medium": ["social", "email", "cpc", "qr"], "campaign_pattern": "^[a-z0-9-]+$"}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
utmkit validate spec.csv --rules rules.json # also on gen and batch
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
All three keys are optional. A `source` or `medium` outside its list, or a `campaign` that doesn't match the regex, is an **error** (`not_allowed`): the row is skipped in `batch` and the run exits 1. When you give a `medium` list, it replaces the built-in "unusual medium" warning. A missing or malformed rules file (bad JSON, a list that isn't a list of strings, an invalid regex, an unknown key) exits 2 with a message. Use `--fix` together with `--rules` to lowercase values before they are checked.
|
|
94
|
+
|
|
95
|
+
utmkit builds links; it doesn't track clicks. Pair it with your analytics tool.
|
|
96
|
+
|
|
97
|
+
## Design notes
|
|
98
|
+
|
|
99
|
+
- **Deterministic slugs**: the `name` column (else the final tagged URL) becomes the filename slug; collisions get `-2`, `-3` suffixes — same input, same output names, every run.
|
|
100
|
+
- **Existing params survive**: `?id=7&ref=nav` stays intact; stale values for the five standard keys (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`) are replaced by the campaign (the campaign is the source of truth); other params such as `utm_id` are kept.
|
|
101
|
+
- **QR is optional**: the core has zero dependencies; `qrcode` is only imported when you pass `--qr` and installs via the `[qr]` extra. PNG needs Pillow (the extra pulls it in); SVG output uses qrcode's own SVG writer and works without Pillow. Custom backends can be injected through the Python API.
|
|
102
|
+
- **Static `index.html`**: host it, print it, or hand it to whoever is running the campaign — no JS, no build step.
|
|
103
|
+
|
|
104
|
+
## Python API
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from utmkit import Campaign, build_url, slugify
|
|
108
|
+
|
|
109
|
+
url = build_url(Campaign("https://ex.com", source="ig", medium="social", campaign="c1"))
|
|
110
|
+
assert slugify("Spring Sale — 20% OFF!") == "spring-sale-20-off"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT-style: use it, ship it, no warranty.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/utmkit/__init__.py
|
|
5
|
+
src/utmkit/batch.py
|
|
6
|
+
src/utmkit/cli.py
|
|
7
|
+
src/utmkit/core.py
|
|
8
|
+
src/utmkit/qr.py
|
|
9
|
+
src/utmkit.egg-info/PKG-INFO
|
|
10
|
+
src/utmkit.egg-info/SOURCES.txt
|
|
11
|
+
src/utmkit.egg-info/dependency_links.txt
|
|
12
|
+
src/utmkit.egg-info/entry_points.txt
|
|
13
|
+
src/utmkit.egg-info/requires.txt
|
|
14
|
+
src/utmkit.egg-info/top_level.txt
|
|
15
|
+
tests/test_batch.py
|
|
16
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
utmkit
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Batch, QR-backend and CLI tests."""
|
|
2
|
+
import csv
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from utmkit.batch import load_campaigns, run_batch, write_csv
|
|
6
|
+
from utmkit.cli import main
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def write_spec(tmp, rows):
|
|
10
|
+
p = tmp / "spec.csv"
|
|
11
|
+
with open(p, "w", newline="", encoding="utf-8") as fh:
|
|
12
|
+
w = csv.writer(fh)
|
|
13
|
+
w.writerow(["url", "source", "medium", "campaign", "name"])
|
|
14
|
+
for r in rows:
|
|
15
|
+
w.writerow(r)
|
|
16
|
+
return str(p)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_load_and_batch(tmp_path):
|
|
20
|
+
spec = write_spec(tmp_path, [
|
|
21
|
+
["https://ex.com/a", "instagram", "social", "launch", "Spring Ad"],
|
|
22
|
+
["https://ex.com/b", "newsletter", "email", "launch", "Spring Ad"],
|
|
23
|
+
])
|
|
24
|
+
campaigns = load_campaigns(spec)
|
|
25
|
+
assert len(campaigns) == 2 and campaigns[0][1].source == "instagram"
|
|
26
|
+
result = run_batch(campaigns, str(tmp_path / "out"))
|
|
27
|
+
assert len(result.rows) == 2
|
|
28
|
+
# identical names get unique slugs
|
|
29
|
+
slugs = [r["slug"] for r in result.rows]
|
|
30
|
+
assert slugs == ["spring-ad", "spring-ad-2"]
|
|
31
|
+
assert result.rows[0]["final_url"].startswith("https://ex.com/a?utm_source=instagram")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_qr_with_fake_backend(tmp_path):
|
|
35
|
+
calls = []
|
|
36
|
+
|
|
37
|
+
def fake_backend(data, path):
|
|
38
|
+
calls.append((data, path))
|
|
39
|
+
with open(path, "wb") as fh:
|
|
40
|
+
fh.write(b"\x89PNG")
|
|
41
|
+
|
|
42
|
+
spec = write_spec(tmp_path, [["https://ex.com", "ig", "social", "c", "n"]])
|
|
43
|
+
result = run_batch(load_campaigns(spec), str(tmp_path / "out"), qr=True, qr_backend=fake_backend)
|
|
44
|
+
assert len(calls) == 1 and result.rows[0]["qr"] == "n.png"
|
|
45
|
+
assert os.path.exists(os.path.join(str(tmp_path / "out"), "n.png"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_qr_unavailable_warns_not_crashes(tmp_path):
|
|
49
|
+
spec = write_spec(tmp_path, [["https://ex.com", "ig", "social", "c", "n"]])
|
|
50
|
+
result = run_batch(load_campaigns(spec), str(tmp_path / "out"), qr=True, qr_backend=None)
|
|
51
|
+
assert any(f.code == "qr_unavailable" for f in result.findings) or result.qr_written
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_html_index(tmp_path):
|
|
55
|
+
spec = write_spec(tmp_path, [["https://ex.com", "ig", "social", "c", "Home"]])
|
|
56
|
+
run_batch(load_campaigns(spec), str(tmp_path / "out"), html_index=True)
|
|
57
|
+
html = (tmp_path / "out" / "index.html").read_text(encoding="utf-8")
|
|
58
|
+
assert "utm_source=ig" in html and "<table" in html
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_write_csv(tmp_path):
|
|
62
|
+
spec = write_spec(tmp_path, [["https://ex.com", "ig", "social", "c", "x"]])
|
|
63
|
+
result = run_batch(load_campaigns(spec), str(tmp_path / "out"))
|
|
64
|
+
out = tmp_path / "out" / "links.csv"
|
|
65
|
+
write_csv(result, str(out))
|
|
66
|
+
rows = list(csv.DictReader(open(out, encoding="utf-8")))
|
|
67
|
+
assert rows[0]["slug"] == "x" and rows[0]["final_url"].startswith("https://ex.com?")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_cli_gen(capsys):
|
|
71
|
+
assert main(["gen", "https://ex.com", "--source", "ig", "--medium", "social",
|
|
72
|
+
"--campaign", "launch"]) == 0
|
|
73
|
+
assert "utm_source=ig" in capsys.readouterr().out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_cli_gen_requires_source(capsys):
|
|
77
|
+
assert main(["gen", "https://ex.com"]) == 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_cli_batch_and_validate(tmp_path, capsys):
|
|
81
|
+
spec = write_spec(tmp_path, [
|
|
82
|
+
["https://ex.com/a", "ig", "social", "launch", "One"],
|
|
83
|
+
["https://ex.com/b", "Newsletter", "email", "launch", "Two"],
|
|
84
|
+
])
|
|
85
|
+
assert main(["batch", spec, "--out-dir", str(tmp_path / "o"), "--html"]) == 0
|
|
86
|
+
assert (tmp_path / "o" / "links.csv").exists() and (tmp_path / "o" / "index.html").exists()
|
|
87
|
+
capsys.readouterr()
|
|
88
|
+
assert main(["validate", spec]) == 0 # warnings only
|
|
89
|
+
assert main(["validate", spec, "--strict"]) == 1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_cli_bad_spec(tmp_path):
|
|
93
|
+
bad = tmp_path / "bad.csv"
|
|
94
|
+
bad.write_text("nope,columns\n1,2\n", encoding="utf-8") # lacks the url column
|
|
95
|
+
assert main(["batch", str(bad)]) == 2
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_batch_skips_error_rows_instead_of_crashing(tmp_path):
|
|
99
|
+
spec = write_spec(tmp_path, [
|
|
100
|
+
["", "instagram", "social", "launch", "No URL"],
|
|
101
|
+
["https://ex.com/a", "instagram", "social", "launch", "Good"],
|
|
102
|
+
])
|
|
103
|
+
out = tmp_path / "out"
|
|
104
|
+
assert main(["batch", spec, "--out-dir", str(out)]) == 1
|
|
105
|
+
rows = list(csv.DictReader(open(out / "links.csv", encoding="utf-8")))
|
|
106
|
+
assert [r["slug"] for r in rows] == ["good"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_html_index_never_links_non_http_urls(tmp_path):
|
|
110
|
+
spec = write_spec(tmp_path, [["javascript:alert(1)", "x", "email", "c", "bad"]])
|
|
111
|
+
out = tmp_path / "out"
|
|
112
|
+
main(["batch", spec, "--out-dir", str(out), "--html"])
|
|
113
|
+
page = (out / "index.html").read_text(encoding="utf-8")
|
|
114
|
+
assert "href='javascript" not in page and "javascript:alert(1)" in page
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_fix_flag_on_gen_and_batch(tmp_path, capsys):
|
|
118
|
+
assert main(["gen", "https://ex.com", "--source", " Instagram Ads ", "--fix"]) == 0
|
|
119
|
+
out, err = capsys.readouterr()
|
|
120
|
+
assert "utm_source=instagram-ads" in out and "INFO" in err and "fixed" in err
|
|
121
|
+
assert main(["gen", "https://ex.com", "--source", " Instagram Ads "]) == 0
|
|
122
|
+
out, err = capsys.readouterr()
|
|
123
|
+
assert "Instagram+Ads" in out and "not_lowercase" in err # unchanged without --fix
|
|
124
|
+
spec = write_spec(tmp_path, [["https://ex.com/a", "Instagram Ads", "Social", "launch", "One"]])
|
|
125
|
+
assert main(["batch", spec, "--out-dir", str(tmp_path / "o"), "--fix", "--strict"]) == 0
|
|
126
|
+
rows = list(csv.DictReader(open(tmp_path / "o" / "links.csv", encoding="utf-8")))
|
|
127
|
+
assert "utm_source=instagram-ads&utm_medium=social" in rows[0]["final_url"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_duplicate_final_url_warns(tmp_path, capsys):
|
|
131
|
+
row = ["https://ex.com/a", "ig", "social", "launch", "One"]
|
|
132
|
+
spec = write_spec(tmp_path, [row, ["https://ex.com/b", "ig", "social", "launch", "Two"], row])
|
|
133
|
+
result = run_batch(load_campaigns(spec), str(tmp_path / "o"))
|
|
134
|
+
dups = [f for f in result.findings if f.code == "duplicate_url"]
|
|
135
|
+
assert len(dups) == 1 and dups[0].severity == "warn"
|
|
136
|
+
assert "row 2" in dups[0].detail and dups[0].where == "row 4"
|
|
137
|
+
assert main(["batch", spec, "--out-dir", str(tmp_path / "o")]) == 0
|
|
138
|
+
assert main(["batch", spec, "--out-dir", str(tmp_path / "o"), "--strict"]) == 1
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
RULES = {"source": ["instagram", "facebook"], "medium": ["social", "email", "cpc", "qr"],
|
|
142
|
+
"campaign_pattern": "^[a-z0-9-]+$"}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_rules_file_allow_list(tmp_path, capsys):
|
|
146
|
+
import json
|
|
147
|
+
rules = tmp_path / "rules.json"
|
|
148
|
+
rules.write_text(json.dumps(RULES), encoding="utf-8")
|
|
149
|
+
spec = write_spec(tmp_path, [["https://ex.com/a", "instagram", "Social-Media", "launch", "One"]])
|
|
150
|
+
assert main(["validate", spec, "--rules", str(rules)]) == 1
|
|
151
|
+
assert "not_allowed" in capsys.readouterr().out
|
|
152
|
+
assert main(["batch", spec, "--out-dir", str(tmp_path / "o"), "--rules", str(rules)]) == 1
|
|
153
|
+
assert main(["gen", "https://ex.com", "--source", "instagram", "--medium", "social",
|
|
154
|
+
"--campaign", "Spring Sale", "--rules", str(rules)]) == 1 # campaign pattern
|
|
155
|
+
assert main(["gen", "https://ex.com", "--source", "facebook", "--medium", "cpc",
|
|
156
|
+
"--campaign", "spring-sale", "--rules", str(rules)]) == 0
|
|
157
|
+
rules.write_text("{not json", encoding="utf-8")
|
|
158
|
+
assert main(["validate", spec, "--rules", str(rules)]) == 2
|
|
159
|
+
rules.write_text('{"medium": "social"}', encoding="utf-8") # must be a list
|
|
160
|
+
assert main(["validate", spec, "--rules", str(rules)]) == 2
|
|
161
|
+
rules.write_text('{"campaign_pattern": "("}', encoding="utf-8") # bad regex
|
|
162
|
+
assert main(["validate", spec, "--rules", str(rules)]) == 2
|
|
163
|
+
assert main(["validate", spec, "--rules", str(tmp_path / "missing.json")]) == 2
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_parse_existing_url(capsys):
|
|
167
|
+
import json
|
|
168
|
+
url = "https://x.com/?utm_source=IG&utm_medium=social"
|
|
169
|
+
assert main(["parse", url]) == 0
|
|
170
|
+
out = capsys.readouterr().out
|
|
171
|
+
assert "utm_source: IG" in out and "not_lowercase" in out and "no_campaign" in out
|
|
172
|
+
assert main(["parse", url, "--json"]) == 0
|
|
173
|
+
data = json.loads(capsys.readouterr().out)
|
|
174
|
+
assert data["url"] == "https://x.com/" and data["utm"]["utm_source"] == "IG"
|
|
175
|
+
codes = {(f["severity"], f["code"]) for f in data["findings"]}
|
|
176
|
+
assert ("warn", "not_lowercase") in codes and ("warn", "no_campaign") in codes
|
|
177
|
+
assert main(["parse", "https://x.com/?id=7"]) == 1 # no utm_source: error
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_batch_qr_format_svg_names_files_svg(tmp_path):
|
|
181
|
+
paths = []
|
|
182
|
+
|
|
183
|
+
def fake_backend(data, path):
|
|
184
|
+
paths.append(path)
|
|
185
|
+
open(path, "w").close()
|
|
186
|
+
|
|
187
|
+
spec = write_spec(tmp_path, [["https://ex.com", "ig", "social", "c", "n"]])
|
|
188
|
+
result = run_batch(load_campaigns(spec), str(tmp_path / "out"), qr=True,
|
|
189
|
+
qr_backend=fake_backend, qr_format="svg")
|
|
190
|
+
assert paths[0].endswith("n.svg") and result.rows[0]["qr"] == "n.svg"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_real_svg_qr_needs_no_pillow(tmp_path, capsys):
|
|
194
|
+
import pytest
|
|
195
|
+
pytest.importorskip("qrcode")
|
|
196
|
+
out = tmp_path / "x.svg"
|
|
197
|
+
assert main(["gen", "https://ex.com", "--source", "ig", "--qr", str(out)]) == 0
|
|
198
|
+
head = out.read_text(encoding="utf-8").lstrip()[:5]
|
|
199
|
+
assert head in ("<?xml", "<svg ") or head.startswith("<svg")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""URL building, slug and validation tests."""
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from utmkit.core import Campaign, build_url, slugify, unique_slug, validate_campaign
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_build_url_appends_utm():
|
|
8
|
+
url = build_url(Campaign("https://ex.com/landing", source="instagram", medium="social",
|
|
9
|
+
campaign="launch26"))
|
|
10
|
+
assert url == "https://ex.com/landing?utm_source=instagram&utm_medium=social&utm_campaign=launch26"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_build_url_preserves_existing_query():
|
|
14
|
+
url = build_url(Campaign("https://ex.com/p?id=7&ref=nav", source="email", campaign="wk42"))
|
|
15
|
+
assert "id=7" in url and "ref=nav" in url and "utm_source=email" in url
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_build_url_overrides_existing_utm():
|
|
19
|
+
url = build_url(Campaign("https://ex.com/?utm_source=old", source="new"))
|
|
20
|
+
assert "utm_source=new" in url and "old" not in url
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_build_url_encodes_spaces_and_fragment_kept():
|
|
24
|
+
url = build_url(Campaign("https://ex.com/a#top", source="a b", campaign="c"))
|
|
25
|
+
assert "utm_source=a+b" in url or "utm_source=a%20b" in url
|
|
26
|
+
assert url.endswith("#top")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_build_url_empty_rejected():
|
|
30
|
+
with pytest.raises(ValueError):
|
|
31
|
+
build_url(Campaign(""))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_slugify():
|
|
35
|
+
assert slugify("Spring Sale — 20% OFF!") == "spring-sale-20-off"
|
|
36
|
+
assert slugify("Ünïcode Ünicorn") == "unicode-unicorn"
|
|
37
|
+
assert slugify("***") == "link"
|
|
38
|
+
assert slugify("") == "link"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_unique_slug_suffixes():
|
|
42
|
+
taken = []
|
|
43
|
+
assert unique_slug("a", taken) == "a" or (taken.append("a") or True)
|
|
44
|
+
taken.clear()
|
|
45
|
+
first = unique_slug("a", taken)
|
|
46
|
+
taken.append(first)
|
|
47
|
+
second = unique_slug("a", taken)
|
|
48
|
+
taken.append(second)
|
|
49
|
+
third = unique_slug("a", taken)
|
|
50
|
+
assert (first, second, third) == ("a", "a-2", "a-3")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_validate_required_source():
|
|
54
|
+
findings = validate_campaign(Campaign("https://x.com"))
|
|
55
|
+
assert any(f.code == "no_source" and f.severity == "error" for f in findings)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_validate_case_and_spaces_warn():
|
|
59
|
+
findings = validate_campaign(Campaign("https://x.com", source="Instagram", medium="email",
|
|
60
|
+
campaign="Spring Sale"))
|
|
61
|
+
codes = [f.code for f in findings]
|
|
62
|
+
assert "not_lowercase" in codes and "has_spaces" in codes
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_validate_unusual_medium_and_scheme():
|
|
66
|
+
findings = validate_campaign(Campaign("ex.com", source="a", medium="teleport"))
|
|
67
|
+
codes = [f.code for f in findings]
|
|
68
|
+
assert "unusual_medium" in codes and "no_scheme" in codes
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_validate_known_mediums_quiet():
|
|
72
|
+
for medium in ("email", "social", "cpc", "qr", "print"):
|
|
73
|
+
assert validate_campaign(Campaign("https://x.com", source="a", medium=medium)) == []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_non_standard_utm_params_survive():
|
|
77
|
+
url = build_url(Campaign("https://ex.com/?utm_id=42&utm_source=old", source="new"))
|
|
78
|
+
assert "utm_id=42" in url and "utm_source=new" in url and "old" not in url
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_normalize_fixes_case_space_and_reports_changes():
|
|
82
|
+
from utmkit.core import normalize
|
|
83
|
+
c, findings = normalize(Campaign("https://x.com", source=" Instagram Ads ", medium="social"), where="gen")
|
|
84
|
+
assert c.source == "instagram-ads" and c.medium == "social"
|
|
85
|
+
assert len(findings) == 1 and findings[0].severity == "info" and findings[0].code == "fixed"
|
|
86
|
+
assert "Instagram Ads" in findings[0].detail and "instagram-ads" in findings[0].detail
|
|
87
|
+
assert validate_campaign(c) == []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_validate_with_rules():
|
|
91
|
+
rules = {"medium": ["social"], "source": ["ig"]}
|
|
92
|
+
f = validate_campaign(Campaign("https://x.com", source="fb", medium="teleport"), rules=rules)
|
|
93
|
+
codes = [(x.severity, x.code) for x in f]
|
|
94
|
+
assert codes.count(("error", "not_allowed")) == 2
|
|
95
|
+
assert ("warn", "unusual_medium") not in codes # the allow-list replaces the generic list
|