Skill Tracer
Skills › Marketing Skills › conversion-tracking › build_recipe.py

build_recipe.py

223 lines · skills/conversion-tracking/scripts/build_recipe.py at 88c79f6

1#!/usr/bin/env python3
2"""Build a single importable GTM container from a detection recipe plus a
3send template, with the user's own IDs injected.
4
5Part of the conversion-tracking skill in Marketing Skills.
6
7Examples:
8 python3 scripts/build_recipe.py --tool gravity-forms --send google-ads \
9 --conversion-id 123456789 --conversion-label AbCdEfGhIj -o import-me.json
11 python3 scripts/build_recipe.py --tool typeform --send ga4 \
12 --measurement-id G-ABC123XYZ --event-name generate_lead -o import-me.json
14The output file is imported in GTM via Admin > Import Container, choosing an
15existing workspace and MERGE (not overwrite). Uses only the Python standard
16library (3.8+). Run from anywhere; paths resolve relative to this file.
18Every ID is checked against its platform's format before use, and values are
19substituted into the parsed container (never into serialized JSON), with
20JavaScript escaping inside Custom HTML tags.
21"""
22import argparse, json, re, sys
23from pathlib import Path
25ROOT = Path(__file__).resolve().parent.parent
26DETECT_DIR = ROOT / "assets" / "gtm-recipes" / "detect"
27SEND_DIR = ROOT / "assets" / "gtm-recipes" / "send"
28EVENT_MAP = ROOT / "assets" / "gtm-recipes" / "event-map.json"
30# (pattern, human description) per kind of ID. Kept strict on purpose, so most
31# formats are safe inside any quoted or unquoted JavaScript literal. The
32# Microsoft event action allows spaces, - and . too, and it is only ever used
33# inside a quoted string in Custom HTML, where js_escape applies.
34FORMATS = {
35 "google_ads_id": (r"[0-9]{6,15}", "digits, e.g. 123456789 (AW- prefix optional)"),
36 "google_ads_label": (r"[A-Za-z0-9_-]{1,64}", "letters, digits, _ and -, e.g. AbCdEfGhIj"),
37 "ga4_id": (r"G-[A-Z0-9]{4,20}", "G- followed by letters and digits, e.g. G-ABC123XYZ"),
38 "meta_pixel": (r"[0-9]{6,20}", "digits, e.g. 1234567890123456"),
39 "tiktok_pixel": (r"[A-Z0-9]{10,30}", "uppercase letters and digits, e.g. C4ABCDEFGH1234567890"),
40 "linkedin_partner": (r"[0-9]{1,15}", "digits, e.g. 1234567"),
41 "linkedin_conversion": (r"[0-9]{1,15}", "digits, e.g. 12345678"),
42 "uet_tag": (r"[0-9]{4,15}", "digits, e.g. 187000000"),
43 "event_name": (r"[A-Za-z][A-Za-z0-9_]{0,49}", "a letter then letters, digits or _, max 50 chars"),
44 "ga4_event_name": (r"[A-Za-z][A-Za-z0-9_]{0,39}", "a letter then letters, digits or _, max 40 chars"),
45 "microsoft_event_action": (r"(?!.*__)[A-Za-z0-9](?:[A-Za-z0-9 _.-]{0,98}[A-Za-z0-9_.-])?",
46 "starts with a letter or digit; letters, digits, spaces, _, - and .; "
47 "no double underscore; max 100 chars"),
49TOKEN_RE = re.compile(r"__[A-Z0-9]+(?:_[A-Z0-9]+)*__")
52def fail(msg):
53 print(f"error: {msg}", file=sys.stderr)
54 sys.exit(1)
57def validated(value, kind, flag):
58 pattern, desc = FORMATS[kind]
59 if not re.fullmatch(pattern, value):
60 fail(f"{flag} {value!r} is not valid: expected {desc}")
61 return value
64def js_escape(value):
65 # For Custom HTML: safe inside a '...' or "..." JS string and cannot
66 # close the surrounding <script> element.
67 out = value.replace("\\", "\\\\").replace("'", "\\'").replace('"', '\\"')
68 out = out.replace("\n", "\\n").replace("\r", "\\r")
69 return out.replace("<", "\\x3c").replace(">", "\\x3e")
72def substitute(node, tokens, in_html=False):
73 """Replace tokens in every string value of a parsed GTM container."""
74 if isinstance(node, dict):
75 is_html = node.get("key") == "html"
76 return {k: substitute(v, tokens, in_html or (is_html and k == "value"))
77 for k, v in node.items()}
78 if isinstance(node, list):
79 return [substitute(v, tokens, in_html) for v in node]
80 if isinstance(node, str):
81 for token, value in tokens.items():
82 if token in node:
83 node = node.replace(token, js_escape(value) if in_html else value)
84 return node
85 return node
88def leftover_tokens(node):
89 found = set()
90 if isinstance(node, dict):
91 for v in node.values():
92 found |= leftover_tokens(v)
93 elif isinstance(node, list):
94 for v in node:
95 found |= leftover_tokens(v)
96 elif isinstance(node, str):
97 found |= set(TOKEN_RE.findall(node))
98 return found
101def main():
102 events = json.loads(EVENT_MAP.read_text())
103 sends = sorted(p.stem for p in SEND_DIR.glob("*.json"))
105 ap = argparse.ArgumentParser(description=__doc__)
106 ap.add_argument("--tool", required=True, choices=sorted(events), help="form/booking tool")
107 ap.add_argument("--send", required=True, choices=sends, help="destination template")
108 ap.add_argument("--conversion-id", help="Google Ads conversion ID (AW- prefix is stripped), or LinkedIn conversion ID")
109 ap.add_argument("--conversion-label", help="Google Ads conversion label")
110 ap.add_argument("--measurement-id", help="GA4 measurement ID, e.g. G-ABC123XYZ")
111 ap.add_argument("--pixel-id", help="Meta pixel ID or TikTok pixel ID")
112 ap.add_argument("--partner-id", help="LinkedIn partner ID (from the Insight Tag)")
113 ap.add_argument("--uet-tag-id", help="Microsoft UET tag ID")
114 ap.add_argument("--event-name", help="event name; defaults per destination "
115 "(ga4 generate_lead, meta Lead, tiktok SubmitForm, "
116 "microsoft submit_lead_form)")
117 ap.add_argument("-o", "--out", default="conversion-tracking-import.json")
118 args = ap.parse_args()
120 def require(value, flag):
121 if not value:
122 fail(f"--send {args.send} requires {flag}")
123 return value
125 def event_name(default, kind="event_name"):
126 return validated(args.event_name or default, kind, "--event-name")
128 tokens = {}
129 if args.send == "google-ads":
130 if not (args.conversion_id and args.conversion_label):
131 fail("--send google-ads requires --conversion-id and --conversion-label")
132 conversion_id = re.sub(r"^AW-", "", args.conversion_id.strip(), flags=re.I)
133 tokens["__GOOGLE_ADS_CONVERSION_ID__"] = validated(conversion_id, "google_ads_id", "--conversion-id")
134 tokens["__GOOGLE_ADS_CONVERSION_LABEL__"] = validated(
135 args.conversion_label.strip(), "google_ads_label", "--conversion-label")
136 elif args.send == "ga4":
137 require(args.measurement_id, "--measurement-id")
138 tokens["__GA4_MEASUREMENT_ID__"] = validated(
139 args.measurement_id.strip().upper(), "ga4_id", "--measurement-id")
140 tokens["__GA4_EVENT_NAME__"] = event_name("generate_lead", "ga4_event_name")
141 elif args.send == "meta":
142 require(args.pixel_id, "--pixel-id")
143 tokens["__META_PIXEL_ID__"] = validated(args.pixel_id.strip(), "meta_pixel", "--pixel-id")
144 tokens["__META_EVENT_NAME__"] = event_name("Lead")
145 elif args.send == "tiktok":
146 require(args.pixel_id, "--pixel-id")
147 tokens["__TIKTOK_PIXEL_ID__"] = validated(args.pixel_id.strip().upper(), "tiktok_pixel", "--pixel-id")
148 tokens["__TIKTOK_EVENT_NAME__"] = event_name("SubmitForm")
149 elif args.send == "linkedin":
150 require(args.partner_id, "--partner-id")
151 require(args.conversion_id, "--conversion-id")
152 tokens["__LINKEDIN_PARTNER_ID__"] = validated(
153 args.partner_id.strip(), "linkedin_partner", "--partner-id")
154 # Interpolated into JavaScript unquoted, so it must be digits.
155 tokens["__LINKEDIN_CONVERSION_ID__"] = validated(
156 args.conversion_id.strip(), "linkedin_conversion", "--conversion-id")
157 elif args.send == "microsoft":
158 require(args.uet_tag_id, "--uet-tag-id")
159 tokens["__MICROSOFT_UET_TAG_ID__"] = validated(args.uet_tag_id.strip(), "uet_tag", "--uet-tag-id")
160 if args.event_name is not None:
161 # Reject control characters anywhere before stripping, so a
162 # leading or trailing newline can't be stripped into a valid value.
163 if re.search(r"[\x00-\x1f\x7f]", args.event_name):
164 fail(f"--event-name {args.event_name!r} is not valid (control characters are not allowed)")
165 args.event_name = args.event_name.strip()
166 tokens["__MICROSOFT_EVENT_ACTION__"] = event_name("submit_lead_form", "microsoft_event_action")
168 tool = events[args.tool]
169 tokens["__RECIPE_LABEL__"] = tool["label"]
170 tokens["__DETECTION_EVENT__"] = tool["event"]
172 detect = json.loads((DETECT_DIR / f"detect-{args.tool}.json").read_text())
173 send = json.loads((SEND_DIR / f"{args.send}.json").read_text())
174 dcv, scv = detect["containerVersion"], send["containerVersion"]
176 # The detect recipe already has a custom-event trigger on the tool's
177 # event; rewire the send tags to it and drop the template's duplicate.
178 # Recipes use Tag Manager's import format (CUSTOM_EVENT). The API spelling
179 # (customEvent) is accepted too, for any older copy.
180 detect_trigger = next(t for t in dcv["trigger"] if t["type"] in ("CUSTOM_EVENT", "customEvent"))
181 send_trigger_ids = {t["triggerId"] for t in scv.get("trigger", [])}
183 next_id = 1000
184 for tag in scv.get("tag", []):
185 tag["tagId"] = str(next_id)
186 next_id += 1
187 tag["firingTriggerId"] = [
188 detect_trigger["triggerId"] if tid in send_trigger_ids else tid
189 for tid in tag.get("firingTriggerId", [])
190 ]
191 dcv["tag"].append(tag)
192 for var in scv.get("variable", []):
193 var["variableId"] = str(next_id)
194 next_id += 1
195 dcv.setdefault("variable", []).append(var)
197 dcv["container"]["name"] = f"Conversion Tracking - {tool['label']} to {args.send}"
199 detect = substitute(detect, tokens)
200 leftover = sorted(leftover_tokens(detect))
201 if leftover:
202 fail(f"unreplaced placeholder tokens remain: {leftover}")
204 Path(args.out).write_text(json.dumps(detect, indent=2) + "\n")
205 print(f"wrote {args.out}")
206 print(f" detects : {tool['label']} ({tool['moment']}) via dataLayer event '{tool['event']}'")
207 print(f" sends to: {args.send}")
208 print(" import : GTM > Admin > Import Container > choose this file > existing workspace > MERGE")
209 print(" consent : ad tags require ad_storage consent (GA4 analytics_storage); with no consent "
210 "setup they fire, with a denied default they wait (see assets/gtm-recipes/README.md)")
211 if args.send == "google-ads":
212 print(" note : if the container already has a Conversion Linker tag, delete the duplicate after import")
213 if args.send in ("meta", "tiktok", "linkedin", "microsoft"):
214 print(f" note : this bundles a base pixel tag on All Pages. If the site already has a "
215 f"{args.send} pixel, DELETE the base tag after import. Two base pixels on one page "
216 f"is a top cause of doubled conversions.")
217 if args.send == "microsoft":
218 print(" note : Microsoft counts nothing until a UET conversion goal exists. Create an "
219 f"event goal whose Action is exactly '{tokens['__MICROSOFT_EVENT_ACTION__']}' (case sensitive).")
222if __name__ == "__main__":
223 main()