PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-feed-manager.php

class-feed-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/seo/class-feed-manager.php

249 lines 7.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * What ThinkRank does to the site's RSS feeds.
4 *
5 * @package ThinkRank
6 * @since 2.7.0
7 */
8
9 declare(strict_types=1);
10
11 namespace ThinkRank\SEO;
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 /**
18 * Feed controls: shorten entries, sign them, and keep them out of the index.
19 *
20 * WordPress publishes a feed of every site, and content scrapers read it to
21 * republish posts wholesale. The standard defence — shipped by The SEO
22 * Framework, Yoast and Rank Math — is three small controls, and ThinkRank had
23 * none of them, which made a ThinkRank site the easy target among the big
24 * plugins.
25 *
26 * - **Shorten entries to an excerpt.** A scraper that copies the feed gets an
27 * intro rather than the article.
28 * - **Sign each entry with a link home.** The useful half: a scraped copy
29 * carries a link back to the original, which is worth more than the copy
30 * costs.
31 * - **Keep feeds out of the index.** A feed URL competing with the post it
32 * duplicates helps nobody. Deliberately a choice rather than a hardcode:
33 * podcast sites need their feed indexable, which is the caveat The SEO
34 * Framework documents for its own version of this.
35 *
36 * All three default off, so an upgrade changes nothing about what an existing
37 * site already publishes to its subscribers. A brand-new install is seeded with
38 * the signature and the noindex on ({@see Activator::seed_feed_defaults()}),
39 * which is where matching the competitors' out-of-the-box posture belongs.
40 *
41 * @since 2.7.0
42 */
43 class Feed_Manager {
44
45 /**
46 * Site identity settings, read once per request.
47 *
48 * @since 2.7.0
49 * @var array|null
50 */
51 private $settings = null;
52
53 /**
54 * Register the feed hooks.
55 *
56 * @since 2.7.0
57 * @return void
58 */
59 public function init(): void {
60 // Early, so the header is set before anything can start the body.
61 add_action('template_redirect', [$this, 'maybe_noindex_feed'], 1);
62
63 add_filter('the_content_feed', [$this, 'filter_content'], 20);
64 add_filter('the_excerpt_rss', [$this, 'filter_excerpt'], 20);
65 }
66
67 /**
68 * Ask crawlers not to index a feed.
69 *
70 * A header rather than a robots.txt rule: a disallowed URL is never fetched,
71 * so the crawler never learns it is not to be indexed and the URL can still
72 * surface from links elsewhere. `follow` is kept so the links inside the
73 * feed still count.
74 *
75 * @since 2.7.0
76 * @return void
77 */
78 public function maybe_noindex_feed(): void {
79 if (!is_feed() || headers_sent()) {
80 return;
81 }
82
83 if (empty($this->settings()['feed_noindex'])) {
84 return;
85 }
86
87 header('X-Robots-Tag: noindex, follow', true);
88 }
89
90 /**
91 * The body of a feed entry.
92 *
93 * @since 2.7.0
94 * @param mixed $content Entry content.
95 * @return mixed
96 */
97 public function filter_content($content) {
98 // Untyped: this value belongs to whoever filtered it before us, and
99 // coercing another plugin's object or null to a string would drop it.
100 if (!is_string($content)) {
101 return $content;
102 }
103
104 $settings = $this->settings();
105
106 if (!empty($settings['feed_excerpt_only'])) {
107 $excerpt = $this->excerpt();
108
109 if ('' !== $excerpt) {
110 $content = $excerpt;
111 }
112 }
113
114 return $content . $this->source_link($settings);
115 }
116
117 /**
118 * The excerpt of a feed entry.
119 *
120 * Signed as well as the body: which of the two a reader sees depends on the
121 * feed template and on the site's Reading setting, and an unsigned entry is
122 * exactly the one a scraper wants.
123 *
124 * @since 2.7.0
125 * @param mixed $excerpt Entry excerpt.
126 * @return mixed
127 */
128 public function filter_excerpt($excerpt) {
129 if (!is_string($excerpt)) {
130 return $excerpt;
131 }
132
133 return $excerpt . $this->source_link($this->settings());
134 }
135
136 /**
137 * The line that points a scraped copy back here.
138 *
139 * @since 2.7.0
140 * @param array $settings Site identity settings.
141 * @return string HTML, or '' when the feature is off.
142 */
143 private function source_link(array $settings): string {
144 if (empty($settings['feed_source_link'])) {
145 return '';
146 }
147
148 $post_id = get_the_ID();
149
150 if (!$post_id) {
151 return '';
152 }
153
154 $permalink = (string) get_permalink($post_id);
155 $home = home_url('/');
156
157 if ('' === $permalink) {
158 return '';
159 }
160
161 $html = sprintf(
162 '<p>%s</p>',
163 sprintf(
164 /* translators: 1: link to the original post, 2: link to the site. */
165 esc_html__('The post %1$s first appeared on %2$s.', 'thinkrank'),
166 '<a href="' . esc_url($permalink) . '">' . esc_html(get_the_title($post_id)) . '</a>',
167 '<a href="' . esc_url($home) . '">' . esc_html(get_bloginfo('name')) . '</a>'
168 )
169 );
170
171 /**
172 * Filter the source line appended to each feed entry.
173 *
174 * @since 2.7.0
175 *
176 * @param string $html The line, as HTML.
177 * @param int $post_id Post the entry describes.
178 */
179 $filtered = apply_filters('thinkrank_feed_source_link', $html, $post_id);
180
181 // Same reasoning as filter_content() above, from the other side: a
182 // caller who returns an array gets `Array to string conversion` and the
183 // literal string "Array" appended to every entry in the feed. Take the
184 // filtered value only when it is one, and keep our own line otherwise.
185 if (is_string($filtered)) {
186 $html = $filtered;
187 }
188
189 // Feed bodies are wrapped in CDATA by the feed templates, and a literal
190 // ]]> inside one ends the section early and breaks the whole document.
191 return str_replace(']]>', ']]&gt;', $html);
192 }
193
194 /**
195 * The excerpt to stand in for a full entry.
196 *
197 * Falls back to one derived from the content when the author wrote none,
198 * because the point of the setting is that the full text does not go out.
199 *
200 * @since 2.7.0
201 * @return string
202 */
203 private function excerpt(): string {
204 $post_id = get_the_ID();
205
206 if (!$post_id) {
207 return '';
208 }
209
210 // Core already shortens entries when the Reading setting says
211 // "Summary". Shortening an excerpt again would cut a short entry down
212 // to almost nothing, so this defers to it.
213 if (get_option('rss_use_excerpt')) {
214 return '';
215 }
216
217 $excerpt = trim((string) get_the_excerpt($post_id));
218
219 return '' === $excerpt ? '' : wpautop($excerpt);
220 }
221
222 /**
223 * Site identity settings, read once.
224 *
225 * @since 2.7.0
226 * @return array
227 */
228 private function settings(): array {
229 if (null === $this->settings) {
230 $this->settings = $this->load_settings();
231 }
232
233 return $this->settings;
234 }
235
236 /**
237 * Read the settings.
238 *
239 * Its own method so the filters can be exercised without a settings store;
240 * what matters here is which switch changed which part of an entry.
241 *
242 * @since 2.7.0
243 * @return array
244 */
245 protected function load_settings(): array {
246 return (array) (new Site_Identity_Manager())->get_settings('site', null);
247 }
248 }
249