BREAKING CHANGE: Renamed from 13-gtm-audit to 13-ourdigital-gtm-manager New Features: - DataLayerInjector class for generating custom HTML tags - Support for 20+ GA4 event types (ecommerce, forms, video, engagement) - Subcommand structure: audit and inject modes - Preset tag generation (ecommerce, engagement, all) - DOM scraping option for dynamic value extraction - Generate tags from audit report findings - Korean payment method support (카카오페이, 네이버페이, 토스) CLI Changes: - `python gtm_manager.py audit --url ...` for auditing - `python gtm_manager.py inject --preset ecommerce` for tag generation - `python gtm_manager.py inject --event purchase --event add_to_cart` - `python gtm_manager.py inject --from-audit report.json` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2074 lines
79 KiB
Python
2074 lines
79 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
GTM Manager - Comprehensive Google Tag Manager management toolkit.
|
|
|
|
Features:
|
|
- Audit: GTM container validation, dataLayer analysis, form/checkout tracking
|
|
- Inject: Generate custom HTML tags for dataLayer pushes when direct code access is unavailable
|
|
- Export: Send audit results to Notion database
|
|
|
|
Usage:
|
|
# Audit mode
|
|
python gtm_manager.py audit --url "https://example.com" --journey full
|
|
|
|
# Inject mode - generate dataLayer push tags
|
|
python gtm_manager.py inject --event purchase --output tags/
|
|
|
|
# Generate tags from audit report
|
|
python gtm_manager.py inject --from-audit gtm_audit_report.json
|
|
|
|
Options:
|
|
audit Run GTM audit on a URL
|
|
inject Generate custom HTML tags for dataLayer injection
|
|
--url Target URL to audit (required for audit)
|
|
--container Expected GTM container ID (e.g., GTM-XXXXXX)
|
|
--journey Journey type: pageview, scroll, click, form, checkout, datalayer, full
|
|
--output Output file/directory path
|
|
--notion Export results to Notion database
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from datetime import datetime
|
|
from urllib.parse import urlparse, parse_qs
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
# Tag destination patterns
|
|
TAG_DESTINATIONS = {
|
|
"GA4": [
|
|
r"google-analytics\.com/g/collect",
|
|
r"analytics\.google\.com/g/collect",
|
|
],
|
|
"Universal Analytics": [
|
|
r"google-analytics\.com/collect",
|
|
r"google-analytics\.com/r/collect",
|
|
],
|
|
"Google Ads": [
|
|
r"googleads\.g\.doubleclick\.net",
|
|
r"google\.com/pagead",
|
|
r"googleadservices\.com/pagead",
|
|
],
|
|
"Meta Pixel": [
|
|
r"facebook\.com/tr",
|
|
r"connect\.facebook\.net",
|
|
],
|
|
"LinkedIn": [
|
|
r"px\.ads\.linkedin\.com",
|
|
r"snap\.licdn\.com",
|
|
],
|
|
"TikTok": [
|
|
r"analytics\.tiktok\.com",
|
|
],
|
|
"Twitter/X": [
|
|
r"ads-twitter\.com",
|
|
r"t\.co/i/adsct",
|
|
],
|
|
"Kakao": [
|
|
r"pixel\.kakao\.com",
|
|
],
|
|
"Naver": [
|
|
r"wcs\.naver\.com",
|
|
],
|
|
}
|
|
|
|
# GA4 Required Parameters by Event
|
|
GA4_EVENT_REQUIREMENTS = {
|
|
"purchase": {
|
|
"required": ["transaction_id", "value", "currency"],
|
|
"items_required": ["item_id", "item_name"],
|
|
},
|
|
"add_to_cart": {
|
|
"required": ["currency", "value"],
|
|
"items_required": ["item_id", "item_name"],
|
|
},
|
|
"begin_checkout": {
|
|
"required": ["currency", "value"],
|
|
"items_required": ["item_id", "item_name"],
|
|
},
|
|
"add_shipping_info": {
|
|
"required": ["currency", "value"],
|
|
"recommended": ["shipping_tier"],
|
|
},
|
|
"add_payment_info": {
|
|
"required": ["currency", "value"],
|
|
"recommended": ["payment_type"],
|
|
},
|
|
"view_item": {
|
|
"required": ["currency", "value"],
|
|
"items_required": ["item_id", "item_name"],
|
|
},
|
|
"view_cart": {
|
|
"required": ["currency", "value"],
|
|
"items_required": ["item_id", "item_name"],
|
|
},
|
|
"generate_lead": {
|
|
"recommended": ["currency", "value"],
|
|
},
|
|
"form_submit": {
|
|
"recommended": ["form_id", "form_name"],
|
|
},
|
|
}
|
|
|
|
# Checkout flow sequence
|
|
CHECKOUT_SEQUENCE = [
|
|
"view_cart",
|
|
"begin_checkout",
|
|
"add_shipping_info",
|
|
"add_payment_info",
|
|
"purchase",
|
|
]
|
|
|
|
|
|
class DataLayerValidator:
|
|
"""Advanced dataLayer validation and monitoring."""
|
|
|
|
def __init__(self):
|
|
self.events = []
|
|
self.issues = []
|
|
self.snapshots = []
|
|
|
|
def validate_event(self, event_data):
|
|
"""Validate a single dataLayer event against GA4 specs."""
|
|
issues = []
|
|
event_name = event_data.get("event")
|
|
|
|
if not event_name:
|
|
return issues
|
|
|
|
# Check if event has requirements
|
|
if event_name in GA4_EVENT_REQUIREMENTS:
|
|
reqs = GA4_EVENT_REQUIREMENTS[event_name]
|
|
ecommerce = event_data.get("ecommerce", {})
|
|
|
|
# Check required fields
|
|
for field in reqs.get("required", []):
|
|
if field not in ecommerce and field not in event_data:
|
|
issues.append({
|
|
"type": "missing_required",
|
|
"event": event_name,
|
|
"field": field,
|
|
"message": f"Missing required field: {field}",
|
|
})
|
|
|
|
# Check items array
|
|
items = ecommerce.get("items", [])
|
|
if reqs.get("items_required") and not items:
|
|
issues.append({
|
|
"type": "missing_items",
|
|
"event": event_name,
|
|
"message": "E-commerce event missing 'items' array",
|
|
})
|
|
|
|
# Validate items structure
|
|
for i, item in enumerate(items):
|
|
for field in reqs.get("items_required", []):
|
|
if field not in item:
|
|
issues.append({
|
|
"type": "item_missing_field",
|
|
"event": event_name,
|
|
"item_index": i,
|
|
"field": field,
|
|
"message": f"Item {i} missing required field: {field}",
|
|
})
|
|
|
|
# Check data types
|
|
if "value" in ecommerce:
|
|
if not isinstance(ecommerce["value"], (int, float)):
|
|
issues.append({
|
|
"type": "wrong_type",
|
|
"event": event_name,
|
|
"field": "value",
|
|
"message": f"'value' should be number, got {type(ecommerce['value']).__name__}",
|
|
})
|
|
|
|
# Check transaction_id uniqueness hint
|
|
if event_name == "purchase" and "transaction_id" in ecommerce:
|
|
tid = ecommerce["transaction_id"]
|
|
if not tid or tid == "" or tid == "undefined":
|
|
issues.append({
|
|
"type": "invalid_transaction_id",
|
|
"event": event_name,
|
|
"message": "transaction_id is empty or invalid",
|
|
})
|
|
|
|
return issues
|
|
|
|
def validate_sequence(self, events):
|
|
"""Validate checkout event sequence."""
|
|
issues = []
|
|
event_names = [e.get("event") for e in events if e.get("event")]
|
|
|
|
# Find checkout events in order
|
|
checkout_events = [e for e in event_names if e in CHECKOUT_SEQUENCE]
|
|
|
|
# Check sequence
|
|
last_idx = -1
|
|
for event in checkout_events:
|
|
idx = CHECKOUT_SEQUENCE.index(event)
|
|
if idx < last_idx:
|
|
issues.append({
|
|
"type": "sequence_error",
|
|
"message": f"Event '{event}' fired out of order",
|
|
})
|
|
last_idx = idx
|
|
|
|
return issues
|
|
|
|
def check_ecommerce_clear(self, events):
|
|
"""Check if ecommerce object is cleared before new pushes."""
|
|
issues = []
|
|
last_had_ecommerce = False
|
|
|
|
for i, event in enumerate(events):
|
|
has_ecommerce = "ecommerce" in event
|
|
is_clear = event.get("ecommerce") is None
|
|
|
|
if has_ecommerce and last_had_ecommerce and not is_clear:
|
|
# Previous had ecommerce, this has ecommerce, but no clear
|
|
issues.append({
|
|
"type": "missing_ecommerce_clear",
|
|
"index": i,
|
|
"event": event.get("event"),
|
|
"message": "E-commerce data should be cleared before new push",
|
|
})
|
|
|
|
if has_ecommerce and not is_clear:
|
|
last_had_ecommerce = True
|
|
elif is_clear:
|
|
last_had_ecommerce = False
|
|
|
|
return issues
|
|
|
|
|
|
class FormAnalyzer:
|
|
"""Form discovery, analysis, and interaction tracking."""
|
|
|
|
def __init__(self, page):
|
|
self.page = page
|
|
self.forms = []
|
|
self.interactions = []
|
|
self.issues = []
|
|
|
|
def discover_forms(self):
|
|
"""Find and analyze all forms on the page."""
|
|
forms_data = self.page.evaluate("""
|
|
() => {
|
|
const forms = document.querySelectorAll('form');
|
|
return Array.from(forms).map((form, idx) => {
|
|
const fields = Array.from(form.querySelectorAll('input, select, textarea'));
|
|
return {
|
|
index: idx,
|
|
id: form.id || null,
|
|
name: form.name || null,
|
|
action: form.action || null,
|
|
method: form.method || 'get',
|
|
className: form.className || null,
|
|
fieldCount: fields.length,
|
|
fields: fields.map(field => ({
|
|
type: field.type || field.tagName.toLowerCase(),
|
|
name: field.name || null,
|
|
id: field.id || null,
|
|
required: field.required || false,
|
|
placeholder: field.placeholder || null,
|
|
validation: field.pattern || null,
|
|
maxLength: field.maxLength > 0 ? field.maxLength : null,
|
|
})),
|
|
hasSubmitButton: form.querySelector('button[type="submit"], input[type="submit"]') !== null,
|
|
};
|
|
});
|
|
}
|
|
""")
|
|
|
|
self.forms = forms_data
|
|
return forms_data
|
|
|
|
def analyze_form_tracking_readiness(self):
|
|
"""Check if forms are ready for GTM tracking."""
|
|
issues = []
|
|
|
|
for form in self.forms:
|
|
# Check for identifiers
|
|
if not form["id"] and not form["name"]:
|
|
issues.append({
|
|
"type": "form_no_identifier",
|
|
"form_index": form["index"],
|
|
"message": f"Form {form['index']} has no id or name attribute",
|
|
"recommendation": "Add id or name attribute for reliable form tracking",
|
|
})
|
|
|
|
# Check fields for tracking
|
|
for field in form["fields"]:
|
|
if field["type"] in ["text", "email", "tel"] and not field["name"] and not field["id"]:
|
|
issues.append({
|
|
"type": "field_no_identifier",
|
|
"form_index": form["index"],
|
|
"field_type": field["type"],
|
|
"message": "Input field missing name/id for tracking",
|
|
})
|
|
|
|
# Check for submit button
|
|
if not form["hasSubmitButton"]:
|
|
issues.append({
|
|
"type": "form_no_submit",
|
|
"form_index": form["index"],
|
|
"message": "Form has no submit button - may use JS submission",
|
|
"recommendation": "Verify form submission triggers dataLayer push",
|
|
})
|
|
|
|
self.issues = issues
|
|
return issues
|
|
|
|
def simulate_form_interaction(self, form_index=0):
|
|
"""Simulate user interaction with a form."""
|
|
if form_index >= len(self.forms):
|
|
return {"error": "Form index out of range"}
|
|
|
|
form = self.forms[form_index]
|
|
interactions = []
|
|
|
|
# Find form element
|
|
form_selector = f"form:nth-of-type({form_index + 1})"
|
|
if form["id"]:
|
|
form_selector = f"#{form['id']}"
|
|
elif form["name"]:
|
|
form_selector = f"form[name='{form['name']}']"
|
|
|
|
try:
|
|
form_element = self.page.locator(form_selector)
|
|
|
|
# Interact with each field
|
|
for field in form["fields"]:
|
|
field_selector = None
|
|
if field["id"]:
|
|
field_selector = f"#{field['id']}"
|
|
elif field["name"]:
|
|
field_selector = f"[name='{field['name']}']"
|
|
|
|
if not field_selector:
|
|
continue
|
|
|
|
try:
|
|
field_element = self.page.locator(field_selector).first
|
|
|
|
# Focus event
|
|
field_element.focus()
|
|
interactions.append({
|
|
"action": "focus",
|
|
"field": field["name"] or field["id"],
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
self.page.wait_for_timeout(200)
|
|
|
|
# Fill based on type
|
|
test_values = {
|
|
"text": "Test User",
|
|
"email": "test@example.com",
|
|
"tel": "010-1234-5678",
|
|
"number": "100",
|
|
"password": "TestPass123!",
|
|
}
|
|
|
|
if field["type"] in test_values:
|
|
field_element.fill(test_values[field["type"]])
|
|
interactions.append({
|
|
"action": "input",
|
|
"field": field["name"] or field["id"],
|
|
"type": field["type"],
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
self.page.wait_for_timeout(200)
|
|
|
|
# Blur event
|
|
field_element.blur()
|
|
interactions.append({
|
|
"action": "blur",
|
|
"field": field["name"] or field["id"],
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
|
|
except Exception as e:
|
|
interactions.append({
|
|
"action": "error",
|
|
"field": field["name"] or field["id"],
|
|
"error": str(e),
|
|
})
|
|
|
|
self.interactions = interactions
|
|
return interactions
|
|
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
def check_form_events(self, datalayer_events):
|
|
"""Check if expected form events are in dataLayer."""
|
|
expected_events = ["form_start", "form_submit", "generate_lead"]
|
|
found_events = []
|
|
missing_events = []
|
|
|
|
event_names = [e.get("event") for e in datalayer_events]
|
|
|
|
for expected in expected_events:
|
|
if expected in event_names:
|
|
found_events.append(expected)
|
|
else:
|
|
missing_events.append(expected)
|
|
|
|
return {
|
|
"found": found_events,
|
|
"missing": missing_events,
|
|
"recommendation": "Consider implementing: " + ", ".join(missing_events) if missing_events else None,
|
|
}
|
|
|
|
|
|
class CheckoutFlowAnalyzer:
|
|
"""E-commerce checkout flow simulation and validation."""
|
|
|
|
def __init__(self, page):
|
|
self.page = page
|
|
self.steps_completed = []
|
|
self.events_captured = []
|
|
self.issues = []
|
|
|
|
def detect_checkout_elements(self):
|
|
"""Find checkout-related elements on page."""
|
|
elements = self.page.evaluate("""
|
|
() => {
|
|
const selectors = {
|
|
cart: [
|
|
'[class*="cart"]', '[id*="cart"]',
|
|
'[class*="basket"]', '[id*="basket"]',
|
|
],
|
|
checkout: [
|
|
'[class*="checkout"]', '[id*="checkout"]',
|
|
'button:has-text("Checkout")', 'a:has-text("Checkout")',
|
|
'button:has-text("결제")', 'a:has-text("결제")',
|
|
],
|
|
addToCart: [
|
|
'button:has-text("Add to Cart")', 'button:has-text("Add to Bag")',
|
|
'button:has-text("장바구니")', 'button:has-text("담기")',
|
|
'[class*="add-to-cart"]', '[id*="add-to-cart"]',
|
|
],
|
|
quantity: [
|
|
'[class*="quantity"]', '[name*="quantity"]',
|
|
'[class*="qty"]', '[name*="qty"]',
|
|
],
|
|
removeItem: [
|
|
'[class*="remove"]', 'button:has-text("Remove")',
|
|
'button:has-text("삭제")', '[class*="delete"]',
|
|
],
|
|
promoCode: [
|
|
'[name*="promo"]', '[name*="coupon"]', '[id*="coupon"]',
|
|
'[placeholder*="promo"]', '[placeholder*="coupon"]',
|
|
],
|
|
};
|
|
|
|
const found = {};
|
|
for (const [type, selectorList] of Object.entries(selectors)) {
|
|
found[type] = [];
|
|
for (const sel of selectorList) {
|
|
try {
|
|
const elements = document.querySelectorAll(sel);
|
|
elements.forEach(el => {
|
|
found[type].push({
|
|
selector: sel,
|
|
tag: el.tagName.toLowerCase(),
|
|
text: el.textContent?.slice(0, 50) || null,
|
|
visible: el.offsetParent !== null,
|
|
});
|
|
});
|
|
} catch(e) {}
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
""")
|
|
return elements
|
|
|
|
def simulate_add_to_cart(self):
|
|
"""Attempt to simulate add-to-cart action."""
|
|
try:
|
|
# Try common add-to-cart selectors
|
|
selectors = [
|
|
'button:has-text("Add to Cart")',
|
|
'button:has-text("Add to Bag")',
|
|
'button:has-text("장바구니")',
|
|
'[class*="add-to-cart"]:visible',
|
|
'[id*="add-to-cart"]:visible',
|
|
]
|
|
|
|
for selector in selectors:
|
|
try:
|
|
btn = self.page.locator(selector).first
|
|
if btn.is_visible():
|
|
btn.click()
|
|
self.steps_completed.append({
|
|
"step": "add_to_cart",
|
|
"selector": selector,
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
self.page.wait_for_timeout(1500)
|
|
return True
|
|
except:
|
|
continue
|
|
|
|
return False
|
|
except Exception as e:
|
|
self.issues.append({"step": "add_to_cart", "error": str(e)})
|
|
return False
|
|
|
|
def simulate_begin_checkout(self):
|
|
"""Attempt to click checkout button."""
|
|
try:
|
|
selectors = [
|
|
'button:has-text("Checkout")',
|
|
'a:has-text("Checkout")',
|
|
'button:has-text("결제하기")',
|
|
'button:has-text("주문하기")',
|
|
'[class*="checkout-btn"]:visible',
|
|
]
|
|
|
|
for selector in selectors:
|
|
try:
|
|
btn = self.page.locator(selector).first
|
|
if btn.is_visible():
|
|
btn.click()
|
|
self.steps_completed.append({
|
|
"step": "begin_checkout",
|
|
"selector": selector,
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
self.page.wait_for_timeout(2000)
|
|
return True
|
|
except:
|
|
continue
|
|
|
|
return False
|
|
except Exception as e:
|
|
self.issues.append({"step": "begin_checkout", "error": str(e)})
|
|
return False
|
|
|
|
def validate_checkout_events(self, datalayer_events):
|
|
"""Validate checkout-related events in dataLayer."""
|
|
results = {
|
|
"events_found": [],
|
|
"events_missing": [],
|
|
"sequence_valid": True,
|
|
"issues": [],
|
|
}
|
|
|
|
event_names = [e.get("event") for e in datalayer_events]
|
|
|
|
# Check each checkout step
|
|
for step in CHECKOUT_SEQUENCE:
|
|
if step in event_names:
|
|
results["events_found"].append(step)
|
|
|
|
# Validate event parameters
|
|
for event in datalayer_events:
|
|
if event.get("event") == step:
|
|
validator = DataLayerValidator()
|
|
issues = validator.validate_event(event)
|
|
results["issues"].extend(issues)
|
|
else:
|
|
results["events_missing"].append(step)
|
|
|
|
# Check sequence
|
|
found_sequence = [e for e in event_names if e in CHECKOUT_SEQUENCE]
|
|
expected_order = [e for e in CHECKOUT_SEQUENCE if e in found_sequence]
|
|
|
|
if found_sequence != expected_order:
|
|
results["sequence_valid"] = False
|
|
results["issues"].append({
|
|
"type": "sequence_error",
|
|
"message": f"Events out of order. Found: {found_sequence}, Expected: {expected_order}",
|
|
})
|
|
|
|
return results
|
|
|
|
|
|
class GTMAuditor:
|
|
"""Main GTM audit orchestrator."""
|
|
|
|
def __init__(self, url, container_id=None, timeout=30000, headless=True):
|
|
self.url = url
|
|
self.expected_container = container_id
|
|
self.timeout = timeout
|
|
self.headless = headless
|
|
self.report = {
|
|
"audit_metadata": {
|
|
"url": url,
|
|
"timestamp": datetime.now().isoformat(),
|
|
"expected_container": container_id,
|
|
},
|
|
"container_status": {},
|
|
"datalayer_analysis": {
|
|
"events": [],
|
|
"validation_issues": [],
|
|
"sequence_issues": [],
|
|
},
|
|
"form_analysis": {
|
|
"forms_found": [],
|
|
"tracking_issues": [],
|
|
"events_status": {},
|
|
},
|
|
"checkout_analysis": {
|
|
"elements_found": {},
|
|
"events_status": {},
|
|
"flow_issues": [],
|
|
},
|
|
"network_requests": [],
|
|
"tags_fired": [],
|
|
"issues": [],
|
|
"recommendations": [],
|
|
"checklist": {},
|
|
}
|
|
self.network_requests = []
|
|
self.datalayer_history = []
|
|
self.page = None
|
|
|
|
def _setup_network_monitoring(self, page):
|
|
"""Intercept and log network requests to tag destinations."""
|
|
def handle_request(request):
|
|
url = request.url
|
|
for destination, patterns in TAG_DESTINATIONS.items():
|
|
for pattern in patterns:
|
|
if re.search(pattern, url):
|
|
parsed = urlparse(url)
|
|
params = parse_qs(parsed.query)
|
|
self.network_requests.append({
|
|
"destination": destination,
|
|
"url": url[:200],
|
|
"method": request.method,
|
|
"params": {k: v[0] if len(v) == 1 else v for k, v in params.items()},
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
break
|
|
|
|
page.on("request", handle_request)
|
|
|
|
def _setup_datalayer_monitoring(self, page):
|
|
"""Inject dataLayer monitoring script."""
|
|
page.evaluate("""
|
|
() => {
|
|
window.__gtmAuditEvents = [];
|
|
const originalPush = window.dataLayer.push;
|
|
window.dataLayer.push = function() {
|
|
const result = originalPush.apply(this, arguments);
|
|
for (let i = 0; i < arguments.length; i++) {
|
|
window.__gtmAuditEvents.push({
|
|
data: JSON.parse(JSON.stringify(arguments[i])),
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
}
|
|
return result;
|
|
};
|
|
}
|
|
""")
|
|
|
|
def _capture_datalayer(self, page):
|
|
"""Capture current dataLayer state."""
|
|
try:
|
|
datalayer = page.evaluate("""
|
|
() => {
|
|
if (typeof window.dataLayer !== 'undefined') {
|
|
return JSON.parse(JSON.stringify(window.dataLayer));
|
|
}
|
|
return null;
|
|
}
|
|
""")
|
|
return datalayer
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
def _capture_monitored_events(self, page):
|
|
"""Capture events logged by our monitoring."""
|
|
try:
|
|
events = page.evaluate("""
|
|
() => window.__gtmAuditEvents || []
|
|
""")
|
|
return events
|
|
except:
|
|
return []
|
|
|
|
def _check_gtm_container(self, page):
|
|
"""Verify GTM container installation."""
|
|
result = page.evaluate("""
|
|
() => {
|
|
const scripts = document.querySelectorAll('script');
|
|
const gtmInfo = {
|
|
installed: false,
|
|
containers: [],
|
|
position: null,
|
|
noscript: false,
|
|
dataLayerInit: false,
|
|
dataLayerInitBeforeGTM: false,
|
|
};
|
|
|
|
gtmInfo.dataLayerInit = typeof window.dataLayer !== 'undefined' &&
|
|
Array.isArray(window.dataLayer);
|
|
|
|
let gtmScriptIndex = -1;
|
|
let dataLayerInitIndex = -1;
|
|
|
|
scripts.forEach((script, index) => {
|
|
const src = script.src || '';
|
|
const innerHTML = script.innerHTML || '';
|
|
|
|
// Check for dataLayer init
|
|
if (innerHTML.includes('dataLayer') && innerHTML.includes('[]')) {
|
|
dataLayerInitIndex = index;
|
|
}
|
|
|
|
const gtmMatch = src.match(/gtm\\.js\\?id=(GTM-[A-Z0-9]+)/);
|
|
if (gtmMatch) {
|
|
gtmInfo.installed = true;
|
|
gtmInfo.containers.push(gtmMatch[1]);
|
|
gtmInfo.position = script.closest('head') ? 'head' : 'body';
|
|
gtmScriptIndex = index;
|
|
}
|
|
|
|
const inlineMatch = innerHTML.match(/GTM-[A-Z0-9]+/g);
|
|
if (inlineMatch) {
|
|
gtmInfo.installed = true;
|
|
inlineMatch.forEach(id => {
|
|
if (!gtmInfo.containers.includes(id)) {
|
|
gtmInfo.containers.push(id);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
gtmInfo.dataLayerInitBeforeGTM = dataLayerInitIndex < gtmScriptIndex && dataLayerInitIndex !== -1;
|
|
|
|
const noscripts = document.querySelectorAll('noscript');
|
|
noscripts.forEach(ns => {
|
|
if (ns.innerHTML.includes('googletagmanager.com/ns.html')) {
|
|
gtmInfo.noscript = true;
|
|
}
|
|
});
|
|
|
|
return gtmInfo;
|
|
}
|
|
""")
|
|
|
|
status = {
|
|
"installed": result["installed"],
|
|
"containers": result["containers"],
|
|
"position": result["position"],
|
|
"noscript_present": result["noscript"],
|
|
"datalayer_initialized": result["dataLayerInit"],
|
|
"datalayer_init_before_gtm": result["dataLayerInitBeforeGTM"],
|
|
"issues": [],
|
|
}
|
|
|
|
if not result["installed"]:
|
|
status["issues"].append("GTM container not detected")
|
|
self.report["issues"].append({
|
|
"severity": "critical",
|
|
"type": "container_missing",
|
|
"message": "GTM container script not found on page",
|
|
})
|
|
|
|
if len(result["containers"]) > 1:
|
|
status["issues"].append(f"Multiple containers: {result['containers']}")
|
|
self.report["issues"].append({
|
|
"severity": "warning",
|
|
"type": "multiple_containers",
|
|
"message": f"Multiple GTM containers found: {', '.join(result['containers'])}",
|
|
})
|
|
|
|
if self.expected_container and self.expected_container not in result["containers"]:
|
|
self.report["issues"].append({
|
|
"severity": "error",
|
|
"type": "container_mismatch",
|
|
"message": f"Expected {self.expected_container}, found {result['containers']}",
|
|
})
|
|
|
|
if result["position"] == "body":
|
|
self.report["issues"].append({
|
|
"severity": "warning",
|
|
"type": "script_position",
|
|
"message": "GTM script in body - may delay tag firing",
|
|
})
|
|
|
|
if not result["dataLayerInitBeforeGTM"]:
|
|
self.report["issues"].append({
|
|
"severity": "warning",
|
|
"type": "datalayer_order",
|
|
"message": "dataLayer should be initialized before GTM script",
|
|
})
|
|
|
|
self.report["container_status"] = status
|
|
return status
|
|
|
|
def _simulate_scroll(self, page):
|
|
"""Simulate scroll to trigger scroll-depth tags."""
|
|
page.evaluate("""
|
|
() => {
|
|
const heights = [0.25, 0.5, 0.75, 0.9, 1.0];
|
|
const docHeight = document.documentElement.scrollHeight;
|
|
heights.forEach((pct, i) => {
|
|
setTimeout(() => {
|
|
window.scrollTo(0, docHeight * pct);
|
|
}, i * 500);
|
|
});
|
|
}
|
|
""")
|
|
page.wait_for_timeout(3000)
|
|
|
|
def _run_form_audit(self, page):
|
|
"""Execute form analysis."""
|
|
print("📝 Analyzing forms...")
|
|
|
|
form_analyzer = FormAnalyzer(page)
|
|
forms = form_analyzer.discover_forms()
|
|
tracking_issues = form_analyzer.analyze_form_tracking_readiness()
|
|
|
|
self.report["form_analysis"]["forms_found"] = forms
|
|
self.report["form_analysis"]["tracking_issues"] = tracking_issues
|
|
|
|
if forms:
|
|
print(f" Found {len(forms)} form(s)")
|
|
# Simulate interaction with first form
|
|
interactions = form_analyzer.simulate_form_interaction(0)
|
|
self.report["form_analysis"]["interactions"] = interactions
|
|
|
|
# Allow time for events
|
|
page.wait_for_timeout(2000)
|
|
|
|
# Check form events
|
|
datalayer = self._capture_datalayer(page)
|
|
if datalayer:
|
|
events_status = form_analyzer.check_form_events(datalayer)
|
|
self.report["form_analysis"]["events_status"] = events_status
|
|
else:
|
|
print(" No forms found on page")
|
|
|
|
def _run_checkout_audit(self, page):
|
|
"""Execute e-commerce checkout flow analysis."""
|
|
print("🛒 Analyzing checkout flow...")
|
|
|
|
checkout_analyzer = CheckoutFlowAnalyzer(page)
|
|
elements = checkout_analyzer.detect_checkout_elements()
|
|
|
|
self.report["checkout_analysis"]["elements_found"] = elements
|
|
|
|
# Log what we found
|
|
for element_type, found in elements.items():
|
|
if found:
|
|
print(f" Found {len(found)} {element_type} element(s)")
|
|
|
|
def _run_datalayer_audit(self, page):
|
|
"""Execute deep dataLayer analysis."""
|
|
print("📊 Analyzing dataLayer...")
|
|
|
|
datalayer = self._capture_datalayer(page)
|
|
monitored_events = self._capture_monitored_events(page)
|
|
|
|
if not datalayer:
|
|
self.report["datalayer_analysis"]["issues"] = ["dataLayer not found"]
|
|
return
|
|
|
|
validator = DataLayerValidator()
|
|
|
|
# Validate each event
|
|
for event in datalayer:
|
|
if isinstance(event, dict):
|
|
issues = validator.validate_event(event)
|
|
if issues:
|
|
self.report["datalayer_analysis"]["validation_issues"].extend(issues)
|
|
|
|
# Check sequence
|
|
sequence_issues = validator.validate_sequence(datalayer)
|
|
self.report["datalayer_analysis"]["sequence_issues"] = sequence_issues
|
|
|
|
# Check ecommerce clearing
|
|
clear_issues = validator.check_ecommerce_clear(datalayer)
|
|
self.report["datalayer_analysis"]["validation_issues"].extend(clear_issues)
|
|
|
|
# Store events
|
|
events = []
|
|
for i, item in enumerate(datalayer):
|
|
if isinstance(item, dict) and item.get("event"):
|
|
events.append({
|
|
"index": i,
|
|
"event": item.get("event"),
|
|
"has_ecommerce": "ecommerce" in item,
|
|
"params": list(item.keys()),
|
|
})
|
|
|
|
self.report["datalayer_analysis"]["events"] = events
|
|
print(f" Found {len(events)} events in dataLayer")
|
|
|
|
def _generate_recommendations(self):
|
|
"""Generate recommendations based on findings."""
|
|
recs = []
|
|
|
|
for issue in self.report["issues"]:
|
|
if issue["type"] == "container_missing":
|
|
recs.append({
|
|
"priority": "high",
|
|
"action": "Install GTM container",
|
|
"details": "Add GTM snippet to <head> section",
|
|
})
|
|
elif issue["type"] == "datalayer_order":
|
|
recs.append({
|
|
"priority": "medium",
|
|
"action": "Initialize dataLayer before GTM",
|
|
"details": "Add 'window.dataLayer = window.dataLayer || [];' before GTM",
|
|
})
|
|
|
|
# Form recommendations
|
|
if not self.report["form_analysis"]["forms_found"]:
|
|
pass # No forms to track
|
|
elif self.report["form_analysis"].get("events_status", {}).get("missing"):
|
|
missing = self.report["form_analysis"]["events_status"]["missing"]
|
|
recs.append({
|
|
"priority": "medium",
|
|
"action": "Implement form tracking events",
|
|
"details": f"Missing events: {', '.join(missing)}",
|
|
})
|
|
|
|
# DataLayer recommendations
|
|
validation_issues = self.report["datalayer_analysis"].get("validation_issues", [])
|
|
if validation_issues:
|
|
recs.append({
|
|
"priority": "high",
|
|
"action": "Fix dataLayer validation issues",
|
|
"details": f"{len(validation_issues)} issue(s) found in event structure",
|
|
})
|
|
|
|
# Tag coverage
|
|
destinations = set(r["destination"] for r in self.network_requests)
|
|
if "GA4" not in destinations:
|
|
recs.append({
|
|
"priority": "high",
|
|
"action": "Verify GA4 implementation",
|
|
"details": "No GA4 requests detected",
|
|
})
|
|
|
|
self.report["recommendations"] = recs
|
|
|
|
def _generate_checklist(self):
|
|
"""Generate audit checklist."""
|
|
self.report["checklist"] = {
|
|
"container_health": {
|
|
"gtm_installed": self.report["container_status"].get("installed", False),
|
|
"correct_container": self.expected_container in self.report["container_status"].get("containers", []) if self.expected_container else True,
|
|
"no_duplicates": len(self.report["container_status"].get("containers", [])) <= 1,
|
|
"correct_position": self.report["container_status"].get("position") == "head",
|
|
"datalayer_init_order": self.report["container_status"].get("datalayer_init_before_gtm", False),
|
|
},
|
|
"datalayer_quality": {
|
|
"initialized": self.report["container_status"].get("datalayer_initialized", False),
|
|
"events_present": len(self.report["datalayer_analysis"].get("events", [])) > 0,
|
|
"no_validation_errors": len(self.report["datalayer_analysis"].get("validation_issues", [])) == 0,
|
|
"correct_sequence": len(self.report["datalayer_analysis"].get("sequence_issues", [])) == 0,
|
|
},
|
|
"form_tracking": {
|
|
"forms_identifiable": all(
|
|
f.get("id") or f.get("name")
|
|
for f in self.report["form_analysis"].get("forms_found", [])
|
|
) if self.report["form_analysis"].get("forms_found") else True,
|
|
"form_events_present": len(
|
|
self.report["form_analysis"].get("events_status", {}).get("found", [])
|
|
) > 0 if self.report["form_analysis"].get("forms_found") else True,
|
|
},
|
|
"tag_firing": {
|
|
"ga4_active": any(r["destination"] == "GA4" for r in self.network_requests),
|
|
"requests_captured": len(self.network_requests) > 0,
|
|
},
|
|
}
|
|
|
|
def run_audit(self, journey="pageview"):
|
|
"""Execute the full audit workflow."""
|
|
print(f"🔍 Starting GTM audit for: {self.url}")
|
|
print(f" Journey type: {journey}")
|
|
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=self.headless)
|
|
context = browser.new_context(
|
|
viewport={"width": 1920, "height": 1080},
|
|
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) GTMAudit/1.0"
|
|
)
|
|
page = context.new_page()
|
|
self.page = page
|
|
|
|
self._setup_network_monitoring(page)
|
|
|
|
try:
|
|
print("📄 Loading page...")
|
|
page.goto(self.url, timeout=self.timeout, wait_until="networkidle")
|
|
page.wait_for_timeout(2000)
|
|
|
|
# Setup dataLayer monitoring after page load
|
|
try:
|
|
self._setup_datalayer_monitoring(page)
|
|
except:
|
|
pass
|
|
|
|
print("🏷️ Checking GTM container...")
|
|
self._check_gtm_container(page)
|
|
|
|
# Run journey-specific audits
|
|
if journey in ["scroll", "pageview", "full"]:
|
|
print("📜 Simulating scroll...")
|
|
self._simulate_scroll(page)
|
|
|
|
if journey in ["form", "full"]:
|
|
self._run_form_audit(page)
|
|
|
|
if journey in ["checkout", "full"]:
|
|
self._run_checkout_audit(page)
|
|
|
|
if journey in ["datalayer", "full"]:
|
|
self._run_datalayer_audit(page)
|
|
|
|
# Always do basic dataLayer capture
|
|
page.wait_for_timeout(2000)
|
|
self._run_datalayer_audit(page)
|
|
|
|
# Store network requests
|
|
self.report["network_requests"] = self.network_requests
|
|
self.report["tags_fired"] = list(set(r["destination"] for r in self.network_requests))
|
|
|
|
except Exception as e:
|
|
self.report["issues"].append({
|
|
"severity": "critical",
|
|
"type": "audit_error",
|
|
"message": str(e),
|
|
})
|
|
finally:
|
|
browser.close()
|
|
|
|
self._generate_recommendations()
|
|
self._generate_checklist()
|
|
|
|
print("✅ Audit complete!")
|
|
return self.report
|
|
|
|
def save_report(self, filepath):
|
|
"""Save report to JSON file."""
|
|
with open(filepath, "w", encoding="utf-8") as f:
|
|
json.dump(self.report, f, indent=2, ensure_ascii=False)
|
|
print(f"📝 Report saved to: {filepath}")
|
|
|
|
def print_summary(self):
|
|
"""Print audit summary to console."""
|
|
print("\n" + "="*60)
|
|
print("📋 GTM AUDIT SUMMARY")
|
|
print("="*60)
|
|
|
|
# Container
|
|
cs = self.report["container_status"]
|
|
print(f"\n🏷️ Container: {'✅ Installed' if cs.get('installed') else '❌ Not Found'}")
|
|
if cs.get("containers"):
|
|
print(f" IDs: {', '.join(cs['containers'])}")
|
|
|
|
# DataLayer
|
|
dl = self.report["datalayer_analysis"]
|
|
print(f"\n📊 DataLayer:")
|
|
print(f" Events found: {len(dl.get('events', []))}")
|
|
print(f" Validation issues: {len(dl.get('validation_issues', []))}")
|
|
|
|
# Forms
|
|
fa = self.report["form_analysis"]
|
|
if fa.get("forms_found"):
|
|
print(f"\n📝 Forms:")
|
|
print(f" Forms found: {len(fa['forms_found'])}")
|
|
print(f" Tracking issues: {len(fa.get('tracking_issues', []))}")
|
|
|
|
# Tags
|
|
print(f"\n🔥 Tags Fired: {', '.join(self.report['tags_fired']) if self.report['tags_fired'] else 'None detected'}")
|
|
|
|
# Issues
|
|
print(f"\n⚠️ Total Issues: {len(self.report['issues'])}")
|
|
for issue in self.report["issues"][:5]:
|
|
print(f" - [{issue['severity'].upper()}] {issue['message']}")
|
|
|
|
# Recommendations
|
|
print(f"\n💡 Recommendations: {len(self.report['recommendations'])}")
|
|
for rec in self.report["recommendations"][:3]:
|
|
print(f" - [{rec['priority'].upper()}] {rec['action']}")
|
|
|
|
print("\n" + "="*60)
|
|
|
|
|
|
class NotionExporter:
|
|
"""Export GTM audit reports to Notion database."""
|
|
|
|
# Default database ID - OurDigital GTM Audit Log
|
|
DEFAULT_DATABASE_ID = "2cf581e5-8a1e-8163-997f-ccb387156a20"
|
|
|
|
def __init__(self, token=None, database_id=None):
|
|
import os
|
|
self.token = token or os.environ.get("NOTION_TOKEN") or os.environ.get("NOTION_API_KEY")
|
|
self.database_id = database_id or self.DEFAULT_DATABASE_ID
|
|
self.base_url = "https://api.notion.com/v1"
|
|
|
|
if not self.token:
|
|
raise ValueError("Notion token not found. Set NOTION_TOKEN or NOTION_API_KEY environment variable.")
|
|
|
|
def _headers(self):
|
|
return {
|
|
"Authorization": f"Bearer {self.token}",
|
|
"Content-Type": "application/json",
|
|
"Notion-Version": "2022-06-28",
|
|
}
|
|
|
|
def _make_request(self, method, endpoint, data=None):
|
|
import urllib.request
|
|
import urllib.error
|
|
|
|
url = f"{self.base_url}{endpoint}"
|
|
req = urllib.request.Request(url, method=method, headers=self._headers())
|
|
|
|
if data:
|
|
req.data = json.dumps(data).encode("utf-8")
|
|
|
|
try:
|
|
with urllib.request.urlopen(req) as response:
|
|
return json.loads(response.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as e:
|
|
error_body = e.read().decode("utf-8")
|
|
raise Exception(f"Notion API error: {e.code} - {error_body}")
|
|
|
|
def _generate_audit_id(self, url, timestamp):
|
|
"""Generate unique audit ID."""
|
|
from urllib.parse import urlparse
|
|
import hashlib
|
|
|
|
domain = urlparse(url).netloc.replace("www.", "")
|
|
date_str = timestamp[:10].replace("-", "")
|
|
hash_suffix = hashlib.md5(f"{url}{timestamp}".encode()).hexdigest()[:6]
|
|
return f"GTM-{domain[:20]}-{date_str}-{hash_suffix}"
|
|
|
|
def _determine_audit_status(self, report):
|
|
"""Determine overall audit status based on issues."""
|
|
issues = report.get("issues", [])
|
|
critical_count = sum(1 for i in issues if i.get("severity") == "critical")
|
|
high_count = sum(1 for i in issues if i.get("severity") in ["critical", "error"])
|
|
|
|
if critical_count > 0:
|
|
return "Fail"
|
|
elif high_count > 0 or len(issues) > 5:
|
|
return "Warning"
|
|
return "Pass"
|
|
|
|
def _determine_gtm_status(self, container_status):
|
|
"""Determine GTM installation status."""
|
|
if not container_status.get("installed"):
|
|
return "Not Found"
|
|
if len(container_status.get("containers", [])) > 1:
|
|
return "Multiple Containers"
|
|
return "Installed"
|
|
|
|
def _generate_summary(self, report):
|
|
"""Generate text summary of audit findings."""
|
|
parts = []
|
|
|
|
# Container status
|
|
cs = report.get("container_status", {})
|
|
if cs.get("installed"):
|
|
containers = cs.get("containers", [])
|
|
parts.append(f"GTM: {', '.join(containers)}")
|
|
else:
|
|
parts.append("GTM: Not installed")
|
|
|
|
# DataLayer
|
|
dl = report.get("datalayer_analysis", {})
|
|
event_count = len(dl.get("events", []))
|
|
validation_issues = len(dl.get("validation_issues", []))
|
|
parts.append(f"Events: {event_count}")
|
|
if validation_issues:
|
|
parts.append(f"Validation issues: {validation_issues}")
|
|
|
|
# Forms
|
|
fa = report.get("form_analysis", {})
|
|
form_count = len(fa.get("forms_found", []))
|
|
if form_count:
|
|
parts.append(f"Forms: {form_count}")
|
|
|
|
# Tags
|
|
tags = report.get("tags_fired", [])
|
|
if tags:
|
|
parts.append(f"Tags: {', '.join(tags)}")
|
|
|
|
# Issues
|
|
issues = report.get("issues", [])
|
|
if issues:
|
|
parts.append(f"Total issues: {len(issues)}")
|
|
|
|
return " | ".join(parts)
|
|
|
|
def export_report(self, report, journey_type="full"):
|
|
"""Export audit report to Notion database."""
|
|
metadata = report.get("audit_metadata", {})
|
|
url = metadata.get("url", "")
|
|
timestamp = metadata.get("timestamp", datetime.now().isoformat())
|
|
|
|
audit_id = self._generate_audit_id(url, timestamp)
|
|
container_status = report.get("container_status", {})
|
|
issues = report.get("issues", [])
|
|
tags_fired = report.get("tags_fired", [])
|
|
|
|
# Count critical issues
|
|
critical_count = sum(1 for i in issues if i.get("severity") in ["critical", "error"])
|
|
|
|
# Build Notion page properties
|
|
from urllib.parse import urlparse
|
|
site_name = urlparse(url).netloc if url else "Unknown"
|
|
|
|
properties = {
|
|
"Site": {
|
|
"title": [{"text": {"content": site_name}}]
|
|
},
|
|
"Audit ID": {
|
|
"rich_text": [{"text": {"content": audit_id}}]
|
|
},
|
|
"URL": {
|
|
"url": url if url else None
|
|
},
|
|
"Audit Date": {
|
|
"date": {"start": timestamp[:10]} if timestamp else None
|
|
},
|
|
"Journey Type": {
|
|
"select": {"name": journey_type}
|
|
},
|
|
"GTM Status": {
|
|
"select": {"name": self._determine_gtm_status(container_status)}
|
|
},
|
|
"Container IDs": {
|
|
"rich_text": [{"text": {"content": ", ".join(container_status.get("containers", []))}}]
|
|
},
|
|
"Tags Fired": {
|
|
"multi_select": [{"name": tag} for tag in tags_fired if tag in ["GA4", "Google Ads", "Meta Pixel", "LinkedIn", "TikTok", "Kakao", "Naver"]]
|
|
},
|
|
"Issues Count": {
|
|
"number": len(issues)
|
|
},
|
|
"Critical Issues": {
|
|
"number": critical_count
|
|
},
|
|
"Audit Status": {
|
|
"select": {"name": self._determine_audit_status(report)}
|
|
},
|
|
"Summary": {
|
|
"rich_text": [{"text": {"content": self._generate_summary(report)[:2000]}}]
|
|
},
|
|
}
|
|
|
|
# Create page in database
|
|
page_data = {
|
|
"parent": {"database_id": self.database_id},
|
|
"properties": properties,
|
|
}
|
|
|
|
result = self._make_request("POST", "/pages", page_data)
|
|
|
|
page_url = result.get("url", "")
|
|
print(f"📤 Exported to Notion: {page_url}")
|
|
|
|
return {
|
|
"success": True,
|
|
"page_id": result.get("id"),
|
|
"page_url": page_url,
|
|
"audit_id": audit_id,
|
|
}
|
|
|
|
def add_detailed_content(self, page_id, report):
|
|
"""Add detailed content blocks to the Notion page."""
|
|
blocks = []
|
|
|
|
# Issues section
|
|
issues = report.get("issues", [])
|
|
if issues:
|
|
blocks.append({
|
|
"type": "heading_2",
|
|
"heading_2": {
|
|
"rich_text": [{"type": "text", "text": {"content": "Issues Found"}}]
|
|
}
|
|
})
|
|
|
|
for issue in issues[:10]: # Limit to 10 issues
|
|
severity = issue.get("severity", "info").upper()
|
|
message = issue.get("message", "")
|
|
blocks.append({
|
|
"type": "bulleted_list_item",
|
|
"bulleted_list_item": {
|
|
"rich_text": [{"type": "text", "text": {"content": f"[{severity}] {message}"}}]
|
|
}
|
|
})
|
|
|
|
# Recommendations section
|
|
recommendations = report.get("recommendations", [])
|
|
if recommendations:
|
|
blocks.append({
|
|
"type": "heading_2",
|
|
"heading_2": {
|
|
"rich_text": [{"type": "text", "text": {"content": "Recommendations"}}]
|
|
}
|
|
})
|
|
|
|
for rec in recommendations:
|
|
priority = rec.get("priority", "").upper()
|
|
action = rec.get("action", "")
|
|
details = rec.get("details", "")
|
|
blocks.append({
|
|
"type": "bulleted_list_item",
|
|
"bulleted_list_item": {
|
|
"rich_text": [{"type": "text", "text": {"content": f"[{priority}] {action}: {details}"}}]
|
|
}
|
|
})
|
|
|
|
# Checklist section
|
|
checklist = report.get("checklist", {})
|
|
if checklist:
|
|
blocks.append({
|
|
"type": "heading_2",
|
|
"heading_2": {
|
|
"rich_text": [{"type": "text", "text": {"content": "Checklist"}}]
|
|
}
|
|
})
|
|
|
|
for category, items in checklist.items():
|
|
blocks.append({
|
|
"type": "heading_3",
|
|
"heading_3": {
|
|
"rich_text": [{"type": "text", "text": {"content": category.replace("_", " ").title()}}]
|
|
}
|
|
})
|
|
|
|
for item, passed in items.items():
|
|
status = "✅" if passed else "❌"
|
|
item_name = item.replace("_", " ").title()
|
|
blocks.append({
|
|
"type": "bulleted_list_item",
|
|
"bulleted_list_item": {
|
|
"rich_text": [{"type": "text", "text": {"content": f"{status} {item_name}"}}]
|
|
}
|
|
})
|
|
|
|
if blocks:
|
|
self._make_request("PATCH", f"/blocks/{page_id}/children", {"children": blocks})
|
|
|
|
|
|
class DataLayerInjector:
|
|
"""Generate GTM custom HTML tags for dataLayer injection.
|
|
|
|
Use when direct code access is unavailable and you need to push
|
|
dataLayer events through GTM custom HTML tags.
|
|
"""
|
|
|
|
# Event templates with required/recommended parameters
|
|
EVENT_TEMPLATES = {
|
|
"page_view": {
|
|
"description": "Track page views with custom dimensions",
|
|
"params": {
|
|
"page_title": "document.title",
|
|
"page_location": "window.location.href",
|
|
"page_path": "window.location.pathname",
|
|
},
|
|
"custom_params": ["content_group", "author", "publish_date"],
|
|
"trigger": "All Pages",
|
|
},
|
|
"view_item": {
|
|
"description": "Track product detail page views",
|
|
"params": {
|
|
"currency": "'KRW'",
|
|
"value": "/* product price */",
|
|
},
|
|
"items_params": ["item_id", "item_name", "item_brand", "item_category", "price", "quantity"],
|
|
"trigger": "Product Detail Page",
|
|
"scrape_selectors": {
|
|
"item_name": [".product-title", ".product-name", "h1.title", "[itemprop='name']"],
|
|
"price": [".product-price", ".price", "[itemprop='price']", ".sale-price"],
|
|
"item_id": ["[data-product-id]", "[data-sku]", ".product-sku"],
|
|
"item_brand": [".brand", "[itemprop='brand']", ".product-brand"],
|
|
"item_category": [".category", ".breadcrumb", "[itemprop='category']"],
|
|
},
|
|
},
|
|
"add_to_cart": {
|
|
"description": "Track add to cart actions",
|
|
"params": {
|
|
"currency": "'KRW'",
|
|
"value": "/* cart value */",
|
|
},
|
|
"items_params": ["item_id", "item_name", "item_brand", "item_category", "price", "quantity"],
|
|
"trigger": "Add to Cart Button Click",
|
|
"trigger_selector": "button.add-to-cart, .btn-cart, [data-action='add-to-cart']",
|
|
},
|
|
"remove_from_cart": {
|
|
"description": "Track remove from cart actions",
|
|
"params": {
|
|
"currency": "'KRW'",
|
|
"value": "/* removed item value */",
|
|
},
|
|
"items_params": ["item_id", "item_name", "price", "quantity"],
|
|
"trigger": "Remove from Cart Click",
|
|
"trigger_selector": "button.remove, .btn-remove, [data-action='remove']",
|
|
},
|
|
"view_cart": {
|
|
"description": "Track cart page views",
|
|
"params": {
|
|
"currency": "'KRW'",
|
|
"value": "/* total cart value */",
|
|
},
|
|
"items_params": ["item_id", "item_name", "price", "quantity"],
|
|
"trigger": "Cart Page",
|
|
},
|
|
"begin_checkout": {
|
|
"description": "Track checkout initiation",
|
|
"params": {
|
|
"currency": "'KRW'",
|
|
"value": "/* checkout value */",
|
|
"coupon": "/* coupon code if any */",
|
|
},
|
|
"items_params": ["item_id", "item_name", "price", "quantity"],
|
|
"trigger": "Checkout Page",
|
|
},
|
|
"add_shipping_info": {
|
|
"description": "Track shipping info submission",
|
|
"params": {
|
|
"currency": "'KRW'",
|
|
"value": "/* order value */",
|
|
"shipping_tier": "/* shipping method */",
|
|
},
|
|
"items_params": ["item_id", "item_name", "price", "quantity"],
|
|
"trigger": "Shipping Form Submit",
|
|
},
|
|
"add_payment_info": {
|
|
"description": "Track payment info submission",
|
|
"params": {
|
|
"currency": "'KRW'",
|
|
"value": "/* order value */",
|
|
"payment_type": "/* payment method */",
|
|
},
|
|
"items_params": ["item_id", "item_name", "price", "quantity"],
|
|
"trigger": "Payment Form Submit",
|
|
"payment_types_kr": ["신용카드", "카카오페이", "네이버페이", "토스", "무통장입금", "휴대폰결제"],
|
|
},
|
|
"purchase": {
|
|
"description": "Track completed purchases",
|
|
"params": {
|
|
"transaction_id": "/* unique order ID */",
|
|
"currency": "'KRW'",
|
|
"value": "/* total order value */",
|
|
"tax": "/* tax amount */",
|
|
"shipping": "/* shipping cost */",
|
|
"coupon": "/* coupon code if any */",
|
|
},
|
|
"items_params": ["item_id", "item_name", "item_brand", "item_category", "price", "quantity"],
|
|
"trigger": "Order Confirmation Page",
|
|
"scrape_selectors": {
|
|
"transaction_id": [".order-number", ".order-id", "[data-order-id]"],
|
|
"value": [".order-total", ".total-price", ".grand-total"],
|
|
},
|
|
},
|
|
"generate_lead": {
|
|
"description": "Track lead form submissions",
|
|
"params": {
|
|
"currency": "'KRW'",
|
|
"value": "/* lead value if applicable */",
|
|
},
|
|
"custom_params": ["form_id", "form_name", "lead_source"],
|
|
"trigger": "Lead Form Submit",
|
|
},
|
|
"form_submit": {
|
|
"description": "Track general form submissions",
|
|
"params": {},
|
|
"custom_params": ["form_id", "form_name", "form_destination"],
|
|
"trigger": "Form Submission",
|
|
},
|
|
"form_start": {
|
|
"description": "Track form interaction start",
|
|
"params": {},
|
|
"custom_params": ["form_id", "form_name"],
|
|
"trigger": "Form Field Focus",
|
|
},
|
|
"scroll": {
|
|
"description": "Track scroll depth",
|
|
"params": {
|
|
"percent_scrolled": "/* 25, 50, 75, 90, 100 */",
|
|
},
|
|
"trigger": "Scroll Depth",
|
|
},
|
|
"file_download": {
|
|
"description": "Track file downloads",
|
|
"params": {
|
|
"file_name": "/* filename */",
|
|
"file_extension": "/* pdf, xlsx, etc */",
|
|
"link_url": "/* download URL */",
|
|
},
|
|
"trigger": "File Download Click",
|
|
"trigger_selector": "a[href$='.pdf'], a[href$='.xlsx'], a[href$='.docx'], a[download]",
|
|
},
|
|
"video_start": {
|
|
"description": "Track video play start",
|
|
"params": {
|
|
"video_title": "/* video title */",
|
|
"video_provider": "/* youtube, vimeo, etc */",
|
|
"video_url": "/* video URL */",
|
|
},
|
|
"trigger": "Video Start",
|
|
},
|
|
"video_progress": {
|
|
"description": "Track video progress milestones",
|
|
"params": {
|
|
"video_title": "/* video title */",
|
|
"video_percent": "/* 25, 50, 75, 100 */",
|
|
"video_current_time": "/* seconds watched */",
|
|
},
|
|
"trigger": "Video Progress",
|
|
},
|
|
"video_complete": {
|
|
"description": "Track video completion",
|
|
"params": {
|
|
"video_title": "/* video title */",
|
|
"video_duration": "/* total duration */",
|
|
},
|
|
"trigger": "Video Complete",
|
|
},
|
|
"search": {
|
|
"description": "Track site search",
|
|
"params": {
|
|
"search_term": "/* search query */",
|
|
},
|
|
"custom_params": ["search_results_count"],
|
|
"trigger": "Search Results Page",
|
|
"scrape_selectors": {
|
|
"search_term": ["input[name='q']", "input[name='search']", ".search-input"],
|
|
},
|
|
},
|
|
"login": {
|
|
"description": "Track user login",
|
|
"params": {
|
|
"method": "/* login method */",
|
|
},
|
|
"trigger": "Login Success",
|
|
},
|
|
"sign_up": {
|
|
"description": "Track user registration",
|
|
"params": {
|
|
"method": "/* signup method */",
|
|
},
|
|
"trigger": "Signup Success",
|
|
},
|
|
"share": {
|
|
"description": "Track social sharing",
|
|
"params": {
|
|
"method": "/* share platform */",
|
|
"content_type": "/* article, product, etc */",
|
|
"item_id": "/* content ID */",
|
|
},
|
|
"trigger": "Share Button Click",
|
|
},
|
|
"outbound_click": {
|
|
"description": "Track outbound link clicks",
|
|
"params": {
|
|
"link_url": "/* destination URL */",
|
|
"link_domain": "/* destination domain */",
|
|
"outbound": "true",
|
|
},
|
|
"trigger": "Outbound Link Click",
|
|
},
|
|
}
|
|
|
|
def __init__(self, currency="KRW"):
|
|
self.currency = currency
|
|
self.generated_tags = []
|
|
|
|
def generate_tag(self, event_name, options=None):
|
|
"""Generate a custom HTML tag for a specific event."""
|
|
options = options or {}
|
|
|
|
if event_name not in self.EVENT_TEMPLATES:
|
|
return {"error": f"Unknown event: {event_name}. Available: {list(self.EVENT_TEMPLATES.keys())}"}
|
|
|
|
template = self.EVENT_TEMPLATES[event_name]
|
|
|
|
# Build the tag
|
|
tag = {
|
|
"event": event_name,
|
|
"name": f"cHTML - dataLayer - {event_name}",
|
|
"description": template["description"],
|
|
"trigger_recommendation": template.get("trigger", "Custom"),
|
|
"trigger_selector": template.get("trigger_selector"),
|
|
"html": self._generate_html(event_name, template, options),
|
|
"scrape_selectors": template.get("scrape_selectors", {}),
|
|
}
|
|
|
|
self.generated_tags.append(tag)
|
|
return tag
|
|
|
|
def _generate_html(self, event_name, template, options):
|
|
"""Generate the custom HTML tag code."""
|
|
use_scraping = options.get("use_scraping", False)
|
|
include_comments = options.get("include_comments", True)
|
|
|
|
lines = ["<script>"]
|
|
|
|
if include_comments:
|
|
lines.append(f"// GTM Custom HTML Tag: {event_name}")
|
|
lines.append(f"// {template['description']}")
|
|
lines.append("// Generated by OurDigital GTM Manager")
|
|
lines.append("")
|
|
|
|
# Check if this is an ecommerce event with items
|
|
has_items = "items_params" in template
|
|
|
|
if has_items:
|
|
lines.append("(function() {")
|
|
lines.append(" // Clear previous ecommerce data")
|
|
lines.append(" window.dataLayer.push({ ecommerce: null });")
|
|
lines.append("")
|
|
|
|
if use_scraping and template.get("scrape_selectors"):
|
|
lines.extend(self._generate_scraping_code(template, event_name))
|
|
else:
|
|
lines.extend(self._generate_manual_ecommerce_code(event_name, template, options))
|
|
|
|
lines.append("})();")
|
|
else:
|
|
# Simple event without items
|
|
lines.append("window.dataLayer.push({")
|
|
lines.append(f" 'event': '{event_name}',")
|
|
|
|
for param, default_value in template.get("params", {}).items():
|
|
if param == "currency":
|
|
lines.append(f" '{param}': '{self.currency}',")
|
|
else:
|
|
lines.append(f" '{param}': {default_value},")
|
|
|
|
# Add custom params as placeholders
|
|
for param in template.get("custom_params", []):
|
|
lines.append(f" '{param}': /* TODO: set {param} */,")
|
|
|
|
lines.append("});")
|
|
|
|
lines.append("</script>")
|
|
|
|
return "\n".join(lines)
|
|
|
|
def _generate_scraping_code(self, template, event_name):
|
|
"""Generate code that scrapes values from DOM."""
|
|
lines = []
|
|
selectors = template.get("scrape_selectors", {})
|
|
|
|
lines.append(" // Scrape product/order data from page")
|
|
lines.append(" function getText(selectors) {")
|
|
lines.append(" for (var i = 0; i < selectors.length; i++) {")
|
|
lines.append(" var el = document.querySelector(selectors[i]);")
|
|
lines.append(" if (el) return el.textContent.trim();")
|
|
lines.append(" }")
|
|
lines.append(" return '';")
|
|
lines.append(" }")
|
|
lines.append("")
|
|
lines.append(" function getPrice(selectors) {")
|
|
lines.append(" var text = getText(selectors);")
|
|
lines.append(" var num = text.replace(/[^0-9]/g, '');")
|
|
lines.append(" return num ? parseInt(num, 10) : 0;")
|
|
lines.append(" }")
|
|
lines.append("")
|
|
|
|
# Generate variable assignments
|
|
for field, sels in selectors.items():
|
|
sel_str = json.dumps(sels)
|
|
if field in ["price", "value"]:
|
|
lines.append(f" var {field} = getPrice({sel_str});")
|
|
else:
|
|
lines.append(f" var {field} = getText({sel_str});")
|
|
|
|
lines.append("")
|
|
lines.append(" window.dataLayer.push({")
|
|
lines.append(f" 'event': '{event_name}',")
|
|
lines.append(" 'ecommerce': {")
|
|
lines.append(f" 'currency': '{self.currency}',")
|
|
|
|
if "value" in selectors or "price" in selectors:
|
|
lines.append(" 'value': value || price,")
|
|
if "transaction_id" in selectors:
|
|
lines.append(" 'transaction_id': transaction_id,")
|
|
|
|
lines.append(" 'items': [{")
|
|
|
|
items_params = template.get("items_params", [])
|
|
for param in items_params:
|
|
if param in selectors:
|
|
lines.append(f" '{param}': {param},")
|
|
elif param == "quantity":
|
|
lines.append(f" '{param}': 1,")
|
|
elif param == "price":
|
|
lines.append(f" '{param}': price,")
|
|
|
|
lines.append(" }]")
|
|
lines.append(" }")
|
|
lines.append(" });")
|
|
|
|
return lines
|
|
|
|
def _generate_manual_ecommerce_code(self, event_name, template, options):
|
|
"""Generate ecommerce code with manual value placeholders."""
|
|
lines = []
|
|
|
|
lines.append(" window.dataLayer.push({")
|
|
lines.append(f" 'event': '{event_name}',")
|
|
lines.append(" 'ecommerce': {")
|
|
|
|
for param, default_value in template.get("params", {}).items():
|
|
if param == "currency":
|
|
lines.append(f" '{param}': '{self.currency}',")
|
|
else:
|
|
lines.append(f" '{param}': {default_value},")
|
|
|
|
lines.append(" 'items': [{")
|
|
|
|
for param in template.get("items_params", []):
|
|
lines.append(f" '{param}': /* TODO: set {param} */,")
|
|
|
|
lines.append(" }]")
|
|
lines.append(" }")
|
|
lines.append(" });")
|
|
|
|
return lines
|
|
|
|
def generate_from_audit(self, audit_report):
|
|
"""Generate tags based on audit findings."""
|
|
recommendations = []
|
|
|
|
# Check for missing events
|
|
datalayer = audit_report.get("datalayer_analysis", {})
|
|
existing_events = [e.get("event") for e in datalayer.get("events", [])]
|
|
|
|
# Form analysis
|
|
form_analysis = audit_report.get("form_analysis", {})
|
|
if form_analysis.get("forms_found") and "form_submit" not in existing_events:
|
|
recommendations.append({
|
|
"event": "form_submit",
|
|
"reason": f"Found {len(form_analysis['forms_found'])} form(s) but no form_submit event",
|
|
})
|
|
self.generate_tag("form_submit")
|
|
|
|
# Checkout analysis
|
|
checkout = audit_report.get("checkout_analysis", {})
|
|
elements = checkout.get("elements_found", {})
|
|
|
|
if elements.get("addToCart") and "add_to_cart" not in existing_events:
|
|
recommendations.append({
|
|
"event": "add_to_cart",
|
|
"reason": "Add to cart elements found but no add_to_cart event",
|
|
})
|
|
self.generate_tag("add_to_cart")
|
|
|
|
if elements.get("checkout") and "begin_checkout" not in existing_events:
|
|
recommendations.append({
|
|
"event": "begin_checkout",
|
|
"reason": "Checkout elements found but no begin_checkout event",
|
|
})
|
|
self.generate_tag("begin_checkout")
|
|
|
|
# Check for missing ecommerce sequence
|
|
for event in CHECKOUT_SEQUENCE:
|
|
if event not in existing_events:
|
|
if event not in [r["event"] for r in recommendations]:
|
|
recommendations.append({
|
|
"event": event,
|
|
"reason": f"Missing from checkout sequence",
|
|
})
|
|
self.generate_tag(event)
|
|
|
|
return {
|
|
"recommendations": recommendations,
|
|
"generated_tags": self.generated_tags,
|
|
}
|
|
|
|
def generate_all_ecommerce(self):
|
|
"""Generate all ecommerce-related tags."""
|
|
ecommerce_events = [
|
|
"view_item", "add_to_cart", "remove_from_cart", "view_cart",
|
|
"begin_checkout", "add_shipping_info", "add_payment_info", "purchase"
|
|
]
|
|
|
|
for event in ecommerce_events:
|
|
self.generate_tag(event)
|
|
|
|
return self.generated_tags
|
|
|
|
def generate_engagement_tags(self):
|
|
"""Generate engagement tracking tags."""
|
|
engagement_events = [
|
|
"form_submit", "form_start", "scroll", "file_download",
|
|
"video_start", "video_progress", "video_complete",
|
|
"search", "outbound_click", "share"
|
|
]
|
|
|
|
for event in engagement_events:
|
|
self.generate_tag(event)
|
|
|
|
return self.generated_tags
|
|
|
|
def save_tags(self, output_path, format="html"):
|
|
"""Save generated tags to files."""
|
|
import os
|
|
|
|
if not self.generated_tags:
|
|
return {"error": "No tags generated yet"}
|
|
|
|
os.makedirs(output_path, exist_ok=True)
|
|
saved_files = []
|
|
|
|
for tag in self.generated_tags:
|
|
event = tag["event"]
|
|
|
|
if format == "html":
|
|
filename = f"{event}_tag.html"
|
|
filepath = os.path.join(output_path, filename)
|
|
with open(filepath, "w", encoding="utf-8") as f:
|
|
f.write(f"<!-- {tag['name']} -->\n")
|
|
f.write(f"<!-- {tag['description']} -->\n")
|
|
f.write(f"<!-- Trigger: {tag['trigger_recommendation']} -->\n")
|
|
if tag.get("trigger_selector"):
|
|
f.write(f"<!-- Trigger Selector: {tag['trigger_selector']} -->\n")
|
|
f.write("\n")
|
|
f.write(tag["html"])
|
|
saved_files.append(filepath)
|
|
|
|
elif format == "json":
|
|
filename = f"{event}_tag.json"
|
|
filepath = os.path.join(output_path, filename)
|
|
with open(filepath, "w", encoding="utf-8") as f:
|
|
json.dump(tag, f, indent=2, ensure_ascii=False)
|
|
saved_files.append(filepath)
|
|
|
|
# Also save a combined file
|
|
combined_path = os.path.join(output_path, f"all_tags.{format}")
|
|
if format == "html":
|
|
with open(combined_path, "w", encoding="utf-8") as f:
|
|
f.write("<!-- All Generated GTM Custom HTML Tags -->\n")
|
|
f.write("<!-- Generated by OurDigital GTM Manager -->\n\n")
|
|
for tag in self.generated_tags:
|
|
f.write(f"\n<!-- ========== {tag['event']} ========== -->\n")
|
|
f.write(f"<!-- {tag['description']} -->\n")
|
|
f.write(f"<!-- Trigger: {tag['trigger_recommendation']} -->\n\n")
|
|
f.write(tag["html"])
|
|
f.write("\n\n")
|
|
else:
|
|
with open(combined_path, "w", encoding="utf-8") as f:
|
|
json.dump(self.generated_tags, f, indent=2, ensure_ascii=False)
|
|
|
|
saved_files.append(combined_path)
|
|
|
|
return {"saved_files": saved_files, "count": len(self.generated_tags)}
|
|
|
|
def print_tag(self, tag):
|
|
"""Print a tag to console."""
|
|
print(f"\n{'='*60}")
|
|
print(f"📦 {tag['name']}")
|
|
print(f" {tag['description']}")
|
|
print(f" Trigger: {tag['trigger_recommendation']}")
|
|
if tag.get("trigger_selector"):
|
|
print(f" Selector: {tag['trigger_selector']}")
|
|
print(f"{'='*60}")
|
|
print(tag["html"])
|
|
print()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="GTM Manager - Audit and manage Google Tag Manager implementations",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Audit a website
|
|
python gtm_manager.py audit --url https://example.com
|
|
|
|
# Generate ecommerce dataLayer tags
|
|
python gtm_manager.py inject --preset ecommerce --output ./tags
|
|
|
|
# Generate tags from audit report
|
|
python gtm_manager.py inject --from-audit gtm_audit_report.json
|
|
|
|
# Generate specific event tag
|
|
python gtm_manager.py inject --event purchase --event add_to_cart
|
|
"""
|
|
)
|
|
|
|
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
|
|
# Audit subcommand
|
|
audit_parser = subparsers.add_parser("audit", help="Audit GTM implementation on a URL")
|
|
audit_parser.add_argument("--url", required=True, help="Target URL to audit")
|
|
audit_parser.add_argument("--container", help="Expected GTM container ID (e.g., GTM-XXXXXX)")
|
|
audit_parser.add_argument("--journey", default="full",
|
|
choices=["pageview", "scroll", "click", "form", "checkout", "datalayer", "full"],
|
|
help="Journey type to simulate")
|
|
audit_parser.add_argument("--output", default="gtm_audit_report.json", help="Output file path")
|
|
audit_parser.add_argument("--timeout", type=int, default=30000, help="Page load timeout (ms)")
|
|
audit_parser.add_argument("--headless", action="store_true", default=True, help="Run headless")
|
|
audit_parser.add_argument("--notion", action="store_true", help="Export results to Notion database")
|
|
audit_parser.add_argument("--notion-database", help="Notion database ID")
|
|
audit_parser.add_argument("--notion-detailed", action="store_true", help="Add detailed content to Notion page")
|
|
audit_parser.add_argument("--generate-tags", action="store_true", help="Generate missing dataLayer tags after audit")
|
|
|
|
# Inject subcommand
|
|
inject_parser = subparsers.add_parser("inject", help="Generate dataLayer injection tags")
|
|
inject_parser.add_argument("--event", action="append", help="Event type(s) to generate (can be repeated)")
|
|
inject_parser.add_argument("--preset", choices=["ecommerce", "engagement", "all"],
|
|
help="Generate preset group of tags")
|
|
inject_parser.add_argument("--from-audit", help="Generate tags based on audit report JSON file")
|
|
inject_parser.add_argument("--output", default="./gtm_tags", help="Output directory for generated tags")
|
|
inject_parser.add_argument("--format", choices=["html", "json"], default="html", help="Output format")
|
|
inject_parser.add_argument("--currency", default="KRW", help="Currency code for ecommerce events")
|
|
inject_parser.add_argument("--scrape", action="store_true", help="Generate DOM scraping code for values")
|
|
inject_parser.add_argument("--list-events", action="store_true", help="List available event types")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.command == "audit":
|
|
run_audit(args)
|
|
elif args.command == "inject":
|
|
run_inject(args)
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
def run_audit(args):
|
|
"""Run GTM audit."""
|
|
auditor = GTMAuditor(
|
|
url=args.url,
|
|
container_id=args.container,
|
|
timeout=args.timeout,
|
|
headless=args.headless,
|
|
)
|
|
|
|
report = auditor.run_audit(journey=args.journey)
|
|
auditor.save_report(args.output)
|
|
auditor.print_summary()
|
|
|
|
# Generate missing tags if requested
|
|
if args.generate_tags:
|
|
print("\n🏷️ Generating missing dataLayer tags...")
|
|
injector = DataLayerInjector()
|
|
result = injector.generate_from_audit(report)
|
|
|
|
if result["recommendations"]:
|
|
print(f" Found {len(result['recommendations'])} missing event(s)")
|
|
for rec in result["recommendations"]:
|
|
print(f" - {rec['event']}: {rec['reason']}")
|
|
|
|
output_dir = args.output.replace(".json", "_tags")
|
|
save_result = injector.save_tags(output_dir)
|
|
print(f" Saved {save_result['count']} tags to {output_dir}/")
|
|
else:
|
|
print(" No missing events detected")
|
|
|
|
# Export to Notion if requested
|
|
if args.notion:
|
|
try:
|
|
exporter = NotionExporter(database_id=args.notion_database)
|
|
result = exporter.export_report(report, journey_type=args.journey)
|
|
|
|
if args.notion_detailed and result.get("page_id"):
|
|
exporter.add_detailed_content(result["page_id"], report)
|
|
print(" Added detailed content to Notion page")
|
|
except Exception as e:
|
|
print(f"❌ Notion export failed: {e}")
|
|
|
|
|
|
def run_inject(args):
|
|
"""Run dataLayer injection tag generation."""
|
|
injector = DataLayerInjector(currency=args.currency)
|
|
|
|
# List available events
|
|
if args.list_events:
|
|
print("\n📋 Available Event Types:")
|
|
print("=" * 60)
|
|
|
|
categories = {
|
|
"Ecommerce": ["view_item", "add_to_cart", "remove_from_cart", "view_cart",
|
|
"begin_checkout", "add_shipping_info", "add_payment_info", "purchase"],
|
|
"Forms & Leads": ["form_submit", "form_start", "generate_lead"],
|
|
"Engagement": ["scroll", "file_download", "search", "outbound_click", "share"],
|
|
"Video": ["video_start", "video_progress", "video_complete"],
|
|
"User": ["login", "sign_up"],
|
|
"Page": ["page_view"],
|
|
}
|
|
|
|
for category, events in categories.items():
|
|
print(f"\n{category}:")
|
|
for event in events:
|
|
template = DataLayerInjector.EVENT_TEMPLATES.get(event, {})
|
|
desc = template.get("description", "")
|
|
print(f" - {event}: {desc}")
|
|
|
|
return
|
|
|
|
# Generate from audit report
|
|
if args.from_audit:
|
|
try:
|
|
with open(args.from_audit, "r", encoding="utf-8") as f:
|
|
report = json.load(f)
|
|
|
|
print(f"📊 Analyzing audit report: {args.from_audit}")
|
|
result = injector.generate_from_audit(report)
|
|
|
|
if result["recommendations"]:
|
|
print(f"\n🔍 Found {len(result['recommendations'])} missing event(s):")
|
|
for rec in result["recommendations"]:
|
|
print(f" - {rec['event']}: {rec['reason']}")
|
|
else:
|
|
print(" No missing events detected in audit report")
|
|
|
|
except FileNotFoundError:
|
|
print(f"❌ Audit report not found: {args.from_audit}")
|
|
return
|
|
except json.JSONDecodeError:
|
|
print(f"❌ Invalid JSON in audit report: {args.from_audit}")
|
|
return
|
|
|
|
# Generate preset groups
|
|
elif args.preset:
|
|
print(f"📦 Generating {args.preset} preset tags...")
|
|
|
|
if args.preset == "ecommerce":
|
|
injector.generate_all_ecommerce()
|
|
elif args.preset == "engagement":
|
|
injector.generate_engagement_tags()
|
|
elif args.preset == "all":
|
|
injector.generate_all_ecommerce()
|
|
injector.generate_engagement_tags()
|
|
for event in ["page_view", "login", "sign_up"]:
|
|
injector.generate_tag(event)
|
|
|
|
# Generate specific events
|
|
elif args.event:
|
|
print(f"📦 Generating tags for: {', '.join(args.event)}")
|
|
|
|
for event in args.event:
|
|
options = {"use_scraping": args.scrape}
|
|
result = injector.generate_tag(event, options)
|
|
if "error" in result:
|
|
print(f" ❌ {result['error']}")
|
|
else:
|
|
injector.print_tag(result)
|
|
|
|
else:
|
|
print("❌ Please specify --event, --preset, or --from-audit")
|
|
print(" Use --list-events to see available event types")
|
|
return
|
|
|
|
# Save generated tags
|
|
if injector.generated_tags:
|
|
save_result = injector.save_tags(args.output, format=args.format)
|
|
print(f"\n✅ Saved {save_result['count']} tag(s) to {args.output}/")
|
|
for filepath in save_result["saved_files"][:5]:
|
|
print(f" - {filepath}")
|
|
if len(save_result["saved_files"]) > 5:
|
|
print(f" ... and {len(save_result['saved_files']) - 5} more")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|