PluginProbe
Filter Everything — WordPress & WooCommerce Filters / trunk
Filter Everything — WordPress & WooCommerce Filters vtrunk
1.9.6 1.9.5 1.9.4 1.9.3 1.9.2.2 1.9.2.1 trunk 1.2.1 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.1 1.4.4 1.4.5 1.4.8 1.4.9 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 All 51 releases
filter-everything / src / RobotsTxt.php

RobotsTxt.php in Filter Everything — WordPress & WooCommerce Filters trunk, at src/RobotsTxt.php

385 lines 13.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * robots.txt helper — keeps well-behaved crawlers away from filtering result pages.
4 *
5 * Why this exists: filter combinations produce a practically unlimited number of
6 * URLs, and bots (search engines, AI crawlers, scrapers) that once discover them
7 * keep requesting them for months — even after the filter links are hidden or the
8 * plugin is removed. Hiding the links (see «Disable filter links for crawlers»)
9 * stops new discovery; the only signal that also stops compliant crawlers from
10 * re-fetching URLs they already know is a robots.txt Disallow. That is Google's own
11 * recommendation for faceted navigation: disallow the URL patterns rather than rely
12 * on noindex (a noindex page still costs a full crawl request).
13 *
14 * Two URL schemes, two rule sets:
15 *
16 * QUERY MODE (free build, or PRO with pretty permalinks off) — every filter URL is
17 * fully described by its parameter names (?color=red&min_price=10), so robots.txt
18 * can express "any URL carrying a filter parameter" precisely:
19 * Disallow: /*?color=
20 * Disallow: /*&color=
21 * (two patterns per parameter: first in the query string or after another one —
22 * deliberately NOT the looser /*?*color= form, which would also match unrelated
23 * parameters that merely end in the same word, e.g. ?product_color=).
24 *
25 * PRETTY MODE (PRO) — filters travel as path segments (/shop/color-red/size-xl/)
26 * and SEO Rules may make some of those pages indexable, so the rules must only
27 * name the classes of URL the plugin never indexes (SeoFrontend::isNoindex()):
28 * - numeric/date ranges and the in-filter search term (still query params);
29 * - several values of one filter (…/color-red-or-blue/);
30 * - more filters in one URL than the highest Indexing Depth allows;
31 * - and, when no post type has an Indexing Depth at all (nothing is
32 * indexable), every filter segment outright.
33 * Single-filter pages stay crawlable otherwise (commented suggestions show how
34 * to block them too). Path patterns match a segment that STARTS with a filter
35 * prefix, so a product/category slug beginning with the same word would match
36 * as well — the Settings box shows the rules for review before anything is
37 * added.
38 *
39 * The generated block is
40 * - appended to WordPress' virtual /robots.txt while the option is on (the filter
41 * has no effect when a physical robots.txt file exists — WordPress never serves
42 * the virtual one then);
43 * - shown on Settings → General so it can be copied into a physical file.
44 */
45
46 if ( ! defined('ABSPATH') ) {
47 exit;
48 }
49
50 if ( ! function_exists( 'flrt_robots_helper_available' ) ) {
51 /**
52 * Whether the robots.txt helper (option, rules box, robots_txt hook) is active
53 * in this build. Both builds since 1.9.6; kept as a single switch/filter.
54 *
55 * @return bool
56 */
57 function flrt_robots_helper_available()
58 {
59 return (bool) apply_filters( 'wpc_robots_helper_available', true );
60 }
61 }
62
63 if ( ! function_exists( 'flrt_robots_pretty_mode' ) ) {
64 /**
65 * True when filters travel as path segments (PRO with pretty permalinks).
66 *
67 * @return bool
68 */
69 function flrt_robots_pretty_mode()
70 {
71 return defined( 'FLRT_PERMALINKS_ENABLED' ) && FLRT_PERMALINKS_ENABLED;
72 }
73 }
74
75 if ( ! function_exists( 'flrt_robots_registry' ) ) {
76 /**
77 * The URL-prefix registry (option wpc_filter_permalinks, 'entity#ename' => slug)
78 * in URL order, as [ ['entity' => …, 'slug' => …], … ] with empty slugs dropped.
79 *
80 * @return array[]
81 */
82 function flrt_robots_registry()
83 {
84 $registry = get_option( 'wpc_filter_permalinks', [] );
85
86 if ( ! is_array( $registry ) ) {
87 $registry = maybe_unserialize( $registry );
88 }
89 if ( ! is_array( $registry ) ) {
90 $registry = [];
91 }
92
93 $entries = [];
94
95 foreach ( $registry as $entityAndEname => $slug ) {
96 // Same normalisation as UrlManager::getParamName()
97 $slug = sanitize_title( (string) $slug );
98 if ( $slug === '' ) {
99 continue;
100 }
101 $parts = explode( '#', (string) $entityAndEname, 2 );
102 $entries[] = [ 'entity' => $parts[0], 'slug' => $slug ];
103 }
104
105 return $entries;
106 }
107 }
108
109 if ( ! function_exists( 'flrt_robots_filter_params' ) ) {
110 /**
111 * Query-string parameter names the plugin's filter URLs can carry.
112 *
113 * Built the same way UrlManager/RequestParser build and read them: numeric
114 * entities travel as min_{slug}/max_{slug}, date entities as {slug}_from/{slug}_to
115 * and — in query mode only — everything else as the bare slug. The in-filter
116 * search term («srch») always travels with filter URLs too.
117 *
118 * @param bool|null $pretty_mode null = detect; true = only the parameters that
119 * stay in the query string with pretty permalinks
120 * @return string[] sorted, unique parameter names
121 */
122 function flrt_robots_filter_params( $pretty_mode = null )
123 {
124 if ( $pretty_mode === null ) {
125 $pretty_mode = flrt_robots_pretty_mode();
126 }
127
128 $params = [];
129
130 foreach ( flrt_robots_registry() as $entry ) {
131 $slug = $entry['slug'];
132
133 switch ( $entry['entity'] ) {
134 case 'post_meta_num':
135 case 'tax_numeric':
136 $params[] = 'min_' . $slug;
137 $params[] = 'max_' . $slug;
138 break;
139 case 'post_date':
140 case 'post_meta_date':
141 $params[] = $slug . '_from';
142 $params[] = $slug . '_to';
143 break;
144 default:
145 if ( ! $pretty_mode ) {
146 $params[] = $slug;
147 }
148 }
149 }
150
151 if ( ! empty( flrt_robots_registry() ) ) {
152 $params[] = 'srch';
153 }
154
155 $params = array_values( array_unique( $params ) );
156 sort( $params, SORT_STRING );
157
158 return apply_filters( 'wpc_robots_filter_params', $params );
159 }
160 }
161
162 if ( ! function_exists( 'flrt_robots_path_prefixes' ) ) {
163 /**
164 * Prefixes of the filters that travel as path segments, in URL order (the
165 * registry order IS the canonical segment order — any other order 404s).
166 *
167 * @return string[]
168 */
169 function flrt_robots_path_prefixes()
170 {
171 $prefixes = [];
172
173 foreach ( flrt_robots_registry() as $entry ) {
174 if ( in_array( $entry['entity'], [ 'post_meta_num', 'tax_numeric', 'post_date', 'post_meta_date' ], true ) ) {
175 continue;
176 }
177 $prefixes[] = $entry['slug'];
178 }
179
180 return array_values( array_unique( $prefixes ) );
181 }
182 }
183
184 if ( ! function_exists( 'flrt_robots_indexing_depth' ) ) {
185 /**
186 * The highest Indexing Depth configured for any post type (PRO option
187 * wpc_indexing_deep_settings), i.e. the most filters a single indexable URL
188 * may carry anywhere on the site. 0 = no filter page is indexable.
189 *
190 * @return int
191 */
192 function flrt_robots_indexing_depth()
193 {
194 $depth = 0;
195 $options = get_option( 'wpc_indexing_deep_settings', [] );
196
197 if ( is_array( $options ) ) {
198 foreach ( $options as $value ) {
199 $depth = max( $depth, (int) $value );
200 }
201 }
202
203 return (int) apply_filters( 'wpc_robots_indexing_depth', $depth );
204 }
205 }
206
207 if ( ! function_exists( 'flrt_robots_combinations' ) ) {
208 /**
209 * All ordered k-combinations of $items (order preserved), or null when there
210 * would be more than $limit of them.
211 *
212 * @return array[]|null
213 */
214 function flrt_robots_combinations( array $items, $k, $limit )
215 {
216 $n = count( $items );
217 if ( $k < 1 || $k > $n ) {
218 return [];
219 }
220
221 // C(n, k) without overflow: stop counting once past the limit
222 $count = 1;
223 for ( $i = 1; $i <= $k; $i++ ) {
224 $count = $count * ( $n - $k + $i ) / $i;
225 if ( $count > $limit ) {
226 return null;
227 }
228 }
229
230 $result = [];
231 $indexes = range( 0, $k - 1 );
232
233 while ( true ) {
234 $combo = [];
235 foreach ( $indexes as $i ) {
236 $combo[] = $items[ $i ];
237 }
238 $result[] = $combo;
239
240 // advance
241 $i = $k - 1;
242 while ( $i >= 0 && $indexes[ $i ] === $n - $k + $i ) {
243 $i--;
244 }
245 if ( $i < 0 ) {
246 break;
247 }
248 $indexes[ $i ]++;
249 for ( $j = $i + 1; $j < $k; $j++ ) {
250 $indexes[ $j ] = $indexes[ $j - 1 ] + 1;
251 }
252 }
253
254 return $result;
255 }
256 }
257
258 if ( ! function_exists( 'flrt_robots_txt_rules' ) ) {
259 /**
260 * The robots.txt block for the current filters, or '' when there are no
261 * filters yet. A repeated "User-agent: *" group is fine: crawlers merge groups
262 * that address the same user agent.
263 *
264 * @return string
265 */
266 function flrt_robots_txt_rules()
267 {
268 $pretty = flrt_robots_pretty_mode();
269 $params = flrt_robots_filter_params( $pretty );
270 $prefixes = $pretty ? flrt_robots_path_prefixes() : [];
271
272 if ( empty( $params ) && empty( $prefixes ) ) {
273 return '';
274 }
275
276 $lines = [];
277 $lines[] = '# Filter Everything: do not crawl filtering result pages';
278 $lines[] = 'User-agent: *';
279
280 // 1) Query-string parameters — in query mode that is every filter; with
281 // pretty permalinks only the ranges/dates/search that never index.
282 foreach ( $params as $param ) {
283 $lines[] = 'Disallow: /*?' . $param . '=';
284 $lines[] = 'Disallow: /*&' . $param . '=';
285 }
286
287 // 2) Path segments (pretty permalinks)
288 if ( $pretty && ! empty( $prefixes ) ) {
289 $depth = flrt_robots_indexing_depth();
290
291 if ( $depth < 1 ) {
292 // Nothing on this site indexes filter pages: block every filter
293 // segment. (A product or category slug that starts with one of
294 // these prefixes would match the same pattern — review the list.)
295 $lines[] = '# No Indexing Depth is set, so no filtering result page is indexable: block every filter segment';
296 foreach ( $prefixes as $prefix ) {
297 $lines[] = 'Disallow: /*/' . $prefix . '-';
298 }
299 } else {
300 // 2a) several values of one filter — never indexable
301 $lines[] = '# Several values of one filter (…/color-red-or-blue/) are never indexable';
302 foreach ( $prefixes as $prefix ) {
303 foreach ( [ '-or-', '-and-' ] as $separator ) {
304 $lines[] = 'Disallow: /*/' . $prefix . '-*' . $separator;
305 }
306 }
307
308 // 2b) more filters in one URL than the Indexing Depth allows
309 $size = $depth + 1;
310 $combos = flrt_robots_combinations( $prefixes, $size, 300 );
311
312 if ( $combos === null ) {
313 $lines[] = sprintf( '# URLs with more than %d filters are never indexable, but there are too many prefix combinations to list them here', $depth );
314 } elseif ( ! empty( $combos ) ) {
315 $lines[] = sprintf( '# URLs with more than %d filters (your highest Indexing Depth) are never indexable', $depth );
316 foreach ( $combos as $combo ) {
317 $pattern = '';
318 foreach ( $combo as $prefix ) {
319 $pattern .= '/*/' . $prefix . '-';
320 }
321 // "/*/a-/*/b-" → "/*/a-*/b-": the wildcard between segments
322 $lines[] = 'Disallow: ' . str_replace( '-/*/', '-*/', $pattern );
323 }
324 }
325
326 // 2c) single-filter pages stay crawlable — they are the ones SEO
327 // Rules index; leave the opt-in as commented lines
328 $lines[] = '# Single-filter pages stay crawlable because your SEO Rules may index them; to block them too, remove the # below';
329 foreach ( $prefixes as $prefix ) {
330 $lines[] = '# Disallow: /*/' . $prefix . '-';
331 }
332 }
333 }
334
335 $rules = implode( "\n", $lines );
336
337 return (string) apply_filters( 'wpc_robots_txt_rules', $rules, $params, $prefixes );
338 }
339 }
340
341 if ( ! function_exists( 'flrt_robots_txt_is_physical' ) ) {
342 /**
343 * True when a physical robots.txt file exists in the WordPress root. WordPress
344 * serves the virtual robots.txt (and runs the robots_txt filter) only when it
345 * does not.
346 *
347 * @return bool
348 */
349 function flrt_robots_txt_is_physical()
350 {
351 return file_exists( ABSPATH . 'robots.txt' );
352 }
353 }
354
355 if ( ! function_exists( 'flrt_robots_txt_append_rules' ) ) {
356 /**
357 * robots_txt filter callback: appends the filter rules to WordPress' virtual
358 * robots.txt while the option is on.
359 *
360 * @param string $output robots.txt content so far
361 * @param bool $public blog_public option; when false WordPress already emits
362 * "Disallow: /" for everyone and our rules are redundant
363 * @return string
364 */
365 function flrt_robots_txt_append_rules( $output, $public )
366 {
367 if ( ! $public || ! flrt_robots_helper_available() ) {
368 return $output;
369 }
370
371 if ( flrt_get_option( 'robots_txt_block_filters' ) !== 'on' ) {
372 return $output;
373 }
374
375 $rules = flrt_robots_txt_rules();
376 if ( $rules === '' ) {
377 return $output;
378 }
379
380 return rtrim( (string) $output ) . "\n\n" . $rules . "\n";
381 }
382 }
383
384 add_filter( 'robots_txt', 'flrt_robots_txt_append_rules', 20, 2 );
385