utmkit 0.1.0__py3-none-any.whl
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/__init__.py +6 -0
- utmkit/batch.py +121 -0
- utmkit/cli.py +130 -0
- utmkit/core.py +183 -0
- utmkit/qr.py +42 -0
- utmkit-0.1.0.dist-info/METADATA +115 -0
- utmkit-0.1.0.dist-info/RECORD +11 -0
- utmkit-0.1.0.dist-info/WHEEL +5 -0
- utmkit-0.1.0.dist-info/entry_points.txt +2 -0
- utmkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- utmkit-0.1.0.dist-info/top_level.txt +1 -0
utmkit/__init__.py
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__"]
|
utmkit/batch.py
ADDED
|
@@ -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))
|
utmkit/cli.py
ADDED
|
@@ -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())
|
utmkit/core.py
ADDED
|
@@ -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", ""))
|
utmkit/qr.py
ADDED
|
@@ -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,11 @@
|
|
|
1
|
+
utmkit/__init__.py,sha256=8i3nSd_T9j0SCeonpSwFO03iwXNdodB05CMlT_Sm2Qw,274
|
|
2
|
+
utmkit/batch.py,sha256=nOFoSBY_24oX4wkXBEPiryOYO_0RQULthpW-Om8Pgqo,5088
|
|
3
|
+
utmkit/cli.py,sha256=_mopA9h_XwaX1ZyUu8cWMMbTom80UyLgubpjr4HTJyo,6388
|
|
4
|
+
utmkit/core.py,sha256=dlYbOfcouSS-z_Cd3IKeZUSX7HP-Nn3pR94K78KkJXQ,7650
|
|
5
|
+
utmkit/qr.py,sha256=Oa-VbHnFjSZ3_vk0A6sWEoElRkzjulavtV1gqsVkQDA,1304
|
|
6
|
+
utmkit-0.1.0.dist-info/licenses/LICENSE,sha256=MlKnYMK2NBctIb-rpLY0EIwVni85V8-igwwParSUi6I,1093
|
|
7
|
+
utmkit-0.1.0.dist-info/METADATA,sha256=yoBxnzsmDvyoj7nuUitBf14DKJP7ldu7SHFEpdhfwx8,5534
|
|
8
|
+
utmkit-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
utmkit-0.1.0.dist-info/entry_points.txt,sha256=EKVtx_za00bFaLS7BQHNwrrtHQwW5_sM2kuzFPdyaTo,43
|
|
10
|
+
utmkit-0.1.0.dist-info/top_level.txt,sha256=Z5zxVrsM6ak01SNUQ99N3F-yHXDS_2bdw7zy3D708-A,7
|
|
11
|
+
utmkit-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
utmkit
|