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-social-images.php

class-social-images.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-social-images.php

336 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The set of images a page offers to Open Graph consumers.
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 * Collects the secondary images that follow the primary `og:image`.
19 *
20 * Open Graph allows several `og:image` tags on one page and consumers treat
21 * the first as primary: Facebook lets the sharer pick between them, and others
22 * fall back down the list when the first fails validation (too small, wrong
23 * aspect, 404 behind a CDN). ThinkRank published exactly one, so a post whose
24 * social image failed validation shared with no image at all rather than with
25 * its second-best one.
26 *
27 * This produces everything *after* the primary, in the order the issue asked
28 * for: the featured image (when a per-post override displaced it), then images
29 * found in the content. The primary itself is resolved elsewhere and passed in,
30 * so there is one answer to "what is the main image" and this cannot disagree
31 * with it.
32 *
33 * Twitter and schema are deliberately not involved. A Twitter card renders one
34 * image and schema's `image` identifies the page's primary; handing either a
35 * list would change what they mean, not enrich it.
36 *
37 * @since 2.7.0
38 */
39 class Social_Images {
40
41 /**
42 * Total `og:image` tags a page may carry, primary included.
43 *
44 * Four is enough to give a sharer a real choice without turning the head
45 * into a gallery; consumers read the list in order and stop at the first
46 * that validates, so length past this point buys nothing.
47 *
48 * @since 2.7.0
49 * @var int
50 */
51 public const DEFAULT_LIMIT = 4;
52
53 /**
54 * Every image after the primary, ready to emit.
55 *
56 * @since 2.7.0
57 *
58 * @param int $post_id Post being rendered.
59 * @param string $primary_url The `og:image` already resolved for the page.
60 * @return array<int, array<string, mixed>> Each with url, and width/height/
61 * type/alt where they are known.
62 */
63 public static function additional(int $post_id, string $primary_url): array {
64 if ($post_id <= 0) {
65 return [];
66 }
67
68 /**
69 * Filter how many og:image tags a page may carry in total.
70 *
71 * @since 2.7.0
72 *
73 * @param int $limit Maximum tags, primary included.
74 * @param int $post_id Post being rendered.
75 */
76 $limit = (int) apply_filters('thinkrank_og_image_limit', self::DEFAULT_LIMIT, $post_id);
77
78 // One slot is the primary's, so nothing below can be offered unless the
79 // site asked for at least two.
80 if ($limit < 2) {
81 return [];
82 }
83
84 // The primary occupies the first slot whether or not it was resolvable
85 // here, so it seeds the seen-list and can never be offered twice.
86 $seen = [];
87
88 if ('' !== $primary_url) {
89 $seen[self::fingerprint($primary_url)] = true;
90 }
91
92 $images = [];
93
94 foreach (self::candidates($post_id) as $url) {
95 if (count($images) >= $limit - 1) {
96 break;
97 }
98
99 $url = self::usable($url);
100
101 if ('' === $url) {
102 continue;
103 }
104
105 $print = self::fingerprint($url);
106
107 if (isset($seen[$print])) {
108 continue;
109 }
110
111 $seen[$print] = true;
112 $images[] = self::describe($url, $post_id);
113 }
114
115 /**
116 * Filter the secondary Open Graph images for a page.
117 *
118 * @since 2.7.0
119 *
120 * @param array $images Secondary images, primary excluded.
121 * @param int $post_id Post being rendered.
122 */
123 return (array) apply_filters('thinkrank_og_extra_images', $images, $post_id);
124 }
125
126 /**
127 * Candidate URLs, in the order they should be offered.
128 *
129 * @since 2.7.0
130 * @param int $post_id Post being rendered.
131 * @return array<int, string>
132 */
133 private static function candidates(int $post_id): array {
134 $urls = [];
135
136 // The featured image. Usually this IS the primary and the fingerprint
137 // check drops it; it matters when a per-post social override displaced
138 // it, which is exactly the case where a second image is worth offering.
139 $thumbnail_id = (int) get_post_thumbnail_id($post_id);
140
141 if ($thumbnail_id) {
142 $src = wp_get_attachment_image_src($thumbnail_id, 'large');
143
144 if (is_array($src) && !empty($src[0])) {
145 $urls[] = (string) $src[0];
146 }
147 }
148
149 foreach (self::from_content($post_id) as $url) {
150 $urls[] = $url;
151 }
152
153 return $urls;
154 }
155
156 /**
157 * Image URLs in the post body, in document order.
158 *
159 * Reads the stored content rather than running it through `the_content`.
160 * This is called while the document head is being written, and rendering
161 * the body there would run every shortcode and block on the page for the
162 * sake of a few `src` attributes.
163 *
164 * @since 2.7.0
165 * @param int $post_id Post being rendered.
166 * @return array<int, string>
167 */
168 private static function from_content(int $post_id): array {
169 $content = (string) get_post_field('post_content', $post_id);
170
171 if ('' === $content || false === stripos($content, '<img')) {
172 return [];
173 }
174
175 if (!preg_match_all('/<img\b[^>]*>/i', $content, $tags)) {
176 return [];
177 }
178
179 $urls = [];
180
181 foreach ($tags[0] as $tag) {
182 if (preg_match('/\bsrc\s*=\s*["\']([^"\']+)["\']/i', $tag, $match)) {
183 $urls[] = $match[1];
184 }
185 }
186
187 return $urls;
188 }
189
190 /**
191 * A URL Open Graph can actually use, or '' to skip it.
192 *
193 * @since 2.7.0
194 * @param string $url Raw candidate.
195 * @return string
196 */
197 private static function usable(string $url): string {
198 $url = trim($url);
199
200 if ('' === $url) {
201 return '';
202 }
203
204 // Inline data and blob URLs are not fetchable by a crawler, and a
205 // protocol-relative or root-relative src cannot be resolved without
206 // guessing the host it belongs to.
207 $scheme = wp_parse_url($url, PHP_URL_SCHEME);
208
209 if (!is_string($scheme) || !in_array(strtolower($scheme), ['http', 'https'], true)) {
210 return '';
211 }
212
213 return (string) esc_url_raw($url);
214 }
215
216 /**
217 * An image plus whatever is known about it.
218 *
219 * Dimensions and type are only claimed for an attachment on this site.
220 * Guessing them for a remote image would publish numbers a consumer uses
221 * to lay out a card before it has fetched the file.
222 *
223 * @since 2.7.0
224 *
225 * @param string $url Image URL.
226 * @param int $post_id Post being rendered, for the alt fallback.
227 * @return array<string, mixed>
228 */
229 private static function describe(string $url, int $post_id): array {
230 $image = ['url' => $url];
231
232 $attachment_id = (int) attachment_url_to_postid($url);
233
234 if ($attachment_id) {
235 $meta = wp_get_attachment_metadata($attachment_id);
236
237 // Vector uploads report 0x0; publishing that as a dimension is
238 // invalid, so the companions are omitted rather than zeroed.
239 $width = is_array($meta) && isset($meta['width']) ? (int) $meta['width'] : 0;
240 $height = is_array($meta) && isset($meta['height']) ? (int) $meta['height'] : 0;
241
242 if ($width > 0 && $height > 0) {
243 $image['width'] = $width;
244 $image['height'] = $height;
245 }
246
247 $type = (string) get_post_mime_type($attachment_id);
248
249 if ('' !== $type) {
250 $image['type'] = $type;
251 }
252
253 $alt = trim((string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true));
254
255 if ('' !== $alt) {
256 $image['alt'] = $alt;
257 }
258 }
259
260 if (!isset($image['alt'])) {
261 $alt = self::alt_from_content($url, $post_id);
262
263 if ('' !== $alt) {
264 $image['alt'] = $alt;
265 }
266 }
267
268 return $image;
269 }
270
271 /**
272 * The `alt` the author wrote on this image in the body.
273 *
274 * The attachment's own alt text wins where there is one, but an image
275 * inserted with a different alt — or one hosted elsewhere, which has no
276 * attachment at all — still has the author's words on it.
277 *
278 * @since 2.7.0
279 *
280 * @param string $url Image URL.
281 * @param int $post_id Post being rendered.
282 * @return string
283 */
284 private static function alt_from_content(string $url, int $post_id): string {
285 $content = (string) get_post_field('post_content', $post_id);
286
287 if ('' === $content) {
288 return '';
289 }
290
291 if (!preg_match_all('/<img\b[^>]*>/i', $content, $tags)) {
292 return '';
293 }
294
295 foreach ($tags[0] as $tag) {
296 if (!preg_match('/\bsrc\s*=\s*["\']([^"\']+)["\']/i', $tag, $src)) {
297 continue;
298 }
299
300 if (self::fingerprint(trim($src[1])) !== self::fingerprint($url)) {
301 continue;
302 }
303
304 if (preg_match('/\balt\s*=\s*["\']([^"\']*)["\']/i', $tag, $alt)) {
305 return trim($alt[1]);
306 }
307 }
308
309 return '';
310 }
311
312 /**
313 * An identity for a URL that survives WordPress's size derivatives.
314 *
315 * `logo.jpg`, `logo-1024x768.jpg` and the `large` derivative of the same
316 * upload are one image to a reader, and offering all three as alternatives
317 * is noise. Scheme and query are dropped for the same reason — the same
318 * file over http and https is not a second option.
319 *
320 * @since 2.7.0
321 * @param string $url Image URL.
322 * @return string
323 */
324 private static function fingerprint(string $url): string {
325 $parts = wp_parse_url($url);
326
327 $host = isset($parts['host']) ? strtolower((string) $parts['host']) : '';
328 $path = isset($parts['path']) ? (string) $parts['path'] : $url;
329
330 // Strip a trailing -WxH that WordPress appends to a resized copy.
331 $path = (string) preg_replace('/-\d+x\d+(\.[A-Za-z0-9]+)$/', '$1', $path);
332
333 return $host . $path;
334 }
335 }
336