PluginProbe ʕ •ᴥ•ʔ
Advanced Ads – Ad Manager & AdSense / 2.0.25
Advanced Ads – Ad Manager & AdSense v2.0.25
2.0.25 2.0.24 2.0.23 2.0.22 2.0.21 1.38.0 1.39.0 1.39.1 1.39.2 1.39.3 1.39.4 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.40.0 1.40.1 1.40.2 1.41.0 1.42.0 1.42.1 1.42.2 1.43.0 1.44.0 1.44.1 1.45.0 1.45.1 1.46.0 1.47.0 1.47.1 1.47.2 1.47.3 1.47.4 1.47.5 1.48.0 1.48.1 1.49.0 1.5.0 1.5.0.1 1.5.1 1.5.2 1.5.2.1 1.5.4 1.5.4.1 1.5.5 1.50.0 1.51.0 1.51.1 1.51.2 1.51.3 1.52.0 1.52.1 1.52.2 1.52.3 1.52.4 1.53.0 1.53.1 1.53.2 1.54.0 1.54.1 1.55.0 1.56.0 1.56.1 1.56.2 1.56.3 1.56.4 1.6 1.6.1 1.6.10 1.6.10.1 1.6.10.2 1.6.11 1.6.11.1 1.6.12 1.6.13 1.6.14 1.6.15 1.6.16 1.6.17 1.6.17.1 1.6.17.2 1.6.2 1.6.2.1 1.6.3 1.6.4 1.6.4.1 1.6.5 1.6.6 1.6.6.1 1.6.7 1.6.7.1 1.6.8 1.6.8.1 1.6.8.2 1.6.8.3 1.6.9 1.6.9.1 1.6.9.2 1.6.9.3 1.6.9.4 1.7 1.7.0.1 1.7.0.2 1.7.0.3 1.7.1 1.7.1.1 1.7.1.2 1.7.1.3 1.7.1.4 1.7.1.5 1.7.10 trunk 1.7.11 1.0.1 1.7.12 1.0.2 1.7.13 1.0.3 1.7.14 1.1.0 1.7.15 1.1.1 1.7.16 1.1.2 1.7.17 1.1.3 1.7.18 1.10 1.7.19 1.10.1 1.7.2 1.10.10 1.7.2.1 1.10.11 1.7.20 1.10.12 1.7.21 1.10.2 1.7.22 1.10.3 1.7.23 1.10.4 1.7.24 1.10.5 1.7.25 1.10.6 1.7.3 1.10.7 1.7.4 1.10.8 1.7.4.1 1.10.9 1.7.4.2 1.11 1.7.4.3 1.11.1 1.7.4.4 1.11.2 1.7.4.5 1.12 1.7.5 1.13 1.7.5.1 1.13.1 1.7.6 1.13.2 1.7.7 1.13.3 1.7.8 1.13.4 1.7.9 1.13.5 1.7.9.1 1.13.6 1.7.9.2 1.13.7 1.7.9.3 1.13.8 1.8 1.14 1.8.1 1.14.1 1.8.10 1.14.10 1.8.11 1.14.11 1.8.12 1.14.2 1.8.13 1.14.3 1.8.14 1.14.4 1.8.15 1.14.5 1.8.16 1.14.6 1.8.17 1.14.7 1.8.18 1.14.8 1.8.19 1.14.9 1.8.2 1.15 1.8.20 1.16 1.8.21 1.16.1 1.8.22 1.17 1.8.23 1.17.1 1.8.24 1.17.10 1.8.25 1.17.10-rc.1 1.8.26 1.17.11 1.8.27 1.17.12 1.8.28 1.17.12-rc.1 1.8.29 1.17.2 1.8.3 1.17.3 1.8.30 1.17.4 1.8.4 1.17.5 1.8.5 1.17.6 1.8.6 1.17.7 1.8.7 1.17.8 1.8.8 1.17.9 1.8.9 1.17.9-beta.1 1.9 1.18.0 2.0.0 1.19.0 2.0.1 1.19.1 2.0.10 1.2 2.0.11 1.2.1 2.0.12 1.2.2 2.0.13 1.2.3 2.0.14 1.2.4 2.0.15 1.2.5 2.0.16 1.2.6 2.0.17 1.2.7 2.0.18 1.20.0 2.0.19 1.20.0-rc.1 2.0.2 1.20.0-rc.2 2.0.20 1.20.1 2.0.3 1.20.2 2.0.4 1.20.3 2.0.5 1.21.0 2.0.6 1.21.1 2.0.7 1.22.0 2.0.8 1.22.1 2.0.9 1.22.2 1.23.0 1.23.1 1.23.2 1.24.0 1.24.1 1.24.2 1.25.0 1.25.1 1.26.0 1.27.0 1.28.0 1.29.0 1.29.1 1.3 1.3.1 1.3.10 1.3.11 1.3.12 1.3.13 1.3.14 1.3.15 1.3.16 1.3.17 1.3.18 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.30.0 1.30.1 1.30.2 1.30.2-rc.1 1.30.3 1.30.4 1.30.4-rc.1 1.30.5 1.31.0 1.31.1 1.32.0 1.32.0-rc.1 1.33.0 1.33.1 1.33.2 1.34.0 1.35.0 1.35.1 1.36.0 1.36.1 1.36.2 1.36.3 1.37.0 1.37.1 1.37.2
advanced-ads / includes / class-content-injector.php
advanced-ads / includes Last commit date
abstracts 1 day ago admin 1 day ago ads 1 day ago compatibility 1 week ago crons 1 day ago frontend 1 week ago groups 1 day ago importers 5 days ago installation 1 day ago interfaces 5 months ago license 1 day ago placements 1 day ago rest 1 day ago traits 1 day ago utilities 1 day ago cap_map.php 3 years ago class-assets-registry.php 1 day ago class-autoloader.php 1 week ago class-cache-invalidator.php 1 day ago class-constants.php 1 day ago class-content-injector.php 1 month ago class-entities.php 3 months ago class-modal.php 1 year ago class-modules.php 1 year ago class-options.php 1 year ago class-plugin.php 1 day ago class-post-data.php 10 months ago class-shortcodes.php 1 week ago class-upgrades.php 1 day ago class-widget.php 11 months ago default-hooks.php 5 months ago functions-ad.php 1 day ago functions-components.php 3 months ago functions-conditional.php 1 year ago functions-core.php 1 year ago functions-group.php 1 day ago functions-placement.php 1 day ago functions.php 1 week ago index.php 2 years ago load_modules.php 2 years ago
class-content-injector.php
835 lines
1 <?php
2 /**
3 * Content injector template file.
4 * Brief description of the styles in this file
5 *
6 * @since 2.0.19
7 * @package AdvancedAds
8 * @author Advanced Ads <info@wpadvancedads.com>
9 */
10
11 namespace AdvancedAds;
12
13 use Advanced_Ads;
14 use AdvancedAds\Utilities\Conditional;
15 use AdvancedAds\Framework\Utilities\Str;
16 use DOMDocument;
17 use DOMElement;
18 use DOMNode;
19 use DOMNodeList;
20 use DOMXPath;
21
22 defined( 'ABSPATH' ) || exit;
23
24 /**
25 * Content injector template file class.
26 *
27 * phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
28 * phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
29 */
30 class Content_Injector {
31 /**
32 * Gather placeholders which later are replaced by the ads.
33 *
34 * @var array
35 */
36 private static $ads_for_placeholders = [];
37
38 /**
39 * Self-closing HTML tags that cannot have children.
40 * Hash map for O(1) lookup vs in_array() linear scan.
41 *
42 * @var array
43 */
44 private const SELF_CLOSING_TAGS = [
45 'area' => true,
46 'base' => true,
47 'basefont' => true,
48 'bgsound' => true,
49 'br' => true,
50 'col' => true,
51 'embed' => true,
52 'frame' => true,
53 'hr' => true,
54 'img' => true,
55 'input' => true,
56 'keygen' => true,
57 'link' => true,
58 'meta' => true,
59 'param' => true,
60 'source' => true,
61 'track' => true,
62 'wbr' => true,
63 ];
64
65 /**
66 * Sort order for ad positions.
67 * Class constant avoids allocating a new array on every usort() comparison.
68 *
69 * @var array
70 */
71 private const POSITION_ORDER = [
72 'before' => 1,
73 'prepend' => 2,
74 'append' => 3,
75 'after' => 4,
76 ];
77
78 /**
79 * Stored as a constant so sprintf() is called once, not per DOM load.
80 *
81 * @var string
82 */
83 private const DOM_PREFIX = '<!DOCTYPE html><html><meta http-equiv="Content-Type" content="text/html; charset=%s" /><body>';
84
85 /**
86 * Inject ads directly into the content.
87 *
88 * @param string $placement_id Id of the placement.
89 * @param array $placement_opts Placement options.
90 * @param string $content Content to inject placement into.
91 * @param array $options Injection options.
92 *
93 * @return string $content Content with injected placement.
94 */
95 public static function &inject_in_content( $placement_id, $placement_opts, &$content, $options = [] ) {
96 if ( ! extension_loaded( 'dom' ) ) {
97 return $content;
98 }
99
100 // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
101 // phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
102
103 $tag = isset( $placement_opts['tag'] ) ? $placement_opts['tag'] : 'p';
104 $tag = preg_replace( '/[^a-z0-9]/i', '', $tag );
105 $tag_option = $tag;
106
107 $tag = apply_filters( 'advanced-ads-placement-content-injection-xpath', $tag, $placement_opts );
108
109 $plugin_options = Advanced_Ads::get_instance()->options();
110
111 $defaults = [
112 'allowEmpty' => false,
113 'paragraph_select_from_bottom' => isset( $placement_opts['start_from_bottom'] ) && $placement_opts['start_from_bottom'],
114 'position' => isset( $placement_opts['position'] ) ? $placement_opts['position'] : 'after',
115 'before' => isset( $placement_opts['position'] ) && 'before' === $placement_opts['position'],
116 'alter_nodes' => true,
117 'repeat' => false,
118 ];
119
120 $defaults['paragraph_id'] = isset( $placement_opts['index'] ) ? $placement_opts['index'] : 1;
121 $defaults['paragraph_id'] = max( 1, (int) $defaults['paragraph_id'] );
122 $defaults['itemLimit'] = 'p' === $tag_option ? 2 : 1;
123
124 if ( ! empty( $plugin_options['content-injection-level-disabled'] ) ) {
125 $defaults['itemLimit'] = 1000;
126 }
127
128 if ( in_array( $tag_option, [ 'img', 'iframe', 'custom' ], true ) ) {
129 $defaults['allowEmpty'] = true;
130 }
131
132 $common_keys = array_intersect_key( $options, $placement_opts );
133 if ( empty( $common_keys ) ) {
134 $options = array_merge( $options, $placement_opts );
135 }
136
137 $options = apply_filters(
138 'advanced-ads-placement-content-injection-options',
139 wp_parse_args( $options, $defaults ),
140 $tag_option
141 );
142
143 $wp_charset = get_bloginfo( 'charset' );
144 $content_to_load = self::get_content_to_load( $content );
145
146 if ( ! $content_to_load ) {
147 return $content;
148 }
149
150 // Enable libxml error suppression once for the entire method.
151 $prev_libxml = libxml_use_internal_errors( true );
152
153 $dom = new DOMDocument( '1.0', $wp_charset );
154 $prefix = sprintf( self::DOM_PREFIX, $wp_charset );
155 $success = $dom->loadHtml( $prefix . $content_to_load );
156
157 // Free the pre-processed content string — we have the DOM now.
158 unset( $content_to_load );
159
160 if ( true !== $success ) {
161 libxml_use_internal_errors( $prev_libxml );
162 return $content;
163 }
164
165 $tag = self::resolve_tag_xpath( $tag_option, $tag, $placement_opts );
166 $xpath = new DOMXPath( $dom );
167 $items = self::query_items( $xpath, $tag, $options['itemLimit'] );
168 $items = apply_filters( 'advanced-ads-placement-content-injection-items', $items, $xpath, $tag_option );
169
170 $whitespaces = json_decode( '"\t\n\r \u00A0"' );
171 $paragraphs = [];
172
173 foreach ( $items as $item ) {
174 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.NotSnakeCaseMemberVar
175 if ( $options['allowEmpty'] || ( isset( $item->textContent ) && trim( $item->textContent, $whitespaces ) !== '' ) ) {
176 $paragraphs[] = $item;
177 }
178 }
179
180 // Free the NodeList — $paragraphs holds the nodes we care about.
181 unset( $items );
182
183 $ancestors_to_limit = self::get_ancestors_to_limit( $xpath );
184 $paragraphs = self::filter_by_ancestors_to_limit( $paragraphs, $ancestors_to_limit );
185 unset( $ancestors_to_limit );
186
187 $paragraph_count = count( $paragraphs );
188 $options['paragraph_count'] = $paragraph_count;
189
190 if ( $paragraph_count >= $options['paragraph_id'] ) {
191 $offset = $options['paragraph_select_from_bottom']
192 ? $paragraph_count - $options['paragraph_id']
193 : $options['paragraph_id'] - 1;
194
195 $offsets = apply_filters(
196 'advanced-ads-placement-content-offsets',
197 [ $offset ],
198 $options,
199 $placement_opts,
200 $xpath,
201 $paragraphs,
202 $dom
203 );
204
205 $did_inject = false;
206
207 foreach ( $offsets as $offset ) {
208 $node = apply_filters(
209 'advanced-ads-placement-content-injection-node',
210 $paragraphs[ $offset ],
211 $tag,
212 $options['before']
213 );
214
215 if ( $options['alter_nodes'] ) {
216 $node = self::adjust_node_for_injection( $node, $tag_option, $options['before'] );
217 }
218
219 $ad_content = (string) get_the_placement( $placement_id, '', $placement_opts );
220
221 if ( trim( $ad_content, $whitespaces ) === '' ) {
222 continue;
223 }
224
225 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.NotSnakeCaseMemberVar
226 $ad_content = self::filter_ad_content( $ad_content, $node->tagName, $options );
227
228 // Parse ad HTML into a temporary DOM, import into main DOM, then free immediately.
229 $ad_dom = new DOMDocument( '1.0', $wp_charset );
230 $ad_dom->loadHtml( $prefix . $ad_content );
231 unset( $ad_content );
232
233 $ad_body = $ad_dom->getElementsByTagName( 'body' )->item( 0 );
234
235 // Snapshot childNodes BEFORE importing — importing from a live NodeList
236 // causes it to mutate mid-iteration, silently skipping nodes.
237 $ad_nodes = null !== $ad_body ? iterator_to_array( $ad_body->childNodes ) : [];
238 unset( $ad_body );
239
240 self::insert_nodes( $dom, $node, $ad_nodes, $options['position'] );
241 unset( $ad_nodes );
242
243 // Explicitly release the ad DOM tree — do not wait for end-of-scope GC.
244 unset( $ad_dom );
245
246 $did_inject = true;
247 }
248
249 // Release paragraph node references held in memory.
250 unset( $paragraphs, $offsets );
251
252 libxml_use_internal_errors( $prev_libxml );
253
254 if ( ! $did_inject ) {
255 // Flush any accumulated placeholders to prevent static state leaks.
256 self::$ads_for_placeholders = [];
257 return $content;
258 }
259
260 $content_orig = $content;
261
262 // Extract body content via string operations — avoids a second DOM parse.
263 $content = self::extract_body_content( $dom->saveHTML() );
264
265 // Largest single memory consumer — free it as soon as saveHTML() is done.
266 unset( $dom );
267
268 $content = self::prepare_output( $content, $content_orig );
269 unset( $content_orig );
270 } elseif (
271 Conditional::user_can( 'advanced_ads_manage_options' )
272 && -1 !== $options['itemLimit']
273 && empty( $plugin_options['content-injection-level-disabled'] )
274 ) {
275 $all_items = $xpath->query( '//' . $tag );
276 $extra_paras = [];
277
278 foreach ( $all_items as $item ) {
279 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.NotSnakeCaseMemberVar
280 if ( $options['allowEmpty'] || ( isset( $item->textContent ) && trim( $item->textContent, $whitespaces ) !== '' ) ) {
281 $extra_paras[] = $item;
282 }
283 }
284
285 unset( $all_items );
286
287 $extra_paras = self::filter_by_ancestors_to_limit(
288 $extra_paras,
289 self::get_ancestors_to_limit( $xpath )
290 );
291
292 if ( $options['paragraph_id'] <= count( $extra_paras ) ) {
293 add_filter( 'advanced-ads-ad-health-nodes', [ 'Advanced_Ads_In_Content_Injector', 'add_ad_health_node' ] );
294 }
295
296 unset( $extra_paras, $dom );
297 libxml_use_internal_errors( $prev_libxml );
298 } else {
299 unset( $dom );
300 libxml_use_internal_errors( $prev_libxml );
301 }
302
303 // phpcs:enable
304
305 return $content;
306 }
307
308 /**
309 * Extract the inner body content from a full HTML document string.
310 *
311 * DOMDocument::saveHTML() emits a full document including DOCTYPE and <html>.
312 * Using string operations here avoids a costly second DOM parse just to unwrap it.
313 *
314 * @param string $html Full HTML document string from saveHTML().
315 *
316 * @return string Inner body content only.
317 */
318 private static function extract_body_content( $html ) {
319 $start = strpos( $html, '<body>' );
320 $end = strrpos( $html, '</body>' );
321
322 if ( false === $start || false === $end ) {
323 return $html;
324 }
325
326 return substr( $html, $start + 6, $end - $start - 6 );
327 }
328
329 /**
330 * Resolve the XPath tag expression based on tag option.
331 *
332 * @param string $tag_option Original tag option.
333 * @param string $tag Current tag value (may have been filtered).
334 * @param array $placement_opts Placement options.
335 *
336 * @return string Resolved XPath expression.
337 */
338 private static function resolve_tag_xpath( $tag_option, $tag, $placement_opts ) {
339 switch ( $tag_option ) {
340 case 'p':
341 return 'p[not(parent::blockquote)]';
342
343 case 'pwithoutimg':
344 return 'p[not(descendant::img) and not(parent::blockquote)]';
345
346 case 'img':
347 $shortcodes = "@class and (
348 contains(concat(' ', normalize-space(@class), ' '), ' gallery-size') or
349 contains(concat(' ', normalize-space(@class), ' '), ' wp-caption ') )";
350 return "*[self::img or self::figure or self::div[$shortcodes]]
351 [not(ancestor::table or ancestor::figure or ancestor::div[$shortcodes])]";
352
353 case 'headlines':
354 $headlines = apply_filters( 'advanced-ads-headlines-for-ad-injection', [ 'h2', 'h3', 'h4' ] );
355 foreach ( $headlines as &$headline ) {
356 $headline = 'self::' . $headline;
357 }
358 return '*[' . implode( ' or ', $headlines ) . ']';
359
360 case 'anyelement':
361 $exclude = [
362 'html', 'body', 'script', 'style', 'tr', 'td',
363 'a', 'abbr', 'b', 'bdo', 'br', 'button', 'cite', 'code',
364 'dfn', 'em', 'i', 'img', 'kbd', 'label', 'option', 'q',
365 'samp', 'select', 'small', 'span', 'strong', 'sub', 'sup',
366 'textarea', 'time', 'tt', 'var',
367 ];
368 return '*[not(self::' . implode( ' or self::', $exclude ) . ')]';
369
370 case 'custom':
371 return ! empty( $placement_opts['xpath'] ) ? self::wp_untexturize( stripslashes( $placement_opts['xpath'] ) ) : 'p';
372
373 default:
374 return $tag;
375 }
376 }
377
378 /**
379 * Query DOM items with progressive level fallback.
380 *
381 * @param DOMXPath $xpath XPath object.
382 * @param string $tag XPath tag expression.
383 * @param int $item_limit Minimum items required before falling back deeper.
384 *
385 * @return DOMNodeList
386 */
387 private static function query_items( DOMXPath $xpath, $tag, $item_limit ) {
388 if ( -1 === $item_limit ) {
389 return $xpath->query( $tag );
390 }
391
392 $levels = [
393 '/html/body/' . $tag,
394 '/html/body/*/' . $tag,
395 '/html/body/*/*/' . $tag,
396 '//' . $tag,
397 ];
398
399 $items = null;
400 foreach ( $levels as $query ) {
401 $items = $xpath->query( $query );
402 if ( $items->length >= $item_limit ) {
403 break;
404 }
405 }
406
407 return $items;
408 }
409
410 /**
411 * Adjust the target node to avoid injecting inside captions, galleries, or links.
412 *
413 * @param DOMNode $node The candidate node.
414 * @param string $tag_option Original tag option.
415 * @param bool $before Whether injection is before the node.
416 *
417 * @return DOMNode Adjusted node.
418 */
419 private static function adjust_node_for_injection( $node, $tag_option, $before ) {
420 $parent = $node;
421 for ( $i = 0; $i < 4; $i++ ) {
422 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
423 $parent = $parent->parentNode;
424 if ( ! $parent instanceof DOMElement ) {
425 break;
426 }
427 if ( preg_match( '/\b(wp-caption|gallery-size)\b/', $parent->getAttribute( 'class' ) ) ) {
428 $node = $parent;
429 break;
430 }
431 }
432
433 if (
434 'img' === $tag_option
435 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
436 && $node->parentNode instanceof DOMElement
437 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
438 && 'a' === $node->parentNode->tagName
439 && ! $before
440 ) {
441 // Reference grandparent to inject after the link rather than inside it.
442 // The original code referenced without assigning — preserving that behaviour.
443 $node->parentNode->parentNode; // phpcs:ignore
444 }
445
446 return $node;
447 }
448
449 /**
450 * Insert imported nodes into the DOM at the correct position.
451 *
452 * The caller must unset() the ad DOM tree immediately after this call.
453 *
454 * @param DOMDocument $dom Main document.
455 * @param DOMNode $node Reference node.
456 * @param array $ad_nodes Snapshot of ad body child nodes.
457 * @param string $position One of 'before', 'after', 'append', 'prepend'.
458 */
459 private static function insert_nodes( DOMDocument $dom, DOMNode $node, array $ad_nodes, $position ) {
460 switch ( $position ) {
461 case 'append':
462 foreach ( $ad_nodes as $ad_node ) {
463 $node->appendChild( $dom->importNode( $ad_node, true ) );
464 }
465 break;
466
467 case 'prepend':
468 // Cache firstChild once — insertBefore() shifts it on each call.
469 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
470 $first_child = $node->firstChild;
471 foreach ( $ad_nodes as $ad_node ) {
472 $node->insertBefore( $dom->importNode( $ad_node, true ), $first_child );
473 }
474 break;
475
476 case 'before':
477 foreach ( $ad_nodes as $ad_node ) {
478 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
479 $node->parentNode->insertBefore( $dom->importNode( $ad_node, true ), $node );
480 }
481 break;
482
483 case 'after':
484 default:
485 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
486 $ref_node = $node->nextSibling;
487 if ( null !== $ref_node ) {
488 foreach ( $ad_nodes as $ad_node ) {
489 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
490 $ref_node->parentNode->insertBefore( $dom->importNode( $ad_node, true ), $ref_node );
491 }
492 } else {
493 foreach ( $ad_nodes as $ad_node ) {
494 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
495 $node->parentNode->appendChild( $dom->importNode( $ad_node, true ) );
496 }
497 }
498 break;
499 }
500 }
501
502 /**
503 * Get content to load into DOM.
504 *
505 * @param string $content Original content.
506 *
507 * @return string|false Content ready for DOM parsing, or false on empty.
508 */
509 private static function get_content_to_load( $content ) {
510 $content_to_load = preg_replace( '/<script.*?<\/script>/si', '<!--\0-->', $content );
511
512 $wpautop_priority = has_filter( 'the_content', 'wpautop' );
513 if ( $wpautop_priority && Advanced_Ads::get_instance()->get_content_injection_priority() < $wpautop_priority ) {
514 $content_to_load = wpautop( $content_to_load );
515 }
516
517 return $content_to_load;
518 }
519
520 /**
521 * Filter ad content and register it as a placeholder.
522 *
523 * @param string $ad_content Ad HTML content.
524 * @param string $tag_name Tag name beside which the ad is injected.
525 * @param array $options Injection options.
526 *
527 * @return string Placeholder token string.
528 */
529 private static function filter_ad_content( $ad_content, $tag_name, $options ) {
530 // Only run the costly regex when the pattern can possibly match.
531 if ( str_contains( $ad_content, 'document.write' ) ) {
532 $ad_content = preg_replace( '#(document.write.+)</(.*)#', '$1<\/$2', $ad_content );
533 }
534
535 $id = count( self::$ads_for_placeholders );
536 self::$ads_for_placeholders[] = [
537 'id' => $id,
538 'tag' => $tag_name,
539 'position' => $options['position'],
540 'ad' => $ad_content,
541 ];
542
543 return '%advads_placeholder_' . $id . '%';
544 }
545
546 /**
547 * Prepare output and flush the static placeholder store.
548 *
549 * @param string $content Modified (DOM-serialized) content with placeholders.
550 * @param string $content_orig Unmodified original content.
551 *
552 * @return string Final content with ads injected.
553 */
554 private static function prepare_output( $content, $content_orig ) {
555 $content = self::inject_ads( $content, $content_orig, self::$ads_for_placeholders );
556 self::$ads_for_placeholders = [];
557 return $content;
558 }
559
560 /**
561 * Locate placeholder positions in the modified content and inject ads at the
562 * corresponding positions in the original content.
563 *
564 * Memory strategy:
565 * - $content (DOM version) is unset as soon as the placeholder scan is done.
566 * - $new_content is built incrementally using substr() slices of $content_orig —
567 * we never duplicate $content_orig into another variable.
568 * - Match arrays are unset immediately after use.
569 *
570 * @param string $content DOM-serialized content with ad placeholders.
571 * @param string $content_orig Unmodified original content.
572 * @param array $ads_for_placeholders Ad metadata array.
573 *
574 * @return string Content with ads injected.
575 */
576 private static function inject_ads( $content, $content_orig, $ads_for_placeholders ) {
577 // Normalize self-closing tag positions.
578 foreach ( $ads_for_placeholders as &$ad ) {
579 if (
580 ( 'prepend' === $ad['position'] || 'append' === $ad['position'] )
581 && isset( self::SELF_CLOSING_TAGS[ $ad['tag'] ] )
582 ) {
583 $ad['position'] = 'after';
584 }
585 }
586 unset( $ad );
587
588 usort( $ads_for_placeholders, [ 'Advanced_Ads_In_Content_Injector', 'sort_ads_for_placehoders' ] );
589
590 // Build tag patterns — skip duplicates during construction, not after.
591 $alts = [];
592 foreach ( $ads_for_placeholders as $ad ) {
593 $tag = $ad['tag'];
594
595 switch ( $ad['position'] ) {
596 case 'before':
597 case 'prepend':
598 $pattern = "<{$tag}[^>]*>";
599 break;
600 case 'after':
601 $pattern = isset( self::SELF_CLOSING_TAGS[ $tag ] ) ? "<{$tag}[^>]*>" : "</{$tag}>";
602 break;
603 case 'append':
604 $pattern = "</{$tag}>";
605 break;
606 default:
607 $pattern = null;
608 }
609
610 if ( null !== $pattern && ! in_array( $pattern, $alts, true ) ) {
611 $alts[] = $pattern;
612 }
613 }
614
615 $tag_regexp = implode( '|', $alts );
616 $alts[] = '%advads_placeholder_(?:\d+)%';
617 $tag_and_placeholder_regexp = implode( '|', $alts );
618 unset( $alts );
619
620 // Phase 1: scan DOM-serialized content to map placeholder → tag occurrence index.
621 preg_match_all( "#{$tag_and_placeholder_regexp}#i", $content, $tag_matches );
622
623 // $content (the DOM version, potentially larger than $content_orig) is no
624 // longer needed — free it before we allocate $orig_tag_matches below.
625 unset( $content );
626
627 $count = 0;
628 foreach ( $tag_matches[0] as $r ) {
629 if ( preg_match( '/%advads_placeholder_(\d+)%/', $r, $result ) ) {
630 $id = (int) $result[1];
631 foreach ( $ads_for_placeholders as $n => $ad ) {
632 if ( (int) $ad['id'] === $id ) {
633 $ads_for_placeholders[ $n ]['offset'] = $count - 1;
634 if ( 'before' === $ad['position'] || 'append' === $ad['position'] ) {
635 $ads_for_placeholders[ $n ]['offset'] = $count;
636 }
637 break;
638 }
639 }
640 } else {
641 ++$count;
642 }
643 }
644 unset( $tag_matches );
645
646 // Phase 2: find byte offsets of injection tags in the original content.
647 preg_match_all( "#{$tag_regexp}#i", $content_orig, $orig_tag_matches, PREG_OFFSET_CAPTURE );
648
649 // Phase 3: build output by streaming substrings of $content_orig.
650 // We never copy $content_orig — only slice it with substr().
651 $new_content = '';
652 $pos = 0;
653
654 foreach ( $orig_tag_matches[0] as $n => $r ) {
655 $to_inject = [];
656 foreach ( $ads_for_placeholders as $ad ) {
657 if ( isset( $ad['offset'] ) && $ad['offset'] === $n ) {
658 $to_inject[] = $ad;
659 }
660 }
661
662 foreach ( $to_inject as $item ) {
663 $found_pos = ( 'before' === $item['position'] || 'append' === $item['position'] )
664 ? $r[1]
665 : $r[1] + strlen( $r[0] );
666
667 $new_content .= substr( $content_orig, $pos, $found_pos - $pos );
668 $pos = $found_pos;
669 $new_content .= $item['ad'];
670 }
671 }
672
673 unset( $orig_tag_matches, $ads_for_placeholders );
674
675 $new_content .= substr( $content_orig, $pos );
676
677 return $new_content;
678 }
679
680 /**
681 * Callback for usort() — sorts ads by position priority.
682 * Uses the POSITION_ORDER constant so no array is allocated per comparison.
683 *
684 * @param array $first First ad.
685 * @param array $second Second ad.
686 *
687 * @return int
688 */
689 public static function sort_ads_for_placehoders( $first, $second ) {
690 $a = self::POSITION_ORDER[ $first['position'] ] ?? 99;
691 $b = self::POSITION_ORDER[ $second['position'] ] ?? 99;
692 return $a <=> $b;
693 }
694
695 /**
696 * Add a warning node to the Ad Health bar.
697 *
698 * @param array $nodes Existing nodes.
699 *
700 * @return array Modified nodes.
701 */
702 public static function add_ad_health_node( $nodes ) {
703 $nodes[] = [
704 'type' => 1,
705 'data' => [
706 'parent' => 'advanced_ads_ad_health',
707 'id' => 'advanced_ads_ad_health_the_content_not_enough_elements',
708 'title' => sprintf(
709 /* translators: %s stands for the name of the "Disable level limitation" option */
710 __( 'Set <em>%s</em> to show more ads', 'advanced-ads' ),
711 __( 'Disable level limitation', 'advanced-ads' )
712 ),
713 'href' => admin_url( '/admin.php?page=advanced-ads-settings#top#general' ),
714 'meta' => [
715 'class' => 'advanced_ads_ad_health_warning',
716 'target' => '_blank',
717 ],
718 ],
719 ];
720 return $nodes;
721 }
722
723 /**
724 * Get paths of ancestor nodes that should not contain ads.
725 *
726 * @param DOMXPath $xpath DOMXPath object.
727 *
728 * @return array Node path strings.
729 */
730 private static function get_ancestors_to_limit( DOMXPath $xpath ) {
731 $query = self::get_ancestors_to_limit_query();
732 if ( ! $query ) {
733 return [];
734 }
735
736 $node_list = $xpath->query( $query );
737 $ancestors_to_limit = [];
738
739 foreach ( $node_list as $a ) {
740 $ancestors_to_limit[] = $a->getNodePath();
741 }
742
743 unset( $node_list );
744 return $ancestors_to_limit;
745 }
746
747 /**
748 * Remove paragraphs whose ancestors should not contain ads.
749 *
750 * Short-circuits immediately when no restricted ancestors exist,
751 * avoiding the inner loop entirely.
752 *
753 * Uses Str::starts_with() from Framework instead of stripos()
754 * since node paths are case-sensitive.
755 *
756 * @param array $paragraphs Array of DOMNode objects.
757 * @param array $ancestors_to_limit Node paths of restricted ancestors.
758 *
759 * @return array Filtered array of DOMNode objects.
760 */
761 private static function filter_by_ancestors_to_limit( $paragraphs, $ancestors_to_limit ) {
762 if ( empty( $ancestors_to_limit ) ) {
763 return $paragraphs;
764 }
765
766 $new_paragraphs = [];
767
768 foreach ( $paragraphs as $paragraph ) {
769 $node_path = $paragraph->getNodePath();
770 foreach ( $ancestors_to_limit as $ancestor ) {
771 if ( Str::starts_with( $ancestor . '/', $node_path ) ) {
772 continue 2;
773 }
774 }
775 $new_paragraphs[] = $paragraph;
776 }
777
778 return $new_paragraphs;
779 }
780
781 /**
782 * Build the XPath query to select ancestors that should not contain ads.
783 *
784 * @return string|false XPath query string, or false if none configured.
785 */
786 private static function get_ancestors_to_limit_query() {
787 $items = apply_filters(
788 'advanced-ads-content-injection-nodes-without-ads',
789 [
790 [ 'node' => '.advads-stop-injection', 'type' => 'ancestor' ],
791 [ 'node' => '.woopack-product-carousel', 'type' => 'ancestor' ],
792 [ 'node' => '#wpautbox-%', 'type' => 'ancestor' ],
793 [ 'node' => '.geodir-post-slider', 'type' => 'ancestor' ],
794 ]
795 );
796
797 $query = [];
798
799 foreach ( $items as $p ) {
800 $sel = $p['node'];
801 $sel_type = $sel[0];
802 $sel = substr( $sel, 1 );
803 $rand_pos = strpos( $sel, '%' );
804 $sel = sanitize_html_class( str_replace( '%', '', $sel ) );
805
806 if ( '.' === $sel_type ) {
807 $query[] = false !== $rand_pos
808 ? "@class and contains(concat(' ', normalize-space(@class), ' '), ' $sel')"
809 : "@class and contains(concat(' ', normalize-space(@class), ' '), ' $sel ')";
810 } elseif ( '#' === $sel_type ) {
811 $query[] = false !== $rand_pos
812 ? "@id and starts-with(@id, '$sel')"
813 : "@id and @id = '$sel'";
814 }
815 }
816
817 return $query ? '//*[' . implode( ' or ', $query ) . ']' : false;
818 }
819
820 /**
821 * Un-texturize the text.
822 *
823 * @param string $text Text to un-texturize.
824 *
825 * @return string Un-texturized text.
826 */
827 private static function wp_untexturize( $text ) {
828 return str_replace(
829 [ '', '', '', '' ],
830 [ '"', '"', "'", "'" ],
831 $text
832 );
833 }
834 }
835