PluginProbe
EasyTest – Simplify A/B Testing / trunk
EasyTest – Simplify A/B Testing vtrunk
1.0.4 1.0.3 trunk 1.0.1 1.0.2
convertpro / includes / Classes / ElementRedirection.php

ElementRedirection.php in EasyTest – Simplify A/B Testing trunk, at includes/Classes/ElementRedirection.php

451 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace ConvertPro\Classes;
4 if (!defined('ABSPATH')) {
5 exit; // Called directly, nothing to do here.
6 }
7
8
9 use KubAT\PhpSimple\HtmlDomParser;
10 use simplehtmldom;
11 use simplehtmldom\HtmlNode;
12 use simplehtmldom\HtmlWeb;
13
14 class ElementRedirection
15 {
16
17 private $html_parser;
18
19 function __construct()
20 {
21 if (is_admin()) {
22 return false;
23 }
24 // add_action('wp', [$this, 'autoSelectElement'], 1);
25 // add_action('wp_enqueue_scripts', [$this, 'addElementStyles']);
26
27 add_action('init', array($this, 'start_buffer'), PHP_INT_MAX);
28 add_action('template_redirect', [$this, 'update_conversion']);
29
30
31
32
33 // $this->html_parser = new HtmlWeb();
34
35 }
36
37 public function start_buffer()
38 {
39 ob_start(array($this, 'manipulate_html'));
40
41 // var_dump(ob_get_clean());
42 }
43
44 public function manipulate_html($buffer)
45 {
46 global $wpdb;
47
48 // The buffer opens on init, so it catches everything the site sends that
49 // is not an admin screen: the RSS feed, the XML sitemap, REST responses.
50 // Running those through an HTML parser rewrites them — feeds came back
51 // with <pubDate> lowercased to <pubdate>, which XML readers will not
52 // accept. Only touch something that is actually an HTML page. The XML
53 // check is separate because the sitemap stylesheet is XML that happens
54 // to contain an <html> element.
55 if (false === stripos($buffer, '<html') || 0 === strpos(ltrim($buffer), '<?xml')) {
56 return $buffer;
57 }
58
59 // Keep line breaks. The parser strips them by default, which flattens
60 // everything inside <pre>, <code> and <textarea> onto a single line.
61 $dom = HtmlDomParser::str_get_html($buffer, true, true, DEFAULT_TARGET_CHARSET, false);
62
63 // The parser returns false for output it cannot handle — an empty buffer
64 // on shutdown, for one — and calling find() on that is a fatal error.
65 if (!$dom) {
66 return $buffer;
67 }
68
69 // Rebuilding the page through the parser is not free and not lossless, so
70 // only hand back a rebuilt page when a version was actually removed.
71 $changed = false;
72
73 $styles = '';
74 $active_class = '';
75 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
76 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
77 $results = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}convertpro WHERE test_type='elements' AND active = 1");
78 $output = '';
79 foreach ($results as $value) {
80 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
81 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
82 $variations = $wpdb->get_results(
83 $wpdb->prepare(
84 "SELECT * FROM {$wpdb->prefix}convertpro_variations WHERE splittest_id = %d",
85 $value->id
86 )
87 );
88
89 // Check if any variation matches the cookie value
90 $cookieName = convertpro_test_cookie_name('convert_pro_elm_test_', $value->id);
91 $active_class = isset($_COOKIE[$cookieName]) ? sanitize_text_field(wp_unslash(($_COOKIE[$cookieName]))) : '';
92
93
94
95
96 // $this->autoSelectElement();
97 foreach ($variations as $variation) {
98 // A class that is really selector syntax matches things it was
99 // never meant to. Rows saved before this was checked can still
100 // hold one, so skip them here rather than trusting the column.
101 if ('' === convertpro_safe_class_name($variation->class_name)) {
102 continue;
103 }
104
105 if (empty($active_class)) {
106
107 if ($dom->find('.' . $variation->class_name)) {
108 $this->autoSelectElement($value->id);
109 }
110 }
111
112 $active_class = isset($_COOKIE[$cookieName]) ? sanitize_text_field(wp_unslash($_COOKIE[$cookieName])) : '';
113 // $output .= $dom->find('body', 0)->innertext = 'hellow' . $variation->class_name;
114
115
116 if (!empty($active_class) && $variation->class_name != $active_class) {
117 // var_dump($variation->class_name);
118 foreach ($dom->find('.' . $variation->class_name) as $element) {
119
120 $element->remove();
121 $changed = true;
122 }
123 }
124 }
125 }
126
127
128
129 // Nothing was swapped out, so send the page the theme built rather than
130 // the parser's rebuild of it.
131 if (!$changed) {
132 return $buffer;
133 }
134
135 return $dom->save();
136 // return $output;
137
138 }
139
140 public function update_conversion()
141 {
142 // The page WordPress resolved for this request. Comparing IDs keeps query
143 // parameters and endpoints from mattering, and stops a 404 counting as a
144 // conversion — get_the_permalink() with nothing in the loop falls back to
145 // whichever post comes first.
146 $current_page_id = get_queried_object_id();
147
148 if (!$current_page_id) {
149 return;
150 }
151
152 global $wpdb;
153 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
154 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
155 $results = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}convertpro WHERE test_type = 'elements' AND active = 1");
156
157
158
159 foreach ($results as $value) {
160
161 // Click goals are handled by the front-end tracker; skipping them
162 // also avoids get_the_permalink(0) matching the current page and
163 // counting every visit as a conversion.
164 if ('click' === $value->conversion_type || empty($value->conversion_page_id)) {
165 continue;
166 }
167
168 // Nothing to do unless this is the goal page. Checked before the
169 // variation query so an ordinary page view costs no queries at all.
170 if ((int) $value->conversion_page_id !== $current_page_id) {
171 continue;
172 }
173
174 $run = convertpro_get_test_run($value->id);
175 $cookieName = convertpro_test_cookie_name('convert_pro_elm_test_', $value->id);
176 $variationCookie = convertpro_test_cookie_name('convert_pro_elm_variation_id_', $value->id);
177 $active_class = isset($_COOKIE[$cookieName]) ? sanitize_text_field(wp_unslash($_COOKIE[$cookieName])) : '';
178
179 $variations = $this->convertpro_ele_query($value->id); // Fetch all variations for the current test
180 foreach ($variations as $variation) {
181 $user_variation_id = isset($_COOKIE[$variationCookie]) ? sanitize_text_field(wp_unslash($_COOKIE[$variationCookie])) : '';
182 $client_id = isset($_COOKIE['convert_pro_uid']) ? sanitize_text_field(wp_unslash($_COOKIE['convert_pro_uid'])) : '';
183
184 // Without a visitor id there is no row to flip, and the update
185 // would match on client_id = '' instead.
186 if ($user_variation_id == $variation->id && !empty($active_class) && '' !== $client_id) {
187 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
188 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
189 $wpdb->update(
190 $wpdb->prefix . 'convertpro_interactions',
191 array('type' => 'conversion'),
192 array('variation_id' => $variation->id, 'splittest_id' => $value->id, 'client_id' => $client_id, 'run' => $run)
193 );
194 }
195 }
196 }
197 }
198 public function autoSelectElement($testId)
199 {
200 global $wpdb;
201
202 /* $results = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}convertpro WHERE test_type = 'elements' AND active = 1");
203
204
205 foreach ($results as $value) {
206 */
207 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
208 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
209 $variations = $wpdb->get_results(
210 $wpdb->prepare(
211 "SELECT * FROM {$wpdb->prefix}convertpro_variations WHERE splittest_id = %d AND class_name != ''",
212 $testId
213 )
214 );
215
216 if (empty($variations)) {
217 return;
218 }
219 $cookieName = convertpro_test_cookie_name('convert_pro_elm_test_', $testId);
220 $active_class = isset($_COOKIE[$cookieName]) ? sanitize_text_field(wp_unslash($_COOKIE[$cookieName])) : '';
221
222
223
224 if (empty($active_class)) {
225
226 // checking if all remaining are 0 or not
227 $allRemainingZero = array_filter($variations, function ($obj) {
228 return $obj->remaining != 0;
229 });
230
231
232 if (empty($allRemainingZero)) {
233
234 $this->refillRemaining($variations);
235 $variations = $this->convertpro_ele_query($testId);
236 $variations = array_filter($variations, function ($variation) {
237 return $variation->class_name !== '';
238 });
239 }
240
241 // Pick at random, weighted by the quota left, so the variation a
242 // visitor gets is not decided by when they happened to arrive.
243 $variation = $this->convertpro_ele_selectVariation($variations);
244
245 if ($variation) {
246 $this->updateVariation($wpdb, $variation, $testId, $cookieName, $variation->remaining - 1);
247 }
248 }
249 // }
250 }
251
252
253
254 public function convertpro_ele_query($id)
255 {
256 global $wpdb;
257 // Query the database for variations associated with the specified split test ID
258
259 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
260 $results = $wpdb->get_results(
261 $wpdb->prepare(
262 "SELECT * FROM " . $wpdb->prefix . "convertpro_variations
263 WHERE splittest_id = %d
264 ORDER BY id ASC",
265 $id
266 )
267 );
268 return $results;
269 }
270
271 public function updateVariation($wpdb, $variation, $testid, $cookieName, $remaining)
272 {
273 // Assignment happens while the page is being rendered, so if the headers
274 // have already gone out the cookies would be dropped and this visitor
275 // would be re-assigned (and re-counted) on every request. Skip instead.
276 if (headers_sent()) {
277 return;
278 }
279
280 // The page being built now contains one visitor's variation, so it must
281 // not be stored and handed to the next visitor.
282 convertpro_prevent_page_cache();
283
284 // Take one off the quota in the database rather than writing back the
285 // number this request read, so two visitors arriving together cannot
286 // cost the quota only one. $remaining is left in the signature but no
287 // longer used.
288 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
289 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
290 $result = $wpdb->query(
291 $wpdb->prepare(
292 "UPDATE {$wpdb->prefix}convertpro_variations
293 SET remaining = remaining - 1
294 WHERE id = %d AND splittest_id = %d AND remaining > 0",
295 $variation->id,
296 $testid
297 )
298 );
299
300 $expires = time() + CONVERTPRO_COOKIE_LIFETIME;
301
302 setcookie($cookieName, $variation->class_name, $expires, '/');
303 $_COOKIE[$cookieName] = $variation->class_name; // Update $_COOKIE superglobal
304 setcookie(convertpro_test_cookie_name('convert_pro_elm_variation_id_', $testid), $variation->id, $expires, '/');
305
306 // Reuse the visitor's existing uid when they already have one: storing the
307 // interaction under a freshly generated uid would leave the row orphaned
308 // from the cookie, and the conversion update would never match it.
309 $cookie_value = isset($_COOKIE['convert_pro_uid'])
310 ? sanitize_text_field(wp_unslash($_COOKIE['convert_pro_uid']))
311 : $this->convertpro_ele_generateuid();
312
313 if (!isset($_COOKIE['convert_pro_uid'])) {
314 setcookie('convert_pro_uid', $cookie_value, $expires, '/');
315 setcookie('convert_pro_ele_uid', $testid, $expires, '/');
316 $_COOKIE['convert_pro_uid'] = $cookie_value;
317 }
318
319 $this->convertpro_store_visit_data($cookie_value, $variation->id, $testid);
320 }
321
322 /**
323 * Pick one of the variations that still has quota left, at random and
324 * weighted by the remaining quota.
325 *
326 * @param array $variations Variation rows for a single test.
327 * @return object|null
328 */
329 public function convertpro_ele_selectVariation($variations)
330 {
331 // Filter variations to only include those with remaining count greater than 0
332 $available_variations = array_values(array_filter((array) $variations, function ($variation) {
333 return $variation->remaining > 0;
334 }));
335
336 if (empty($available_variations)) {
337 return null;
338 }
339
340 $total = 0;
341 foreach ($available_variations as $variation) {
342 $total += (int) $variation->remaining;
343 }
344
345 if ($total <= 0) {
346 return $available_variations[array_rand($available_variations)];
347 }
348
349 $ticket = wp_rand(1, $total);
350 $cursor = 0;
351
352 foreach ($available_variations as $variation) {
353 $cursor += (int) $variation->remaining;
354 if ($ticket <= $cursor) {
355 return $variation;
356 }
357 }
358
359 return $available_variations[0];
360 }
361
362 public function refillRemaining($variations)
363 {
364 global $wpdb;
365 $results = [];
366 // var_dump($variations);
367 foreach ($variations as $variation) {
368
369 // Use the full configured percentage; taking the first digit only
370 // (50 -> 5) distorted every split that was not a multiple of ten.
371 $percentage = (int) $variation->percentage;
372 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
373 $results = $wpdb->update(
374 $wpdb->prefix . 'convertpro_variations',
375 array('remaining' => (int) $percentage),
376 array('id' => (int) $variation->id)
377 );
378 }
379
380 return $results;
381 }
382 public function updateeleVariationRemaining($variationid, $remainingCount)
383 {
384 global $wpdb;
385 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
386 $result = $wpdb->update(
387 $wpdb->prefix . 'convertpro_variations',
388 array('remaining' => (int) $remainingCount),
389 array('id' => (int) $variationid)
390 );
391 }
392
393
394
395 public function convertpro_ele_generateuid()
396 {
397 return sprintf(
398 '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
399
400 wp_rand(0, 0xffff),
401 wp_rand(0, 0xffff),
402
403 wp_rand(0, 0xffff),
404 wp_rand(0, 0x0fff) | 0x4000,
405 wp_rand(0, 0x3fff) | 0x8000,
406 wp_rand(0, 0xffff),
407 wp_rand(0, 0xffff),
408 wp_rand(0, 0xffff)
409 );
410 }
411
412 public function convertpro_store_visit_data($cookie_value, $variation_id, $testid)
413 {
414 global $wpdb;
415
416 // Check if there's already an interaction record
417 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
418 $run = convertpro_get_test_run($testid);
419
420 $query = $wpdb->get_results(
421 $wpdb->prepare(
422 "SELECT * FROM {$wpdb->prefix}convertpro_interactions
423 WHERE splittest_id = %d
424 AND client_id = %s
425 AND run = %d",
426 $testid,
427 $cookie_value,
428 $run
429 ),
430 OBJECT
431 );
432
433
434
435 if (empty($query)) {
436 $table_name = $wpdb->prefix . 'convertpro_interactions';
437 // created_at is left to the column default on purpose — see the
438 // matching insert in Redirection::store_visit_data().
439 $wpdb->insert(
440 $table_name,
441 [
442 'client_id' => $cookie_value,
443 'splittest_id' => $testid,
444 'variation_id' => $variation_id, // Ensure correct variation ID is stored
445 'run' => $run,
446 ]
447 );
448 }
449 }
450 }
451