PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 54 releases
thinkrank / includes / admin / importers / class-yoast-exporter.php

class-yoast-exporter.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.13.0, at includes/admin/importers/class-yoast-exporter.php

1,121 lines 47.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Yoast SEO Exporter
5 *
6 * Reads Yoast SEO data from postmeta/termmeta/options and normalizes
7 * into the canonical snapshot format.
8 *
9 * @package ThinkRank\Admin\Importers
10 * @since 2.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\Admin\Importers;
16
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * Yoast Exporter Class
23 *
24 * @since 2.0.0
25 */
26 class Yoast_Exporter extends Abstract_Plugin_Exporter {
27
28 /**
29 * Constructor
30 */
31 public function __construct() {
32 $this->plugin_slug = 'yoast';
33 $this->plugin_name = 'Yoast SEO';
34 $this->plugin_file = 'wordpress-seo/wp-seo.php';
35 $this->meta_key_prefix = '_yoast_wpseo_';
36 $this->option_keys = ['wpseo', 'wpseo_titles', 'wpseo_social'];
37 }
38
39 /**
40 * {@inheritDoc}
41 */
42 public function detect(): bool {
43 global $wpdb;
44
45 $count = (int) $wpdb->get_var(
46 $wpdb->prepare(
47 "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key LIKE %s LIMIT 1",
48 $wpdb->esc_like($this->meta_key_prefix) . '%'
49 )
50 );
51
52 return $count > 0;
53 }
54
55 /**
56 * {@inheritDoc}
57 */
58 public function get_available_types(): array {
59 global $wpdb;
60
61 $types = [];
62
63 $post_count = (int) $wpdb->get_var(
64 $wpdb->prepare(
65 "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key LIKE %s",
66 $wpdb->esc_like($this->meta_key_prefix) . '%'
67 )
68 );
69 if ($post_count > 0) {
70 $types['postmeta'] = $post_count;
71 }
72
73 // Yoast stores term SEO in the wpseo_taxonomy_meta option, not termmeta.
74 $term_count = count($this->get_flattened_taxonomy_meta());
75 if ($term_count > 0) {
76 $types['termmeta'] = $term_count;
77 }
78
79 // Check for user meta
80 $user_count = (int) $wpdb->get_var(
81 $wpdb->prepare(
82 "SELECT COUNT(DISTINCT user_id) FROM {$wpdb->usermeta} WHERE meta_key LIKE %s",
83 $wpdb->esc_like('wpseo_') . '%'
84 )
85 );
86 if ($user_count > 0) {
87 $types['usermeta'] = $user_count;
88 }
89
90 // Settings always available if options exist
91 foreach ($this->option_keys as $key) {
92 if (get_option($key, null) !== null) {
93 $types['settings'] = 1;
94 break;
95 }
96 }
97
98 return $types;
99 }
100
101 /**
102 * {@inheritDoc}
103 */
104 protected function export_postmeta_page(int $page): array {
105 $post_ids = $this->get_post_ids_with_meta($page);
106
107 if (empty($post_ids)) {
108 return [];
109 }
110
111 $records = [];
112 foreach ($post_ids as $post_id) {
113 $post_id = (int) $post_id;
114 $meta = $this->get_all_plugin_meta($post_id);
115
116 if (empty($meta)) {
117 continue;
118 }
119
120 $robots = $this->map_yoast_post_robots(
121 $meta['_yoast_wpseo_meta-robots-noindex'] ?? '',
122 $meta['_yoast_wpseo_meta-robots-nofollow'] ?? ''
123 );
124
125 $advanced_robots = $this->parse_yoast_advanced_robots($meta['_yoast_wpseo_meta-robots-adv'] ?? '');
126
127 $records[] = [
128 'object_id' => $post_id,
129 'object_type' => 'post',
130 'source_plugin' => $this->plugin_slug,
131 'data' => [
132 'seo_title' => $this->convert_post_value($meta['_yoast_wpseo_title'] ?? '', $post_id),
133 'meta_description' => $this->convert_post_value($meta['_yoast_wpseo_metadesc'] ?? '', $post_id),
134 'focus_keyword' => $meta['_yoast_wpseo_focuskw'] ?? '',
135 // Full keyword list (primary + Yoast Premium's additional
136 // keyphrases) for ThinkRank's keyword array — without this
137 // the migrator falls back to the single focus_keyword and
138 // Premium keyphrases never reach the post.
139 'focus_keywords' => array_values(array_filter(
140 array_merge(
141 [(string) ($meta['_yoast_wpseo_focuskw'] ?? '')],
142 $this->parse_yoast_additional_keywords($meta['_yoast_wpseo_focuskeywords'] ?? '')
143 ),
144 static fn($keyword) => trim((string) $keyword) !== ''
145 )),
146 'canonical_url' => $meta['_yoast_wpseo_canonical'] ?? '',
147 'noindex' => $robots['noindex'],
148 'nofollow' => $robots['nofollow'],
149 'noarchive' => $advanced_robots['noarchive'],
150 'noimageindex' => $advanced_robots['noimageindex'],
151 'nosnippet' => $advanced_robots['nosnippet'],
152 'og_title' => $this->convert_post_value($meta['_yoast_wpseo_opengraph-title'] ?? '', $post_id),
153 'og_description' => $this->convert_post_value($meta['_yoast_wpseo_opengraph-description'] ?? '', $post_id),
154 'og_image' => $meta['_yoast_wpseo_opengraph-image'] ?? '',
155 'twitter_title' => $this->convert_post_value($meta['_yoast_wpseo_twitter-title'] ?? '', $post_id),
156 'twitter_description' => $this->convert_post_value($meta['_yoast_wpseo_twitter-description'] ?? '', $post_id),
157 'twitter_image' => $meta['_yoast_wpseo_twitter-image'] ?? '',
158 'primary_category' => (int) ($meta['_yoast_wpseo_primary_category'] ?? 0),
159 'product_identifier' => $this->extract_product_identifier($meta),
160 'schema_type' => $meta['_yoast_wpseo_schema_page_type'] ?? '',
161 // Yoast "cornerstone content" maps to ThinkRank pillar content.
162 'pillar_content' => (int) ($meta['_yoast_wpseo_is_cornerstone'] ?? 0),
163 ],
164 'extended' => [
165 'is_cornerstone' => (bool) ($meta['_yoast_wpseo_is_cornerstone'] ?? false),
166 'focus_keywords_additional' => $this->parse_yoast_additional_keywords($meta['_yoast_wpseo_focuskeywords'] ?? ''),
167 'breadcrumb_title' => $meta['_yoast_wpseo_bctitle'] ?? '',
168 'advanced_robots' => $meta['_yoast_wpseo_meta-robots-adv'] ?? '',
169 'schema_article_type' => $meta['_yoast_wpseo_schema_article_type'] ?? '',
170 'og_image_id' => $meta['_yoast_wpseo_opengraph-image-id'] ?? '',
171 'twitter_image_id' => $meta['_yoast_wpseo_twitter-image-id'] ?? '',
172 'estimated_reading_time' => (int) ($meta['_yoast_wpseo_estimated-reading-time-minutes'] ?? 0),
173 'wordproof_timestamp' => $meta['_yoast_wpseo_wordproof_timestamp'] ?? '',
174 'inclusive_language_score' => $meta['_yoast_wpseo_inclusive-language-score'] ?? '',
175 'linkdex' => $meta['_yoast_wpseo_linkdex'] ?? '',
176 'content_score' => $meta['_yoast_wpseo_content_score'] ?? '',
177 ],
178 ];
179 }
180
181 return $records;
182 }
183
184 /**
185 * {@inheritDoc}
186 */
187 protected function export_termmeta_page(int $page): array {
188 // Yoast keeps term SEO in the wpseo_taxonomy_meta option
189 // ($option[$taxonomy][$term_id] = [...]), NOT the termmeta table.
190 // Keys are wpseo_*-prefixed; the term description is wpseo_desc (not
191 // wpseo_metadesc) and wpseo_noindex is a string (default/index/noindex).
192 $flat = $this->get_flattened_taxonomy_meta();
193 if (empty($flat)) {
194 return [];
195 }
196
197 $offset = ($page - 1) * $this->chunk_size;
198 $slice = array_slice($flat, $offset, $this->chunk_size, true);
199 if (empty($slice)) {
200 return [];
201 }
202
203 $records = [];
204 foreach ($slice as $term_id => $meta) {
205 $term_id = (int) $term_id;
206 $noindex = (($meta['wpseo_noindex'] ?? 'default') === 'noindex') ? 1 : 0;
207
208 $records[] = [
209 'object_id' => $term_id,
210 'object_type' => 'term',
211 'source_plugin' => $this->plugin_slug,
212 'data' => [
213 'seo_title' => $this->convert_term_value($meta['wpseo_title'] ?? ''),
214 'meta_description' => $this->convert_term_value($meta['wpseo_desc'] ?? ''),
215 'focus_keyword' => $meta['wpseo_focuskw'] ?? '',
216 'canonical_url' => $meta['wpseo_canonical'] ?? '',
217 'noindex' => $noindex,
218 'nofollow' => 0,
219 'og_title' => $this->convert_term_value($meta['wpseo_opengraph-title'] ?? ''),
220 'og_description' => $this->convert_term_value($meta['wpseo_opengraph-description'] ?? ''),
221 ],
222 'extended' => [
223 'breadcrumb_title' => $meta['wpseo_bctitle'] ?? '',
224 'og_image' => $meta['wpseo_opengraph-image'] ?? '',
225 'twitter_title' => $meta['wpseo_twitter-title'] ?? '',
226 'twitter_description' => $meta['wpseo_twitter-description'] ?? '',
227 ],
228 ];
229 }
230
231 return $records;
232 }
233
234 /**
235 * {@inheritDoc}
236 */
237 protected function export_usermeta_page(int $page): array {
238 global $wpdb;
239
240 $offset = ($page - 1) * $this->chunk_size;
241
242 $user_ids = $wpdb->get_col(
243 $wpdb->prepare(
244 "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key LIKE %s ORDER BY user_id ASC LIMIT %d OFFSET %d",
245 $wpdb->esc_like('wpseo_') . '%',
246 $this->chunk_size,
247 $offset
248 )
249 );
250
251 if (empty($user_ids)) {
252 return [];
253 }
254
255 $records = [];
256 foreach ($user_ids as $user_id) {
257 $user_id = (int) $user_id;
258
259 // Author title/description go in canonical `data` (seo_title /
260 // meta_description) so the plugin-agnostic migrator writes them to
261 // the `_thinkrank_seo_title` / `_thinkrank_meta_description` USER meta
262 // that Author_Archives_Manager reads. ThinkRank consumes those values
263 // verbatim, so resolve Yoast template tags to literals here — seeding
264 // %%name%% with the author display name (the generic converter only
265 // knows post context).
266 $display_name = (string) get_the_author_meta('display_name', $user_id);
267 $author_title = (string) (get_user_meta($user_id, 'wpseo_title', true) ?: '');
268 $author_desc = (string) (get_user_meta($user_id, 'wpseo_metadesc', true) ?: '');
269 $author_title = $this->convert_template_variables(str_replace('%%name%%', $display_name, $author_title));
270 $author_desc = $this->convert_template_variables(str_replace('%%name%%', $display_name, $author_desc));
271
272 $records[] = [
273 'object_id' => $user_id,
274 'object_type' => 'user',
275 'source_plugin' => $this->plugin_slug,
276 'data' => [
277 'seo_title' => $author_title,
278 'meta_description' => $author_desc,
279 ],
280 'extended' => [
281 'author_noindex' => (bool) get_user_meta($user_id, 'wpseo_noindex_author', true),
282 'social_profiles' => [
283 'facebook' => get_user_meta($user_id, 'facebook', true) ?: '',
284 'twitter' => get_user_meta($user_id, 'twitter', true) ?: '',
285 'linkedin' => get_user_meta($user_id, 'linkedin', true) ?: '',
286 'instagram' => get_user_meta($user_id, 'instagram', true) ?: '',
287 'youtube' => get_user_meta($user_id, 'youtube_url', true) ?: '',
288 'pinterest' => get_user_meta($user_id, 'pinterest_url', true) ?: '',
289 'wikipedia' => get_user_meta($user_id, 'wikipedia_url', true) ?: '',
290 ],
291 ],
292 ];
293 }
294
295 return $records;
296 }
297
298 /**
299 * {@inheritDoc}
300 */
301 protected function export_settings(): array {
302 // get_option()'s [] default only applies to a missing row; a row that
303 // exists but holds a scalar/false (reset, legacy, corrupted) would flow
304 // into the array-typed helpers below and throw a TypeError. Normalize.
305 $wpseo = get_option('wpseo', []);
306 $wpseo = is_array($wpseo) ? $wpseo : [];
307 $titles = get_option('wpseo_titles', []);
308 $titles = is_array($titles) ? $titles : [];
309 $social = get_option('wpseo_social', []);
310 $social = is_array($social) ? $social : [];
311
312 return [
313 [
314 'type' => 'settings',
315 'source_plugin' => $this->plugin_slug,
316 'data' => [
317 'separator' => $this->map_yoast_separator($titles['separator'] ?? 'sc-dash'),
318 'homepage_title' => $this->convert_template_variables($titles['title-home-wpseo'] ?? ''),
319 'homepage_description' => $this->convert_template_variables($titles['metadesc-home-wpseo'] ?? ''),
320 'organization_name' => $this->yoast_identity($titles, $wpseo, 'company_name'),
321 'organization_logo' => $this->yoast_identity($titles, $wpseo, 'company_logo'),
322 // Schema `alternateName` for the site.
323 'alternate_name' => $this->yoast_identity($titles, $wpseo, 'alternate_website_name'),
324 // Knowledge Graph entity: Yoast's company_or_person, with the
325 // name taken from whichever side it points at.
326 'knowledge_graph' => $this->extract_yoast_knowledge_graph($titles, $wpseo),
327 // Yoast's IndexNow key is a public verification token served
328 // at /{key}.txt, not an account secret — carrying it over
329 // avoids re-verifying the site with IndexNow.
330 'instant_indexing' => [
331 'api_key' => (string) ($wpseo['index_now_key'] ?? ''),
332 ],
333 'social_profiles' => [
334 'facebook' => $social['facebook_site'] ?? '',
335 'twitter' => $social['twitter_site'] ?? '',
336 'instagram' => $social['instagram_url'] ?? '',
337 'linkedin' => $social['linkedin_url'] ?? '',
338 'youtube' => $social['youtube_url'] ?? '',
339 'pinterest' => $social['pinterest_url'] ?? '',
340 'wikipedia' => $social['wikipedia_url'] ?? '',
341 ],
342 'noindex_archives' => [
343 'date' => !empty($titles['noindex-archive-wpseo']),
344 'author' => !empty($titles['noindex-author-wpseo']),
345 ],
346 // Default Twitter card size for ThinkRank's Social Meta
347 // settings.
348 'twitter_card_type' => (string) ($social['twitter_card_type'] ?? ''),
349 // Site-wide social defaults with direct ThinkRank homes.
350 'social_defaults' => [
351 'og_default_image' => (string) ($social['og_default_image'] ?? ''),
352 ],
353 ],
354 'extended' => [
355 'breadcrumb_settings' => [
356 'enabled' => !empty($titles['breadcrumbs-enable']),
357 'home_label' => $titles['breadcrumbs-home'] ?? 'Home',
358 'separator' => $titles['breadcrumbs-sep'] ?? '»',
359 'prefix' => (string) ($titles['breadcrumbs-prefix'] ?? ''),
360 ],
361 // Webmaster-tools verification codes. Pinterest is applied
362 // (the one ThinkRank renders); the rest is preserved and
363 // gates /import/cleanup.
364 'webmaster_tools' => array_filter([
365 'google' => (string) ($wpseo['googleverify'] ?? ''),
366 'bing' => (string) ($wpseo['msverify'] ?? ''),
367 'yandex' => (string) ($wpseo['yandexverify'] ?? ''),
368 'baidu' => (string) ($wpseo['baiduverify'] ?? ''),
369 'pinterest' => (string) ($social['pinterestverify'] ?? ''),
370 ]),
371 'post_type_settings' => $this->extract_post_type_settings($titles),
372 'taxonomy_settings' => $this->extract_taxonomy_settings($titles),
373 // Per-context title formats in ThinkRank's SITE IDENTITY token
374 // vocabulary, which is a different dialect from the Global SEO
375 // one used by post_type_settings above.
376 'title_formats' => $this->extract_yoast_title_formats($titles),
377 // Author archive behaviour (Author Archives feature).
378 'author_archives' => $this->extract_yoast_author_archives($titles),
379 // Role Manager assignments (live on the roles, not in wpseo_*).
380 'role_capabilities' => $this->extract_role_capabilities('wpseo_'),
381 // News/Video sitemap post types, when the Yoast News SEO /
382 // Video SEO add-ons are installed.
383 'publisher_sitemaps' => $this->extract_yoast_publisher_sitemaps(),
384 'og_frontpage_title' => $social['og_frontpage_title'] ?? '',
385 'og_frontpage_desc' => $social['og_frontpage_desc'] ?? '',
386 'og_frontpage_image' => $social['og_frontpage_image'] ?? '',
387 // Yoast's XML sitemap is otherwise filter-driven: per-type
388 // inclusion follows the noindex settings (already captured in
389 // post_type_settings / taxonomy_settings) and it exposes no
390 // links/images/ping options. The one real toggle is the global
391 // enable flag; only flag has_data when it is actually present so
392 // the migrator does not disable ThinkRank's sitemap by default.
393 'sitemap_settings' => [
394 'enabled' => !empty($wpseo['enable_xml_sitemap']),
395 'has_data' => array_key_exists('enable_xml_sitemap', $wpseo),
396 ],
397 ],
398 ],
399 ];
400 }
401
402 /**
403 * {@inheritDoc}
404 */
405 protected function export_redirections_page(int $page): array {
406 // Yoast Premium stores redirections in a custom table
407 global $wpdb;
408
409 $table_name = $wpdb->prefix . 'yoast_seo_redirects';
410 $table_exists = $wpdb->get_var(
411 $wpdb->prepare("SHOW TABLES LIKE %s", $table_name)
412 );
413
414 if (!$table_exists) {
415 return [];
416 }
417
418 $offset = ($page - 1) * $this->chunk_size;
419
420 $rows = $wpdb->get_results(
421 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
422 $wpdb->prepare(
423 "SELECT * FROM {$table_name} ORDER BY id ASC LIMIT %d OFFSET %d",
424 $this->chunk_size,
425 $offset
426 ),
427 ARRAY_A
428 );
429 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
430
431 if (empty($rows)) {
432 return [];
433 }
434
435 $records = [];
436 foreach ($rows as $row) {
437 $records[] = [
438 'object_type' => 'redirection',
439 'source_plugin' => $this->plugin_slug,
440 'data' => [],
441 'extended' => [
442 'source_url' => $row['origin'] ?? '',
443 'target_url' => $row['url'] ?? '',
444 'http_code' => (int) ($row['type'] ?? 301),
445 'is_regex' => !empty($row['format'] ?? '') && $row['format'] === 'regex',
446 'enabled' => true,
447 ],
448 ];
449 }
450
451 return $records;
452 }
453
454 /**
455 * {@inheritDoc}
456 */
457 protected function convert_template_variables($value, ?int $post_id = null): string {
458 // Foreign data first: booleans/arrays in the source plugin's options
459 // must degrade to '' here, not fatal the migration (see abstract).
460 $value = $this->stringify_template_value($value);
461
462 if (empty($value) || strpos($value, '%%') === false) {
463 return $value;
464 }
465
466 $replacements = [
467 '%%sitename%%' => get_bloginfo('name'),
468 '%%sitedesc%%' => get_bloginfo('description'),
469 '%%sep%%' => '-',
470 '%%page%%' => '',
471 '%%pagenumber%%' => '',
472 '%%pagetotal%%' => '',
473 '%%currentyear%%' => gmdate('Y'),
474 '%%currentdate%%' => gmdate('Y-m-d'),
475 '%%currentmonth%%' => gmdate('F'),
476 '%%currentday%%' => gmdate('j'),
477 ];
478
479 // Add post-specific replacements
480 if ($post_id) {
481 $post = get_post($post_id);
482 if ($post) {
483 $replacements['%%title%%'] = $post->post_title;
484 $replacements['%%excerpt%%'] = \ThinkRank\Core\Seo_Text::trim_words(
485 $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words(wp_strip_all_tags($post->post_content), 55),
486 55
487 );
488 $replacements['%%date%%'] = get_the_date('', $post);
489 $replacements['%%modified%%'] = get_the_modified_date('', $post);
490 $replacements['%%id%%'] = (string) $post_id;
491 $replacements['%%name%%'] = get_the_author_meta('display_name', (int) $post->post_author);
492
493 // Post type
494 $post_type_obj = get_post_type_object($post->post_type);
495 $replacements['%%pt_single%%'] = $post_type_obj ? $post_type_obj->labels->singular_name : '';
496 $replacements['%%pt_plural%%'] = $post_type_obj ? $post_type_obj->labels->name : '';
497
498 // Category
499 $categories = get_the_category($post_id);
500 $replacements['%%category%%'] = !empty($categories) ? $categories[0]->name : '';
501 $replacements['%%primary_category%%'] = !empty($categories) ? $categories[0]->name : '';
502
503 // Tags
504 $tags = get_the_tags($post_id);
505 $replacements['%%tag%%'] = !empty($tags) ? $tags[0]->name : '';
506 }
507 }
508
509 $value = str_replace(array_keys($replacements), array_values($replacements), $value);
510
511 // Strip any remaining unknown variables
512 $value = preg_replace('/%%[a-z0-9_-]+%%/i', '', $value);
513
514 return trim($value);
515 }
516
517 /**
518 * Yoast per-post tokens ThinkRank resolves per request (#886): kept as
519 * tags rather than frozen into text. Mirrors convert_template_pattern().
520 */
521 private const POST_TOKENS = [
522 '%%title%%' => '%title%',
523 '%%sitename%%' => '%sitename%',
524 '%%sep%%' => '%sep%',
525 '%%excerpt%%' => '%excerpt%',
526 '%%date%%' => '%date%',
527 '%%modified%%' => '%modified%',
528 '%%name%%' => '%author%',
529 '%%category%%' => '%category%',
530 '%%primary_category%%' => '%category%',
531 '%%wc_price%%' => '%price%',
532 '%%wc_sku%%' => '%sku%',
533 '%%wc_shortdesc%%' => '%short_description%',
534 '%%wc_brand%%' => '%brand%',
535 ];
536
537 /**
538 * Yoast per-term tokens ThinkRank resolves on a term archive. The term's
539 * own name and description used to be stripped outright, since the
540 * literal resolver only knows post context.
541 */
542 private const TERM_TOKENS = [
543 '%%term_title%%' => '%term%',
544 '%%term_description%%' => '%excerpt%',
545 '%%category_description%%' => '%excerpt%',
546 '%%tag_description%%' => '%excerpt%',
547 '%%sitename%%' => '%sitename%',
548 '%%sep%%' => '%sep%',
549 ];
550
551 /**
552 * A per-post Yoast value: mapped tokens as ThinkRank tags, the rest literal.
553 *
554 * @param mixed $value Raw Yoast value
555 * @param int $post_id Post ID
556 * @return string
557 */
558 private function convert_post_value($value, int $post_id): string {
559 return $this->tokenize_object_template(
560 $this->stringify_template_value($value),
561 self::literal_token_patterns(self::POST_TOKENS),
562 fn(string $rest): string => $this->convert_template_variables($rest, $post_id)
563 );
564 }
565
566 /**
567 * A per-term Yoast value: mapped tokens as ThinkRank tags, the rest literal.
568 *
569 * @param mixed $value Raw Yoast value
570 * @return string
571 */
572 private function convert_term_value($value): string {
573 return $this->tokenize_object_template(
574 $this->stringify_template_value($value),
575 self::literal_token_patterns(self::TERM_TOKENS),
576 fn(string $rest): string => $this->convert_template_variables($rest)
577 );
578 }
579
580 /**
581 * Translate a Yoast title/description *template* into ThinkRank pattern syntax.
582 *
583 * Unlike convert_template_variables() — which resolves a per-post value to a
584 * literal string — post-type and taxonomy templates must stay templates so the
585 * frontend can resolve them per request. Yoast uses double-percent tokens
586 * (`%%title%%`); ThinkRank's Pattern_Resolver uses single-percent tokens
587 * (`%title%`) with a smaller vocabulary. Map the tokens ThinkRank can resolve
588 * and strip any Yoast token it has no equivalent for, so an imported template
589 * never renders stray `%` characters.
590 *
591 * @param mixed $template Raw Yoast template (e.g. "%%title%% %%sep%% %%sitename%%").
592 * @return string ThinkRank pattern (e.g. "%title% %sep% %sitename%").
593 */
594 private function convert_template_pattern($template): string {
595 // Foreign data first: booleans/arrays in the source plugin's options
596 // must degrade to '' here, not fatal the migration (see abstract).
597 $template = $this->stringify_template_value($template);
598
599 if ($template === '' || strpos($template, '%%') === false) {
600 return $template;
601 }
602
603 // Yoast token => ThinkRank Pattern_Resolver token. Only tokens the
604 // resolver understands (see Pattern_Resolver::placeholders_for()) are
605 // mapped; everything else is stripped below.
606 $map = [
607 '%%title%%' => '%title%',
608 '%%sitename%%' => '%sitename%',
609 '%%sep%%' => '%sep%',
610 '%%excerpt%%' => '%excerpt%',
611 '%%excerpt_only%%' => '%excerpt%',
612 '%%date%%' => '%date%',
613 '%%modified%%' => '%modified%',
614 '%%name%%' => '%author%',
615 '%%category%%' => '%category%',
616 '%%primary_category%%' => '%category%',
617 // WooCommerce SEO product variables. Stripped rather than mapped
618 // until #715, so a product description template imported as "Buy
619 // %title% for" with the price and stock gone.
620 '%%wc_price%%' => '%price%',
621 '%%wc_sku%%' => '%sku%',
622 '%%wc_shortdesc%%' => '%short_description%',
623 '%%wc_brand%%' => '%brand%',
624 ];
625
626 $template = str_replace(array_keys($map), array_values($map), $template);
627
628 // Drop any remaining Yoast tokens ThinkRank cannot resolve
629 // (e.g. %%page%%, %%sitedesc%%, %%pt_single%%, %%tag%%).
630 $template = preg_replace('/%%[a-z0-9_-]+%%/i', '', $template);
631
632 // Tidy whitespace left by removed tokens. Pattern_Resolver also collapses
633 // whitespace/separators at render time, so this is mostly cosmetic.
634 $template = preg_replace('/\s+/', ' ', $template);
635
636 return trim($template);
637 }
638
639 /**
640 * Parse Yoast's `_yoast_wpseo_meta-robots-adv` value.
641 *
642 * Stored as a comma-separated list of advanced directives
643 * (e.g. "noimageindex,nosnippet"), or "-" / "none" when no
644 * extras are selected.
645 *
646 * @param mixed $value Raw meta value from Yoast
647 * @return array{noarchive:int,noimageindex:int,nosnippet:int}
648 */
649 private function parse_yoast_advanced_robots($value): array {
650 $result = ['noarchive' => 0, 'noimageindex' => 0, 'nosnippet' => 0];
651
652 // Foreign data first: non-string meta degrades to '' → no extras (see abstract).
653 $value = $this->stringify_template_value($value);
654
655 if ($value === '' || $value === '-' || $value === 'none') {
656 return $result;
657 }
658
659 $tokens = array_map('trim', explode(',', $value));
660 foreach ($tokens as $token) {
661 if (isset($result[$token])) {
662 $result[$token] = 1;
663 }
664 }
665
666 return $result;
667 }
668
669 /**
670 * Map Yoast's tri-state post robots values to canonical 0/1 flags.
671 *
672 * Yoast stores `_yoast_wpseo_meta-robots-noindex` as 0 (post-type default),
673 * 1 (noindex) or 2 (index) — so ONLY an explicit 1 means noindex; 2 (index)
674 * and 0 (default) must not. `_yoast_wpseo_meta-robots-nofollow` is a plain
675 * 0/1 boolean (1 = nofollow).
676 *
677 * @param mixed $noindex_raw Raw meta-robots-noindex value
678 * @param mixed $nofollow_raw Raw meta-robots-nofollow value
679 * @return array{noindex:int,nofollow:int}
680 */
681 private function map_yoast_post_robots($noindex_raw, $nofollow_raw): array {
682 return [
683 'noindex' => ((int) $noindex_raw === 1) ? 1 : 0,
684 'nofollow' => ((int) $nofollow_raw === 1) ? 1 : 0,
685 ];
686 }
687
688 /**
689 * Flatten the wpseo_taxonomy_meta option to a term_id => meta map.
690 *
691 * The option is nested as $option[$taxonomy][$term_id] = [...]. term_id is
692 * globally unique in WordPress, so keying by it is safe and gives a stable,
693 * paginatable order.
694 *
695 * @return array<int,array> Map of term_id => Yoast term meta array
696 */
697 private function get_flattened_taxonomy_meta(): array {
698 $tax_meta = get_option('wpseo_taxonomy_meta', []);
699 $tax_meta = is_array($tax_meta) ? $tax_meta : [];
700 if (!is_array($tax_meta)) {
701 return [];
702 }
703
704 $flat = [];
705 foreach ($tax_meta as $terms) {
706 if (!is_array($terms)) {
707 continue;
708 }
709 foreach ($terms as $term_id => $meta) {
710 if (is_array($meta) && (int) $term_id > 0) {
711 $flat[(int) $term_id] = $meta;
712 }
713 }
714 }
715
716 ksort($flat);
717
718 return $flat;
719 }
720
721 /**
722 * Convert a Yoast separator slug (e.g. "sc-dash") to its literal character.
723 *
724 * Yoast stores the title separator as a slug, not the glyph. An unknown or
725 * already-literal value is returned unchanged.
726 *
727 * @param string $separator Raw separator value from wpseo_titles
728 * @return string Literal separator character
729 */
730 /**
731 * Read a Yoast site-identity value, tolerating where the version stores it.
732 *
733 * Yoast moved company_name / company_logo / company_or_person / person_name /
734 * website_name / alternate_website_name from the `wpseo` option into
735 * `wpseo_titles` (around Yoast 14). Reading only `wpseo` — as this exporter
736 * originally did — silently returned nothing on every modern install, so the
737 * organization name and logo never migrated. Check the modern location
738 * first, then fall back for older installs.
739 *
740 * @param array $titles `wpseo_titles` option
741 * @param array $wpseo `wpseo` option
742 * @param string $key Setting key
743 * @return string Value, or '' when absent from both
744 */
745 private function yoast_identity(array $titles, array $wpseo, string $key): string {
746 foreach ([$titles, $wpseo] as $source) {
747 if (!empty($source[$key]) && is_scalar($source[$key])) {
748 return (string) $source[$key];
749 }
750 }
751
752 return '';
753 }
754
755 /**
756 * Extract Yoast's Knowledge Graph entity into ThinkRank's vocabulary.
757 *
758 * Yoast stores `company_or_person` ('company' | 'person') alongside separate
759 * name fields; ThinkRank models the same split as organization/person.
760 *
761 * @param array $titles `wpseo_titles` option
762 * @param array $wpseo `wpseo` option
763 * @return array{type:string,name:string}
764 */
765 private function extract_yoast_knowledge_graph(array $titles, array $wpseo): array {
766 $raw = strtolower(trim($this->yoast_identity($titles, $wpseo, 'company_or_person')));
767
768 if ($raw === 'person') {
769 return [
770 'type' => 'person',
771 'name' => $this->yoast_identity($titles, $wpseo, 'person_name'),
772 ];
773 }
774
775 if ($raw === 'company') {
776 return [
777 'type' => 'organization',
778 'name' => $this->yoast_identity($titles, $wpseo, 'company_name'),
779 ];
780 }
781
782 // Yoast also allows "neither" (empty), which the migrator skips.
783 return ['type' => '', 'name' => ''];
784 }
785
786 /**
787 * Map Yoast's per-context title formats onto ThinkRank's Site Identity keys.
788 *
789 * A DIFFERENT token dialect from `post_type_settings`: the Site Identity
790 * renderer resolves %site_title%/%post_title%/%category_title%/… while the
791 * Global SEO renderer resolves %title%/%sitename%/%excerpt%. Converting with
792 * the wrong one renders the token literally on the front end.
793 *
794 * @param array $titles `wpseo_titles` option
795 * @return array Map of ThinkRank title-format key => converted template
796 */
797 private function extract_yoast_title_formats(array $titles): array {
798 // ThinkRank key => [Yoast key, token Yoast's %%title%%/%%term_title%%
799 // stands for in that context].
800 $map = [
801 'homepage_title' => ['title-home-wpseo', ''],
802 'post_title' => ['title-post', '%post_title%'],
803 'page_title' => ['title-page', '%page_title%'],
804 'category_title' => ['title-tax-category', '%category_title%'],
805 'tag_title' => ['title-tax-post_tag', '%tag_title%'],
806 'search_title' => ['title-search-wpseo', '%search_term%'],
807 'archive_title' => ['title-archive-wpseo', '%archive_title%'],
808 'author_title' => ['title-author-wpseo', '%author_name%'],
809 ];
810
811 $formats = [];
812 foreach ($map as $tr_key => [$yoast_key, $context_token]) {
813 $raw = (string) ($titles[$yoast_key] ?? '');
814 if ($raw === '') {
815 continue;
816 }
817
818 $converted = $this->convert_identity_pattern($raw, $context_token);
819 if ($converted !== '') {
820 $formats[$tr_key] = $converted;
821 }
822 }
823
824 return $formats;
825 }
826
827 /**
828 * Convert a Yoast title template into ThinkRank's Site Identity token
829 * vocabulary, preserving structure.
830 *
831 * Tokens ThinkRank cannot resolve (%%page%%, %%pt_single%%, %%currentyear%%)
832 * are stripped so they never render literally, and a separator left dangling
833 * by that strip is dropped rather than rendering as a leading/trailing dash.
834 *
835 * @param string $template Raw Yoast template
836 * @param string $context_token Token %%title%%/%%term_title%% stands for (may be '')
837 * @return string ThinkRank Site Identity template
838 */
839 private function convert_identity_pattern(string $template, string $context_token): string {
840 if ($template === '' || strpos($template, '%%') === false) {
841 return trim($template);
842 }
843
844 $map = [
845 '%%sitename%%' => '%site_title%',
846 '%%sitedesc%%' => '%site_description%',
847 '%%sep%%' => '%sep%',
848 '%%date%%' => '%date%',
849 '%%searchphrase%%' => '%search_term%',
850 '%%name%%' => '%author_name%',
851 // Yoast's own archive/term variables, which ThinkRank resolves under
852 // the same names.
853 '%%archive_title%%' => '%archive_title%',
854 '%%category%%' => '%category_title%',
855 '%%tag%%' => '%tag_title%',
856 // Rank Math leftovers. Yoast's own Rank Math importer rewrites title
857 // settings with a blind str_replace('%', '%%') and NO token
858 // translation, so a site that came RankMath -> Yoast carries Rank
859 // Math tokens wrapped in Yoast's double-percent syntax — tokens Yoast
860 // itself cannot resolve either. Map them rather than stripping them,
861 // or the format silently loses its search term / term name.
862 '%%search_query%%' => '%search_term%',
863 // NOT the WooCommerce product variables, deliberately. This
864 // converter writes Site Identity title formats, and those are
865 // resolved by SEO_Manager::process_title_template() against its own
866 // vocabulary — %site_title%, %separator%, %search_term% and the
867 // rest — which has no %price%/%sku%/%brand% and, unlike
868 // Pattern_Resolver, does not strip what it cannot resolve. Mapping
869 // them here published "Search pennant - %price% - Media Wipe" in a
870 // live <title>: a silent loss traded for the literal leak #715 is
871 // about. The formats this feeds are the homepage, search, archive,
872 // author, category and tag titles, where there is no one product
873 // for a price to belong to either way, so the strip below is the
874 // right answer. The per-post converter keeps them, because
875 // Pattern_Resolver does resolve them: see
876 // convert_template_pattern().
877 ];
878 if ($context_token !== '') {
879 $map['%%title%%'] = $context_token;
880 $map['%%term_title%%'] = $context_token;
881 $map['%%term%%'] = $context_token; // Rank Math leftover (see above)
882 }
883 $template = str_replace(array_keys($map), array_values($map), $template);
884
885 // Drop any remaining Yoast token ThinkRank cannot resolve.
886 $template = preg_replace('/%%[a-z0-9_-]+%%/i', '', $template);
887
888 $template = preg_replace('/\s{2,}/', ' ', (string) $template);
889 $template = trim((string) $template);
890 $template = preg_replace('/^(?:%sep%)\s*/', '', $template);
891 $template = preg_replace('/\s*(?:%sep%)$/', '', (string) $template);
892
893 return trim((string) $template);
894 }
895
896 /**
897 * Extract Yoast's author-archive behaviour for ThinkRank's Author Archives
898 * feature (author_archives_enabled / _title / _meta_desc).
899 *
900 * The archive noindex flag travels separately in `data.noindex_archives`.
901 *
902 * @param array $titles `wpseo_titles` option
903 * @return array Author archive settings
904 */
905 private function extract_yoast_author_archives(array $titles): array {
906 return [
907 // Yoast stores the NEGATIVE `disable-author`; ThinkRank stores the
908 // positive `enabled`, so invert. Cast loosely — Yoast has stored this
909 // as both a real boolean and the string 'on'.
910 'enabled' => !filter_var($titles['disable-author'] ?? false, FILTER_VALIDATE_BOOLEAN),
911 'title' => $this->convert_identity_pattern((string) ($titles['title-author-wpseo'] ?? ''), '%author_name%'),
912 'description' => $this->convert_identity_pattern((string) ($titles['metadesc-author-wpseo'] ?? ''), '%author_name%'),
913 ];
914 }
915
916 /**
917 * Extract News/Video sitemap post types from the Yoast News SEO and Video
918 * SEO add-ons, when installed. Both are separate paid plugins, so their
919 * options are usually absent — an empty result means "nothing to migrate".
920 *
921 * @return array News/Video post type lists (absent keys omitted)
922 */
923 private function extract_yoast_publisher_sitemaps(): array {
924 $out = [];
925
926 $news = get_option('wpseo_news', []);
927 $news = is_array($news) ? $news : [];
928 if (is_array($news) && !empty($news['news_sitemap_include_post_types'])) {
929 // Yoast News stores this as [post_type => 'on'] rather than a list.
930 $types = $news['news_sitemap_include_post_types'];
931 $out['news_post_types'] = is_array($types) && $this->is_assoc_toggle_map($types)
932 ? array_keys(array_filter($types))
933 : array_values(array_map('strval', (array) $types));
934 }
935
936 $video = get_option('wpseo_video', []);
937 $video = is_array($video) ? $video : [];
938 if (is_array($video) && !empty($video['videositemap_posttypes'])) {
939 $types = $video['videositemap_posttypes'];
940 $out['video_post_types'] = is_array($types) && $this->is_assoc_toggle_map($types)
941 ? array_keys(array_filter($types))
942 : array_values(array_map('strval', (array) $types));
943 }
944
945 return $out;
946 }
947
948 /**
949 * Whether an array is a [slug => truthy] toggle map rather than a plain list
950 * of slugs. Yoast's add-ons have used both shapes across versions.
951 *
952 * @param array $value Candidate array
953 * @return bool
954 */
955 private function is_assoc_toggle_map(array $value): bool {
956 return array_keys($value) !== range(0, count($value) - 1);
957 }
958
959 private function map_yoast_separator($separator): string {
960 // Foreign data first: a non-string separator setting degrades to '' so
961 // the default fallback applies instead of fatalling (see abstract).
962 $separator = $this->stringify_template_value($separator);
963
964 $map = [
965 'sc-dash' => '-',
966 'sc-ndash' => '–',
967 'sc-mdash' => '—',
968 'sc-colon' => ':',
969 'sc-middot' => '·',
970 'sc-bull' => '•',
971 'sc-star' => '*',
972 'sc-smstar' => '⋆',
973 'sc-pipe' => '|',
974 'sc-tilde' => '~',
975 'sc-laquo' => '«',
976 'sc-raquo' => '»',
977 'sc-lt' => '>',
978 'sc-gt' => '<',
979 ];
980
981 return $map[$separator] ?? ($separator !== '' ? $separator : '-');
982 }
983
984 /**
985 * Parse Yoast's additional focus keywords JSON
986 *
987 * @param mixed $json JSON string from _yoast_wpseo_focuskeywords
988 * @return array Array of additional keyword strings
989 */
990 private function parse_yoast_additional_keywords($json): array {
991 // Foreign data first: non-string meta degrades to '' → no keywords (see abstract).
992 $json = $this->stringify_template_value($json);
993
994 if (empty($json)) {
995 return [];
996 }
997
998 $decoded = json_decode($json, true);
999 if (!is_array($decoded)) {
1000 return [];
1001 }
1002
1003 $keywords = [];
1004 foreach ($decoded as $item) {
1005 if (isset($item['keyword']) && !empty($item['keyword'])) {
1006 $keywords[] = $item['keyword'];
1007 }
1008 }
1009
1010 return $keywords;
1011 }
1012
1013 /**
1014 * Extract post type SEO settings from Yoast titles option
1015 *
1016 * @param array $titles Yoast wpseo_titles option
1017 * @return array Post type settings
1018 */
1019 private function extract_post_type_settings(array $titles): array {
1020 $settings = [];
1021 $post_types = get_post_types(['public' => true], 'names');
1022
1023 // Yoast's link suggestions is a single GLOBAL toggle, not per-post-type
1024 // like Rank Math's and ThinkRank's. Only a deliberate "off" carries
1025 // information (both plugins default it on), so an off is fanned out to
1026 // every public type and an on is left alone.
1027 $wpseo = get_option('wpseo', []);
1028 $link_suggestions_off = is_array($wpseo)
1029 && array_key_exists('enable_link_suggestions', $wpseo)
1030 && !$wpseo['enable_link_suggestions'];
1031
1032 foreach ($post_types as $pt) {
1033 $pt_settings = [];
1034 if ($link_suggestions_off) {
1035 $pt_settings['link_suggestions'] = false;
1036 }
1037 if (isset($titles["title-{$pt}"])) {
1038 $pt_settings['title_template'] = $this->convert_template_pattern($titles["title-{$pt}"]);
1039 }
1040 if (isset($titles["metadesc-{$pt}"])) {
1041 $pt_settings['description_template'] = $this->convert_template_pattern($titles["metadesc-{$pt}"]);
1042 }
1043 if (isset($titles["noindex-{$pt}"])) {
1044 $pt_settings['noindex'] = (bool) $titles["noindex-{$pt}"];
1045 }
1046 if (!empty($pt_settings)) {
1047 $settings[$pt] = $pt_settings;
1048 }
1049 }
1050
1051 return $settings;
1052 }
1053
1054 /**
1055 * Extract taxonomy SEO settings from Yoast titles option
1056 *
1057 * @param array $titles Yoast wpseo_titles option
1058 * @return array Taxonomy settings
1059 */
1060 private function extract_taxonomy_settings(array $titles): array {
1061 $settings = [];
1062 $taxonomies = get_taxonomies(['public' => true], 'names');
1063
1064 foreach ($taxonomies as $tax) {
1065 $tax_settings = [];
1066 if (isset($titles["title-tax-{$tax}"])) {
1067 $tax_settings['title_template'] = $this->convert_template_pattern($titles["title-tax-{$tax}"]);
1068 }
1069 if (isset($titles["metadesc-tax-{$tax}"])) {
1070 $tax_settings['description_template'] = $this->convert_template_pattern($titles["metadesc-tax-{$tax}"]);
1071 }
1072 if (isset($titles["noindex-tax-{$tax}"])) {
1073 $tax_settings['noindex'] = (bool) $titles["noindex-tax-{$tax}"];
1074 }
1075 if (!empty($tax_settings)) {
1076 $settings[$tax] = $tax_settings;
1077 }
1078 }
1079
1080 return $settings;
1081 }
1082
1083 /**
1084 * A product's identifier from Yoast WooCommerce SEO.
1085 *
1086 * Yoast stores every identifier type in one serialised map keyed by type
1087 * (`gtin8`, `gtin12`, `gtin13`, `gtin14`, `isbn`, `mpn`) rather than in a
1088 * field per type. ThinkRank Pro holds a single identifier, so the first
1089 * non-empty one wins, in the order Google prefers: a GTIN identifies the
1090 * product itself, an MPN only identifies it within one manufacturer, and
1091 * an ISBN applies to books alone.
1092 *
1093 * @since 2.10.1
1094 *
1095 * @param array $meta Post meta for the product.
1096 * @return string Identifier, or '' when the product has none.
1097 */
1098 private function extract_product_identifier(array $meta): string {
1099 $raw = $meta['wpseo_global_identifier_values'] ?? '';
1100
1101 if (is_string($raw) && '' !== $raw) {
1102 $decoded = maybe_unserialize($raw);
1103 $raw = is_array($decoded) ? $decoded : [];
1104 }
1105
1106 if (!is_array($raw) || empty($raw)) {
1107 return '';
1108 }
1109
1110 foreach (['gtin13', 'gtin14', 'gtin12', 'gtin8', 'mpn', 'isbn'] as $type) {
1111 $value = trim((string) ($raw[$type] ?? ''));
1112
1113 if ('' !== $value) {
1114 return $value;
1115 }
1116 }
1117
1118 return '';
1119 }
1120 }
1121