choreograph-create-pixel 0.1.0
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.
- package/README.md +191 -0
- package/dist/bundle.cjs.js +302 -0
- package/dist/bundle.esm.js +300 -0
- package/dist/bundle.iife.js +2 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Choreograph Create Pixel
|
|
2
|
+
|
|
3
|
+
This library lets you apply best practises to [Choreograph Create](https://www.lemonpi.io/) pixel development and implementation.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- [x] Supports **scrape**, **view**, **basket**, and **purchase** pixels
|
|
8
|
+
- [x] Supports dynamic page changes
|
|
9
|
+
- [x] Placement-agnostic through continuous URL validation
|
|
10
|
+
- [x] Optionally enable pixels through user interaction
|
|
11
|
+
- [x] Prevents scraping browser-translated data
|
|
12
|
+
- [x] Error and console spam prevention
|
|
13
|
+
|
|
14
|
+
## Quickstart
|
|
15
|
+
|
|
16
|
+
The following theoretical snippet could be embedded on every page of _example.com_, and would automatically determine when to enable, through URL detection.
|
|
17
|
+
|
|
18
|
+
### Browser
|
|
19
|
+
|
|
20
|
+
```html
|
|
21
|
+
<script src="https://unpkg.com/choreograph-create-pixel@^1"></script>
|
|
22
|
+
<script>
|
|
23
|
+
// Scrape pixel
|
|
24
|
+
new ChoreographCreatePixel({
|
|
25
|
+
// Who is the client? (Advertiser ID)
|
|
26
|
+
who: 0,
|
|
27
|
+
|
|
28
|
+
// What type of pixel is this?
|
|
29
|
+
what: "scrape",
|
|
30
|
+
|
|
31
|
+
// Where should the pixel be enabled?
|
|
32
|
+
where: /example\.com\/products\/\d/,
|
|
33
|
+
|
|
34
|
+
// Which data should be scraped?
|
|
35
|
+
which: {
|
|
36
|
+
// Data layer example
|
|
37
|
+
sku: function () {
|
|
38
|
+
return window.dataLayer[0].product.sku;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
// DOM example
|
|
42
|
+
name: function () {
|
|
43
|
+
return document.querySelector("#product-name").innerText;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Helper example
|
|
47
|
+
url: ChoreographCreatePixel.getUrl,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// View segment pixel
|
|
52
|
+
new ChoreographCreatePixel({
|
|
53
|
+
who: 0,
|
|
54
|
+
what: "view",
|
|
55
|
+
where: /example\.com\/products\/\d/,
|
|
56
|
+
|
|
57
|
+
which: {
|
|
58
|
+
sku: function () {
|
|
59
|
+
return window.dataLayer[0].product.sku;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
</script>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### ES module
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import Pixel from "choreograph-create-pixel";
|
|
70
|
+
|
|
71
|
+
// Scrape pixel
|
|
72
|
+
new Pixel({
|
|
73
|
+
// Who is the client? (Advertiser ID)
|
|
74
|
+
who: 0,
|
|
75
|
+
|
|
76
|
+
// What type of pixel is this?
|
|
77
|
+
what: "scrape",
|
|
78
|
+
|
|
79
|
+
// Where should the pixel be enabled?
|
|
80
|
+
where: /example\.com\/products\/\d/,
|
|
81
|
+
|
|
82
|
+
// Which data should be scraped?
|
|
83
|
+
which: {
|
|
84
|
+
// Data layer example
|
|
85
|
+
sku: function () {
|
|
86
|
+
return window.dataLayer[0].product.sku;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// DOM example
|
|
90
|
+
name: function () {
|
|
91
|
+
return document.querySelector("#product-name").innerText;
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// Helper example
|
|
95
|
+
url: Pixel.getUrl,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// View segment pixel
|
|
100
|
+
new ChoreographCreatePixel({
|
|
101
|
+
who: 0,
|
|
102
|
+
what: "view",
|
|
103
|
+
where: /example\.com\/products\/\d/,
|
|
104
|
+
|
|
105
|
+
which: {
|
|
106
|
+
sku: function () {
|
|
107
|
+
return window.dataLayer[0].product.sku;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
> ES modules require manual bundling and transpiling before they can be safely used as browser scripts.
|
|
114
|
+
|
|
115
|
+
## Other scenarios
|
|
116
|
+
|
|
117
|
+
Besides **scrape** and **view** pixels, this library also supports **basket** and **purchase** segment pixels.
|
|
118
|
+
|
|
119
|
+
### Basket segment
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
{
|
|
123
|
+
who: 0,
|
|
124
|
+
what: "basket",
|
|
125
|
+
where: /example\.com/,
|
|
126
|
+
|
|
127
|
+
// (Optional) When should the pixel be enabled?
|
|
128
|
+
when: {
|
|
129
|
+
listener: "click",
|
|
130
|
+
elements: function () {
|
|
131
|
+
return document.querySelectorAll("button.basket[data-sku]");
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
which: {
|
|
136
|
+
// The "element" parameter only exists while using a "when" condition
|
|
137
|
+
sku: function (element) {
|
|
138
|
+
return element.getAttribute("data-sku");
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Purchase segment
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
{
|
|
148
|
+
who: 0,
|
|
149
|
+
what: "purchase",
|
|
150
|
+
where: /example\.com\/thank-you-for-purchasing/,
|
|
151
|
+
|
|
152
|
+
which: {
|
|
153
|
+
// sku accepts an array of strings to enable multiple segment pixels at once
|
|
154
|
+
sku: function () {
|
|
155
|
+
return [].map.call(document.querySelectorAll('.purchased-product[data-sku]'), function (element) {
|
|
156
|
+
return element.getAttribute("data-sku");
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Configuration
|
|
164
|
+
|
|
165
|
+
| Property | Type | Description | Default |
|
|
166
|
+
| ---------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
|
167
|
+
| `who` | Number | You can retrieve the advertiser ID from the URL in the [Manage platform](https://manage.lemonpi.io/) _(manage.lemonpi.io/r/AGENCY_ID/advertiser/`ADVERTISER_ID`)_. | _Required_ |
|
|
168
|
+
| `what` | String | Pixel type. One of: `"scrape"`, `"view"`, `"basket"`, or `"purchase"`. | _Required_ |
|
|
169
|
+
| `where` | RegExp | Disables pixels on page URLs that are not matched by this pattern. | _Required_ |
|
|
170
|
+
| `which` | Object | An object holding the data fields to be scraped. | _Required_ |
|
|
171
|
+
| `which.sku` | String, Array, or Function | `sku` is the only required data field, as it's the product's unique identifier. In case of segment pixels (`what` equals `"view"`, `"basket"`, or `"purchase"`), an array of strings is allowed to trigger multiple pixel executions. | _Required_ |
|
|
172
|
+
| `which.*` | String, Number, Boolean, or Function | `*` should always match with existing field names in the advertiser's product store. | `undefined` |
|
|
173
|
+
| `when` | Object | Use this property when you want to enable the pixel after a specific DOM event. | `undefined` |
|
|
174
|
+
| `when.listener` | String | DOM event type. [List of supported values.](https://www.w3schools.com/jsref/dom_obj_event.asp) | _Required_ when using `when` |
|
|
175
|
+
| `when.elements` | Function | This function should return DOM element(s) to apply the listener to. | _Required_ when using `when` |
|
|
176
|
+
| `optionalFields` | Array | An array of field names (strings) that are allowed to have empty values. | `[]` |
|
|
177
|
+
| `beforeSend` | Function | Lifecycle function that's executed just before the pixel's data is sent. | `function (data, callback) { callback(data); }` |
|
|
178
|
+
| `afterSend` | Function | Lifecycle function that's executed just after the pixel's data is sent. | `function (data) {}` |
|
|
179
|
+
| `debug` | Boolean | Enables console debugging. Also enabled by adding `?pixel_debug` or `#pixel_debug` to the page URL. | `false` |
|
|
180
|
+
|
|
181
|
+
## Static methods (helpers)
|
|
182
|
+
|
|
183
|
+
### `getUrl({ allowedParameters, customParameters, allowHash })`
|
|
184
|
+
|
|
185
|
+
Returns the bare page URL without query parameters or location hash. This is recommended to prevent scraping unwanted UTM tagging. Use the configuration to tweak its behavior.
|
|
186
|
+
|
|
187
|
+
| Property | Type | Description | Default |
|
|
188
|
+
| ------------------- | ------- | ------------------------------------------------------------------------------------- | ------- |
|
|
189
|
+
| `allowedParameters` | Array | Define query parameter keys (as string values) that are allowed in the resulting URL. | `[]` |
|
|
190
|
+
| `customParameters` | Object | Manually add custom query parameters to the resulting URL. E.g.: `{ foo: "bar"}` | `{}` |
|
|
191
|
+
| `allowHash` | Boolean | Wether or not to include the location hash (`#foo`) of the URL. | `false` |
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/*! choreograph-create-pixel v0.1.0 2022/09/15 */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const log = (
|
|
5
|
+
config,
|
|
6
|
+
message,
|
|
7
|
+
{ level = "info", key = null, data = undefined }
|
|
8
|
+
) => {
|
|
9
|
+
if (!config.debug || config.logs[key] === message) return;
|
|
10
|
+
|
|
11
|
+
const colors = {
|
|
12
|
+
error: "#f44336",
|
|
13
|
+
warn: "#ffa726",
|
|
14
|
+
success: "#66bb6a",
|
|
15
|
+
info: "#29b6f6",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const icons = { error: "⛔️", warn: "⚠️", success: "✅", info: "ℹ️" };
|
|
19
|
+
const titleCss =
|
|
20
|
+
"font-family:HelveticaNeue;font-weight:700;margin:1em 0 1.2em 0;font-size:80%;";
|
|
21
|
+
const messageCss = `font-family:HelveticaNeue;font-size:120%;margin-bottom:.5em;color:${colors[level]}`;
|
|
22
|
+
const keyCss =
|
|
23
|
+
"font-family:monospace;font-size:120%;border:1px solid;border-radius:5px;margin-bottom:1em;padding:.2em .6em";
|
|
24
|
+
|
|
25
|
+
const args = [
|
|
26
|
+
`%cCHOREOGRAPH PIXEL DEBUGGER%c\n%c${icons[level]} ${message}${
|
|
27
|
+
key ? `%c\n→ %c${key}` : "%c%c"
|
|
28
|
+
}`,
|
|
29
|
+
titleCss,
|
|
30
|
+
"",
|
|
31
|
+
messageCss,
|
|
32
|
+
"",
|
|
33
|
+
keyCss,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
if (data) args.push(data);
|
|
37
|
+
console.info(...args);
|
|
38
|
+
if (key) config.logs[key] = message;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const send = (config, data) => {
|
|
42
|
+
const eventTypes = {
|
|
43
|
+
view: "product-viewed",
|
|
44
|
+
basket: "product-basketed",
|
|
45
|
+
purchase: "product-purchased",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const fields = { ...data };
|
|
49
|
+
delete fields.sku;
|
|
50
|
+
|
|
51
|
+
const url =
|
|
52
|
+
config.what === "scrape"
|
|
53
|
+
? `https://d.lemonpi.io/scrapes${config.debug ? "?validate=true" : ""}`
|
|
54
|
+
: `https://d.lemonpi.io/a/${
|
|
55
|
+
config.who
|
|
56
|
+
}/product/event?e=${encodeURIComponent(
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
"event-type": eventTypes[config.what],
|
|
59
|
+
sku: data.sku,
|
|
60
|
+
})
|
|
61
|
+
)}`;
|
|
62
|
+
|
|
63
|
+
if (config.what !== "scrape" && !config.debug) new Image().src = url;
|
|
64
|
+
else
|
|
65
|
+
fetch(
|
|
66
|
+
url,
|
|
67
|
+
config.what === "scrape"
|
|
68
|
+
? {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
"advertiser-id": config.who,
|
|
73
|
+
sku: data.sku,
|
|
74
|
+
fields,
|
|
75
|
+
}),
|
|
76
|
+
}
|
|
77
|
+
: null
|
|
78
|
+
)
|
|
79
|
+
.then((response) => {
|
|
80
|
+
if (!config.debug) return;
|
|
81
|
+
|
|
82
|
+
response
|
|
83
|
+
.json()
|
|
84
|
+
.then((result) => {
|
|
85
|
+
if (response.ok) {
|
|
86
|
+
log(
|
|
87
|
+
config,
|
|
88
|
+
`"${config.what}" pixel successful, with warnings. Details:`,
|
|
89
|
+
{ level: "warn", data: result }
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
config.afterSend(data);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
log(config, error.message, {
|
|
96
|
+
level: "error",
|
|
97
|
+
key: "afterSend",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
} else
|
|
101
|
+
log(
|
|
102
|
+
config,
|
|
103
|
+
`"${config.what}" pixel failed: ${response.status} (${response.statusText}). Details:`,
|
|
104
|
+
{ level: "error", data: result }
|
|
105
|
+
);
|
|
106
|
+
})
|
|
107
|
+
.catch(() => {
|
|
108
|
+
if (response.ok) {
|
|
109
|
+
log(config, `"${config.what}" pixel successful!`, {
|
|
110
|
+
level: "success",
|
|
111
|
+
data,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
config.afterSend(data);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
log(config, error.message, {
|
|
118
|
+
level: "error",
|
|
119
|
+
key: "afterSend",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
} else
|
|
123
|
+
log(
|
|
124
|
+
config,
|
|
125
|
+
`"${config.what}" pixel failed: ${response.status} (${response.statusText})`,
|
|
126
|
+
{ level: "error" }
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
})
|
|
130
|
+
.catch((error) => {
|
|
131
|
+
if (config.debug)
|
|
132
|
+
log(config, `"${config.what}" pixel failed: ${error.message}`, {
|
|
133
|
+
level: "error",
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const scrape = (config, element) => {
|
|
139
|
+
const data = {};
|
|
140
|
+
let hasErrors = false;
|
|
141
|
+
|
|
142
|
+
Object.keys(config.which).forEach((fieldName) => {
|
|
143
|
+
data[fieldName] = config.which[fieldName];
|
|
144
|
+
|
|
145
|
+
if (typeof data[fieldName] === "function") {
|
|
146
|
+
try {
|
|
147
|
+
data[fieldName] = data[fieldName](element);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
log(config, error.message, {
|
|
150
|
+
level: "error",
|
|
151
|
+
key: `which.${fieldName}`,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
hasErrors = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (typeof data[fieldName] === "string")
|
|
159
|
+
data[fieldName] = data[fieldName].replace(/\s+/g, " ").trim();
|
|
160
|
+
|
|
161
|
+
if (data[fieldName] == null || data[fieldName] === "")
|
|
162
|
+
if (!config.optionalFields.includes(fieldName)) {
|
|
163
|
+
log(config, "This required field's value is empty", {
|
|
164
|
+
level: "error",
|
|
165
|
+
key: `which.${fieldName}`,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
hasErrors = true;
|
|
169
|
+
} else data[fieldName] = null;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const jsonString = JSON.stringify(data);
|
|
173
|
+
|
|
174
|
+
if (!hasErrors && config.lastJsonString !== jsonString) {
|
|
175
|
+
log(config, `Scraped data for "${config.what}" pixel:`, { data });
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
config.beforeSend(data, (newData) => {
|
|
179
|
+
if (config.what !== "scrape" && Array.isArray(newData.sku))
|
|
180
|
+
newData.sku.forEach((sku) => send(config, { ...newData, sku }));
|
|
181
|
+
else send(config, newData);
|
|
182
|
+
});
|
|
183
|
+
} catch (error) {
|
|
184
|
+
log(config, error.message, { level: "error", key: "beforeSend" });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
config.lastJsonString = jsonString;
|
|
188
|
+
config.logs = {};
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const cycle = (config) => {
|
|
193
|
+
if (!config.where.test(location.href)) {
|
|
194
|
+
log(
|
|
195
|
+
config,
|
|
196
|
+
`RegExp ${config.where.toString()} does not match ${location.href}`,
|
|
197
|
+
{ level: "warn", key: "where" }
|
|
198
|
+
);
|
|
199
|
+
} else if (document.querySelector('html[class*="translated-"]')) {
|
|
200
|
+
log(
|
|
201
|
+
config,
|
|
202
|
+
"This page has been translated by the browser, and will be excluded",
|
|
203
|
+
{ level: "warn", key: "(browser translation)" }
|
|
204
|
+
);
|
|
205
|
+
} else if (config.when) {
|
|
206
|
+
try {
|
|
207
|
+
let elements = config.when.elements();
|
|
208
|
+
if (!elements.forEach) elements = [elements];
|
|
209
|
+
|
|
210
|
+
elements.forEach((element) => {
|
|
211
|
+
if (!element.hasAttribute(`choreograph-${config.when.listener}`)) {
|
|
212
|
+
element.addEventListener(config.when.listener, () =>
|
|
213
|
+
scrape(config, element)
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
element.setAttribute(`choreograph-${config.when.listener}`, "");
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
} catch (error) {
|
|
220
|
+
log(config, error.message, { level: "error", key: "when.elements" });
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
scrape(config);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
setTimeout(() => cycle(config), 750);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const validateConfig = (config) => {
|
|
230
|
+
if (typeof config.who !== "number")
|
|
231
|
+
log(config, "Please use a number", { level: "error", key: "who" });
|
|
232
|
+
else if (!["scrape", "view", "basket", "purchase"].includes(config.what))
|
|
233
|
+
log(config, "Please use scrape, view, basket, or purchase", {
|
|
234
|
+
level: "error",
|
|
235
|
+
key: "what",
|
|
236
|
+
});
|
|
237
|
+
else if (!(config.where instanceof RegExp))
|
|
238
|
+
log(config, "Please use a regular expression", {
|
|
239
|
+
level: "error",
|
|
240
|
+
key: "where",
|
|
241
|
+
});
|
|
242
|
+
else if (typeof config.which !== "object" || !config.which.sku)
|
|
243
|
+
log(config, "Please provide an SKU", { level: "error", key: "which.sku" });
|
|
244
|
+
else return true;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
class ChoreographCreatePixel {
|
|
248
|
+
constructor(userConfig) {
|
|
249
|
+
const config = {
|
|
250
|
+
debug: /(pixel|lemonpi)_debug/i.test(location.href),
|
|
251
|
+
beforeSend: (data, callback) => callback(data),
|
|
252
|
+
afterSend: () => {},
|
|
253
|
+
optionalFields: [],
|
|
254
|
+
...userConfig,
|
|
255
|
+
logs: {},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
if (validateConfig(config)) cycle(config);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
static getUrl(config) {
|
|
262
|
+
let url = `${location.protocol}//${location.host}${location.pathname}`;
|
|
263
|
+
const parameters = new URLSearchParams(window.location.search);
|
|
264
|
+
|
|
265
|
+
if (config) {
|
|
266
|
+
let paramAdded = false;
|
|
267
|
+
|
|
268
|
+
if (config.allowedParameters && config.allowedParameters.length) {
|
|
269
|
+
config.allowedParameters.forEach((parameter) => {
|
|
270
|
+
const separator = paramAdded ? "&" : "?";
|
|
271
|
+
const key = encodeURI(parameter);
|
|
272
|
+
const value = parameters.get(parameter);
|
|
273
|
+
|
|
274
|
+
if (value != null) {
|
|
275
|
+
url += `${separator}${key}=${encodeURI(value)}`;
|
|
276
|
+
paramAdded = true;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (config.customParameters) {
|
|
282
|
+
const parameters = Object.keys(config.customParameters);
|
|
283
|
+
|
|
284
|
+
parameters.forEach((parameter) => {
|
|
285
|
+
const separator = paramAdded ? "&" : "?";
|
|
286
|
+
const key = encodeURI(parameter);
|
|
287
|
+
const value = encodeURI(config.customParameters[parameter]);
|
|
288
|
+
url += `${separator}${key}=${value}`;
|
|
289
|
+
paramAdded = true;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (config.allowHash) {
|
|
294
|
+
url += window.location.hash;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return url;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = ChoreographCreatePixel;
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/*! choreograph-create-pixel v0.1.0 2022/09/15 */
|
|
2
|
+
const log = (
|
|
3
|
+
config,
|
|
4
|
+
message,
|
|
5
|
+
{ level = "info", key = null, data = undefined }
|
|
6
|
+
) => {
|
|
7
|
+
if (!config.debug || config.logs[key] === message) return;
|
|
8
|
+
|
|
9
|
+
const colors = {
|
|
10
|
+
error: "#f44336",
|
|
11
|
+
warn: "#ffa726",
|
|
12
|
+
success: "#66bb6a",
|
|
13
|
+
info: "#29b6f6",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const icons = { error: "⛔️", warn: "⚠️", success: "✅", info: "ℹ️" };
|
|
17
|
+
const titleCss =
|
|
18
|
+
"font-family:HelveticaNeue;font-weight:700;margin:1em 0 1.2em 0;font-size:80%;";
|
|
19
|
+
const messageCss = `font-family:HelveticaNeue;font-size:120%;margin-bottom:.5em;color:${colors[level]}`;
|
|
20
|
+
const keyCss =
|
|
21
|
+
"font-family:monospace;font-size:120%;border:1px solid;border-radius:5px;margin-bottom:1em;padding:.2em .6em";
|
|
22
|
+
|
|
23
|
+
const args = [
|
|
24
|
+
`%cCHOREOGRAPH PIXEL DEBUGGER%c\n%c${icons[level]} ${message}${
|
|
25
|
+
key ? `%c\n→ %c${key}` : "%c%c"
|
|
26
|
+
}`,
|
|
27
|
+
titleCss,
|
|
28
|
+
"",
|
|
29
|
+
messageCss,
|
|
30
|
+
"",
|
|
31
|
+
keyCss,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
if (data) args.push(data);
|
|
35
|
+
console.info(...args);
|
|
36
|
+
if (key) config.logs[key] = message;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const send = (config, data) => {
|
|
40
|
+
const eventTypes = {
|
|
41
|
+
view: "product-viewed",
|
|
42
|
+
basket: "product-basketed",
|
|
43
|
+
purchase: "product-purchased",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const fields = { ...data };
|
|
47
|
+
delete fields.sku;
|
|
48
|
+
|
|
49
|
+
const url =
|
|
50
|
+
config.what === "scrape"
|
|
51
|
+
? `https://d.lemonpi.io/scrapes${config.debug ? "?validate=true" : ""}`
|
|
52
|
+
: `https://d.lemonpi.io/a/${
|
|
53
|
+
config.who
|
|
54
|
+
}/product/event?e=${encodeURIComponent(
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
"event-type": eventTypes[config.what],
|
|
57
|
+
sku: data.sku,
|
|
58
|
+
})
|
|
59
|
+
)}`;
|
|
60
|
+
|
|
61
|
+
if (config.what !== "scrape" && !config.debug) new Image().src = url;
|
|
62
|
+
else
|
|
63
|
+
fetch(
|
|
64
|
+
url,
|
|
65
|
+
config.what === "scrape"
|
|
66
|
+
? {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "Content-Type": "application/json" },
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
"advertiser-id": config.who,
|
|
71
|
+
sku: data.sku,
|
|
72
|
+
fields,
|
|
73
|
+
}),
|
|
74
|
+
}
|
|
75
|
+
: null
|
|
76
|
+
)
|
|
77
|
+
.then((response) => {
|
|
78
|
+
if (!config.debug) return;
|
|
79
|
+
|
|
80
|
+
response
|
|
81
|
+
.json()
|
|
82
|
+
.then((result) => {
|
|
83
|
+
if (response.ok) {
|
|
84
|
+
log(
|
|
85
|
+
config,
|
|
86
|
+
`"${config.what}" pixel successful, with warnings. Details:`,
|
|
87
|
+
{ level: "warn", data: result }
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
config.afterSend(data);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
log(config, error.message, {
|
|
94
|
+
level: "error",
|
|
95
|
+
key: "afterSend",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
} else
|
|
99
|
+
log(
|
|
100
|
+
config,
|
|
101
|
+
`"${config.what}" pixel failed: ${response.status} (${response.statusText}). Details:`,
|
|
102
|
+
{ level: "error", data: result }
|
|
103
|
+
);
|
|
104
|
+
})
|
|
105
|
+
.catch(() => {
|
|
106
|
+
if (response.ok) {
|
|
107
|
+
log(config, `"${config.what}" pixel successful!`, {
|
|
108
|
+
level: "success",
|
|
109
|
+
data,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
config.afterSend(data);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
log(config, error.message, {
|
|
116
|
+
level: "error",
|
|
117
|
+
key: "afterSend",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
} else
|
|
121
|
+
log(
|
|
122
|
+
config,
|
|
123
|
+
`"${config.what}" pixel failed: ${response.status} (${response.statusText})`,
|
|
124
|
+
{ level: "error" }
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
})
|
|
128
|
+
.catch((error) => {
|
|
129
|
+
if (config.debug)
|
|
130
|
+
log(config, `"${config.what}" pixel failed: ${error.message}`, {
|
|
131
|
+
level: "error",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const scrape = (config, element) => {
|
|
137
|
+
const data = {};
|
|
138
|
+
let hasErrors = false;
|
|
139
|
+
|
|
140
|
+
Object.keys(config.which).forEach((fieldName) => {
|
|
141
|
+
data[fieldName] = config.which[fieldName];
|
|
142
|
+
|
|
143
|
+
if (typeof data[fieldName] === "function") {
|
|
144
|
+
try {
|
|
145
|
+
data[fieldName] = data[fieldName](element);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
log(config, error.message, {
|
|
148
|
+
level: "error",
|
|
149
|
+
key: `which.${fieldName}`,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
hasErrors = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (typeof data[fieldName] === "string")
|
|
157
|
+
data[fieldName] = data[fieldName].replace(/\s+/g, " ").trim();
|
|
158
|
+
|
|
159
|
+
if (data[fieldName] == null || data[fieldName] === "")
|
|
160
|
+
if (!config.optionalFields.includes(fieldName)) {
|
|
161
|
+
log(config, "This required field's value is empty", {
|
|
162
|
+
level: "error",
|
|
163
|
+
key: `which.${fieldName}`,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
hasErrors = true;
|
|
167
|
+
} else data[fieldName] = null;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const jsonString = JSON.stringify(data);
|
|
171
|
+
|
|
172
|
+
if (!hasErrors && config.lastJsonString !== jsonString) {
|
|
173
|
+
log(config, `Scraped data for "${config.what}" pixel:`, { data });
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
config.beforeSend(data, (newData) => {
|
|
177
|
+
if (config.what !== "scrape" && Array.isArray(newData.sku))
|
|
178
|
+
newData.sku.forEach((sku) => send(config, { ...newData, sku }));
|
|
179
|
+
else send(config, newData);
|
|
180
|
+
});
|
|
181
|
+
} catch (error) {
|
|
182
|
+
log(config, error.message, { level: "error", key: "beforeSend" });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
config.lastJsonString = jsonString;
|
|
186
|
+
config.logs = {};
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const cycle = (config) => {
|
|
191
|
+
if (!config.where.test(location.href)) {
|
|
192
|
+
log(
|
|
193
|
+
config,
|
|
194
|
+
`RegExp ${config.where.toString()} does not match ${location.href}`,
|
|
195
|
+
{ level: "warn", key: "where" }
|
|
196
|
+
);
|
|
197
|
+
} else if (document.querySelector('html[class*="translated-"]')) {
|
|
198
|
+
log(
|
|
199
|
+
config,
|
|
200
|
+
"This page has been translated by the browser, and will be excluded",
|
|
201
|
+
{ level: "warn", key: "(browser translation)" }
|
|
202
|
+
);
|
|
203
|
+
} else if (config.when) {
|
|
204
|
+
try {
|
|
205
|
+
let elements = config.when.elements();
|
|
206
|
+
if (!elements.forEach) elements = [elements];
|
|
207
|
+
|
|
208
|
+
elements.forEach((element) => {
|
|
209
|
+
if (!element.hasAttribute(`choreograph-${config.when.listener}`)) {
|
|
210
|
+
element.addEventListener(config.when.listener, () =>
|
|
211
|
+
scrape(config, element)
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
element.setAttribute(`choreograph-${config.when.listener}`, "");
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
log(config, error.message, { level: "error", key: "when.elements" });
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
scrape(config);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
setTimeout(() => cycle(config), 750);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const validateConfig = (config) => {
|
|
228
|
+
if (typeof config.who !== "number")
|
|
229
|
+
log(config, "Please use a number", { level: "error", key: "who" });
|
|
230
|
+
else if (!["scrape", "view", "basket", "purchase"].includes(config.what))
|
|
231
|
+
log(config, "Please use scrape, view, basket, or purchase", {
|
|
232
|
+
level: "error",
|
|
233
|
+
key: "what",
|
|
234
|
+
});
|
|
235
|
+
else if (!(config.where instanceof RegExp))
|
|
236
|
+
log(config, "Please use a regular expression", {
|
|
237
|
+
level: "error",
|
|
238
|
+
key: "where",
|
|
239
|
+
});
|
|
240
|
+
else if (typeof config.which !== "object" || !config.which.sku)
|
|
241
|
+
log(config, "Please provide an SKU", { level: "error", key: "which.sku" });
|
|
242
|
+
else return true;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
class ChoreographCreatePixel {
|
|
246
|
+
constructor(userConfig) {
|
|
247
|
+
const config = {
|
|
248
|
+
debug: /(pixel|lemonpi)_debug/i.test(location.href),
|
|
249
|
+
beforeSend: (data, callback) => callback(data),
|
|
250
|
+
afterSend: () => {},
|
|
251
|
+
optionalFields: [],
|
|
252
|
+
...userConfig,
|
|
253
|
+
logs: {},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
if (validateConfig(config)) cycle(config);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
static getUrl(config) {
|
|
260
|
+
let url = `${location.protocol}//${location.host}${location.pathname}`;
|
|
261
|
+
const parameters = new URLSearchParams(window.location.search);
|
|
262
|
+
|
|
263
|
+
if (config) {
|
|
264
|
+
let paramAdded = false;
|
|
265
|
+
|
|
266
|
+
if (config.allowedParameters && config.allowedParameters.length) {
|
|
267
|
+
config.allowedParameters.forEach((parameter) => {
|
|
268
|
+
const separator = paramAdded ? "&" : "?";
|
|
269
|
+
const key = encodeURI(parameter);
|
|
270
|
+
const value = parameters.get(parameter);
|
|
271
|
+
|
|
272
|
+
if (value != null) {
|
|
273
|
+
url += `${separator}${key}=${encodeURI(value)}`;
|
|
274
|
+
paramAdded = true;
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (config.customParameters) {
|
|
280
|
+
const parameters = Object.keys(config.customParameters);
|
|
281
|
+
|
|
282
|
+
parameters.forEach((parameter) => {
|
|
283
|
+
const separator = paramAdded ? "&" : "?";
|
|
284
|
+
const key = encodeURI(parameter);
|
|
285
|
+
const value = encodeURI(config.customParameters[parameter]);
|
|
286
|
+
url += `${separator}${key}=${value}`;
|
|
287
|
+
paramAdded = true;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (config.allowHash) {
|
|
292
|
+
url += window.location.hash;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return url;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export { ChoreographCreatePixel as default };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/*! choreograph-create-pixel v0.1.0 2022/09/15 */
|
|
2
|
+
var ChoreographCreatePixel=function(){"use strict";function e(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,n)}return r}function t(t){for(var r=1;r<arguments.length;r++){var n=null!=arguments[r]?arguments[r]:{};r%2?e(Object(n),!0).forEach((function(e){o(t,e,n[e])})):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):e(Object(n)).forEach((function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(n,e))}))}return t}function r(e){return r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},r(e)}function n(e,t){for(var r=0;r<t.length;r++){var n=t[r];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,n.key,n)}}function o(e,t,r){return t in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}var a=function(e,t,r){var n,o=r.level,a=void 0===o?"info":o,c=r.key,i=void 0===c?null:c,s=r.data,l=void 0===s?void 0:s;if(e.debug&&e.logs[i]!==t){var u="font-family:HelveticaNeue;font-size:120%;margin-bottom:.5em;color:".concat({error:"#f44336",warn:"#ffa726",success:"#66bb6a",info:"#29b6f6"}[a]),f=["%cCHOREOGRAPH PIXEL DEBUGGER%c\n%c".concat({error:"⛔️",warn:"⚠️",success:"✅",info:"ℹ️"}[a]," ").concat(t).concat(i?"%c\n→ %c".concat(i):"%c%c"),"font-family:HelveticaNeue;font-weight:700;margin:1em 0 1.2em 0;font-size:80%;","",u,"","font-family:monospace;font-size:120%;border:1px solid;border-radius:5px;margin-bottom:1em;padding:.2em .6em"];l&&f.push(l),(n=console).info.apply(n,f),i&&(e.logs[i]=t)}},c=function(e,r){var n=t({},r);delete n.sku;var o="scrape"===e.what?"https://d.lemonpi.io/scrapes".concat(e.debug?"?validate=true":""):"https://d.lemonpi.io/a/".concat(e.who,"/product/event?e=").concat(encodeURIComponent(JSON.stringify({"event-type":{view:"product-viewed",basket:"product-basketed",purchase:"product-purchased"}[e.what],sku:r.sku})));"scrape"===e.what||e.debug?fetch(o,"scrape"===e.what?{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({"advertiser-id":e.who,sku:r.sku,fields:n})}:null).then((function(t){e.debug&&t.json().then((function(n){if(t.ok){a(e,'"'.concat(e.what,'" pixel successful, with warnings. Details:'),{level:"warn",data:n});try{e.afterSend(r)}catch(t){a(e,t.message,{level:"error",key:"afterSend"})}}else a(e,'"'.concat(e.what,'" pixel failed: ').concat(t.status," (").concat(t.statusText,"). Details:"),{level:"error",data:n})})).catch((function(){if(t.ok){a(e,'"'.concat(e.what,'" pixel successful!'),{level:"success",data:r});try{e.afterSend(r)}catch(t){a(e,t.message,{level:"error",key:"afterSend"})}}else a(e,'"'.concat(e.what,'" pixel failed: ').concat(t.status," (").concat(t.statusText,")"),{level:"error"})}))})).catch((function(t){e.debug&&a(e,'"'.concat(e.what,'" pixel failed: ').concat(t.message),{level:"error"})})):(new Image).src=o},i=function(e,r){var n={},o=!1;Object.keys(e.which).forEach((function(t){if(n[t]=e.which[t],"function"==typeof n[t])try{n[t]=n[t](r)}catch(r){a(e,r.message,{level:"error",key:"which.".concat(t)}),o=!0}"string"==typeof n[t]&&(n[t]=n[t].replace(/\s+/g," ").trim()),null!=n[t]&&""!==n[t]||(e.optionalFields.includes(t)?n[t]=null:(a(e,"This required field's value is empty",{level:"error",key:"which.".concat(t)}),o=!0))}));var i=JSON.stringify(n);if(!o&&e.lastJsonString!==i){a(e,'Scraped data for "'.concat(e.what,'" pixel:'),{data:n});try{e.beforeSend(n,(function(r){"scrape"!==e.what&&Array.isArray(r.sku)?r.sku.forEach((function(n){return c(e,t(t({},r),{},{sku:n}))})):c(e,r)}))}catch(t){a(e,t.message,{level:"error",key:"beforeSend"})}e.lastJsonString=i,e.logs={}}},s=function e(t){if(t.where.test(location.href))if(document.querySelector('html[class*="translated-"]'))a(t,"This page has been translated by the browser, and will be excluded",{level:"warn",key:"(browser translation)"});else if(t.when)try{var r=t.when.elements();r.forEach||(r=[r]),r.forEach((function(e){e.hasAttribute("choreograph-".concat(t.when.listener))||(e.addEventListener(t.when.listener,(function(){return i(t,e)})),e.setAttribute("choreograph-".concat(t.when.listener),""))}))}catch(e){a(t,e.message,{level:"error",key:"when.elements"})}else i(t);else a(t,"RegExp ".concat(t.where.toString()," does not match ").concat(location.href),{level:"warn",key:"where"});setTimeout((function(){return e(t)}),750)};return function(){function e(n){!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e);var o=t(t({debug:/(pixel|lemonpi)_debug/i.test(location.href),beforeSend:function(e,t){return t(e)},afterSend:function(){},optionalFields:[]},n),{},{logs:{}});(function(e){if("number"!=typeof e.who)a(e,"Please use a number",{level:"error",key:"who"});else if(["scrape","view","basket","purchase"].includes(e.what))if(e.where instanceof RegExp){if("object"===r(e.which)&&e.which.sku)return!0;a(e,"Please provide an SKU",{level:"error",key:"which.sku"})}else a(e,"Please use a regular expression",{level:"error",key:"where"});else a(e,"Please use scrape, view, basket, or purchase",{level:"error",key:"what"})})(o)&&s(o)}var o,c,i;return o=e,i=[{key:"getUrl",value:function(e){var t="".concat(location.protocol,"//").concat(location.host).concat(location.pathname),r=new URLSearchParams(window.location.search);if(e){var n=!1;e.allowedParameters&&e.allowedParameters.length&&e.allowedParameters.forEach((function(e){var o=n?"&":"?",a=encodeURI(e),c=r.get(e);null!=c&&(t+="".concat(o).concat(a,"=").concat(encodeURI(c)),n=!0)})),e.customParameters&&Object.keys(e.customParameters).forEach((function(r){var o=n?"&":"?",a=encodeURI(r),c=encodeURI(e.customParameters[r]);t+="".concat(o).concat(a,"=").concat(c),n=!0})),e.allowHash&&(t+=window.location.hash)}return t}}],(c=null)&&n(o.prototype,c),i&&n(o,i),Object.defineProperty(o,"prototype",{writable:!1}),e}()}();
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "choreograph-create-pixel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"author": "Rick Stevens <rick.stevens@choreograph.com> (https://www.lemonpi.io/)",
|
|
5
|
+
"repository": "github:rick-stevens/choreograph-create-pixel",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"main": "dist/bundle.cjs.js",
|
|
8
|
+
"module": "dist/bundle.esm.js",
|
|
9
|
+
"browser": "dist/bundle.iife.js",
|
|
10
|
+
"unpkg": "dist/bundle.iife.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"lint": "eslint --ignore-path .gitignore .",
|
|
16
|
+
"format": "prettier --ignore-path .gitignore --check . '!**/*.{js,jsx,vue}'",
|
|
17
|
+
"build": "rollup -c",
|
|
18
|
+
"dev": "rollup -cw",
|
|
19
|
+
"prepare": "npm run lint && npm run format && npm run build"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@babel/core": "^7.19.1",
|
|
23
|
+
"@babel/preset-env": "^7.19.1",
|
|
24
|
+
"@rollup/plugin-babel": "^5.3.1",
|
|
25
|
+
"@rollup/plugin-eslint": "^8.0.2",
|
|
26
|
+
"eslint": "^8.23.1",
|
|
27
|
+
"eslint-config-prettier": "^8.5.0",
|
|
28
|
+
"eslint-plugin-prettier": "^4.2.1",
|
|
29
|
+
"moment": "^2.29.4",
|
|
30
|
+
"prettier": "^2.7.1",
|
|
31
|
+
"rollup": "^2.79.0",
|
|
32
|
+
"rollup-plugin-terser": "^7.0.2"
|
|
33
|
+
}
|
|
34
|
+
}
|