PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.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 trunk 1.0.0 All 52 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.11.0, at includes/admin/importers/class-yoast-exporter.php

1,058 lines 45.0 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_template_variables($meta['_yoast_wpseo_title'] ?? '', $post_id),
133 'meta_description' => $this->convert_template_variables($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_template_variables($meta['_yoast_wpseo_opengraph-title'] ?? '', $post_id),
153 'og_description' => $this->convert_template_variables($meta['_yoast_wpseo_opengraph-description'] ?? '', $post_id),
154 'og_image' => $meta['_yoast_wpseo_opengraph-image'] ?? '',
155 'twitter_title' => $this->convert_template_variables($meta['_yoast_wpseo_twitter-title'] ?? '', $post_id),
156 'twitter_description' => $this->convert_template_variables($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_template_variables($meta['wpseo_title'] ?? ''),
214 'meta_description' => $this->convert_template_variables($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_template_variables($meta['wpseo_opengraph-title'] ?? ''),
220 'og_description' => $this->convert_template_variables($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 * Translate a Yoast title/description *template* into ThinkRank pattern syntax.
519 *
520 * Unlike convert_template_variables() — which resolves a per-post value to a
521 * literal string — post-type and taxonomy templates must stay templates so the
522 * frontend can resolve them per request. Yoast uses double-percent tokens
523 * (`%%title%%`); ThinkRank's Pattern_Resolver uses single-percent tokens
524 * (`%title%`) with a smaller vocabulary. Map the tokens ThinkRank can resolve
525 * and strip any Yoast token it has no equivalent for, so an imported template
526 * never renders stray `%` characters.
527 *
528 * @param mixed $template Raw Yoast template (e.g. "%%title%% %%sep%% %%sitename%%").
529 * @return string ThinkRank pattern (e.g. "%title% %sep% %sitename%").
530 */
531 private function convert_template_pattern($template): string {
532 // Foreign data first: booleans/arrays in the source plugin's options
533 // must degrade to '' here, not fatal the migration (see abstract).
534 $template = $this->stringify_template_value($template);
535
536 if ($template === '' || strpos($template, '%%') === false) {
537 return $template;
538 }
539
540 // Yoast token => ThinkRank Pattern_Resolver token. Only tokens the
541 // resolver understands (see Pattern_Resolver::placeholders_for()) are
542 // mapped; everything else is stripped below.
543 $map = [
544 '%%title%%' => '%title%',
545 '%%sitename%%' => '%sitename%',
546 '%%sep%%' => '%sep%',
547 '%%excerpt%%' => '%excerpt%',
548 '%%excerpt_only%%' => '%excerpt%',
549 '%%date%%' => '%date%',
550 '%%modified%%' => '%modified%',
551 '%%name%%' => '%author%',
552 '%%category%%' => '%category%',
553 '%%primary_category%%' => '%category%',
554 // WooCommerce SEO product variables. Stripped rather than mapped
555 // until #715, so a product description template imported as "Buy
556 // %title% for" with the price and stock gone.
557 '%%wc_price%%' => '%price%',
558 '%%wc_sku%%' => '%sku%',
559 '%%wc_shortdesc%%' => '%short_description%',
560 '%%wc_brand%%' => '%brand%',
561 ];
562
563 $template = str_replace(array_keys($map), array_values($map), $template);
564
565 // Drop any remaining Yoast tokens ThinkRank cannot resolve
566 // (e.g. %%page%%, %%sitedesc%%, %%pt_single%%, %%tag%%).
567 $template = preg_replace('/%%[a-z0-9_-]+%%/i', '', $template);
568
569 // Tidy whitespace left by removed tokens. Pattern_Resolver also collapses
570 // whitespace/separators at render time, so this is mostly cosmetic.
571 $template = preg_replace('/\s+/', ' ', $template);
572
573 return trim($template);
574 }
575
576 /**
577 * Parse Yoast's `_yoast_wpseo_meta-robots-adv` value.
578 *
579 * Stored as a comma-separated list of advanced directives
580 * (e.g. "noimageindex,nosnippet"), or "-" / "none" when no
581 * extras are selected.
582 *
583 * @param mixed $value Raw meta value from Yoast
584 * @return array{noarchive:int,noimageindex:int,nosnippet:int}
585 */
586 private function parse_yoast_advanced_robots($value): array {
587 $result = ['noarchive' => 0, 'noimageindex' => 0, 'nosnippet' => 0];
588
589 // Foreign data first: non-string meta degrades to '' → no extras (see abstract).
590 $value = $this->stringify_template_value($value);
591
592 if ($value === '' || $value === '-' || $value === 'none') {
593 return $result;
594 }
595
596 $tokens = array_map('trim', explode(',', $value));
597 foreach ($tokens as $token) {
598 if (isset($result[$token])) {
599 $result[$token] = 1;
600 }
601 }
602
603 return $result;
604 }
605
606 /**
607 * Map Yoast's tri-state post robots values to canonical 0/1 flags.
608 *
609 * Yoast stores `_yoast_wpseo_meta-robots-noindex` as 0 (post-type default),
610 * 1 (noindex) or 2 (index) — so ONLY an explicit 1 means noindex; 2 (index)
611 * and 0 (default) must not. `_yoast_wpseo_meta-robots-nofollow` is a plain
612 * 0/1 boolean (1 = nofollow).
613 *
614 * @param mixed $noindex_raw Raw meta-robots-noindex value
615 * @param mixed $nofollow_raw Raw meta-robots-nofollow value
616 * @return array{noindex:int,nofollow:int}
617 */
618 private function map_yoast_post_robots($noindex_raw, $nofollow_raw): array {
619 return [
620 'noindex' => ((int) $noindex_raw === 1) ? 1 : 0,
621 'nofollow' => ((int) $nofollow_raw === 1) ? 1 : 0,
622 ];
623 }
624
625 /**
626 * Flatten the wpseo_taxonomy_meta option to a term_id => meta map.
627 *
628 * The option is nested as $option[$taxonomy][$term_id] = [...]. term_id is
629 * globally unique in WordPress, so keying by it is safe and gives a stable,
630 * paginatable order.
631 *
632 * @return array<int,array> Map of term_id => Yoast term meta array
633 */
634 private function get_flattened_taxonomy_meta(): array {
635 $tax_meta = get_option('wpseo_taxonomy_meta', []);
636 $tax_meta = is_array($tax_meta) ? $tax_meta : [];
637 if (!is_array($tax_meta)) {
638 return [];
639 }
640
641 $flat = [];
642 foreach ($tax_meta as $terms) {
643 if (!is_array($terms)) {
644 continue;
645 }
646 foreach ($terms as $term_id => $meta) {
647 if (is_array($meta) && (int) $term_id > 0) {
648 $flat[(int) $term_id] = $meta;
649 }
650 }
651 }
652
653 ksort($flat);
654
655 return $flat;
656 }
657
658 /**
659 * Convert a Yoast separator slug (e.g. "sc-dash") to its literal character.
660 *
661 * Yoast stores the title separator as a slug, not the glyph. An unknown or
662 * already-literal value is returned unchanged.
663 *
664 * @param string $separator Raw separator value from wpseo_titles
665 * @return string Literal separator character
666 */
667 /**
668 * Read a Yoast site-identity value, tolerating where the version stores it.
669 *
670 * Yoast moved company_name / company_logo / company_or_person / person_name /
671 * website_name / alternate_website_name from the `wpseo` option into
672 * `wpseo_titles` (around Yoast 14). Reading only `wpseo` — as this exporter
673 * originally did — silently returned nothing on every modern install, so the
674 * organization name and logo never migrated. Check the modern location
675 * first, then fall back for older installs.
676 *
677 * @param array $titles `wpseo_titles` option
678 * @param array $wpseo `wpseo` option
679 * @param string $key Setting key
680 * @return string Value, or '' when absent from both
681 */
682 private function yoast_identity(array $titles, array $wpseo, string $key): string {
683 foreach ([$titles, $wpseo] as $source) {
684 if (!empty($source[$key]) && is_scalar($source[$key])) {
685 return (string) $source[$key];
686 }
687 }
688
689 return '';
690 }
691
692 /**
693 * Extract Yoast's Knowledge Graph entity into ThinkRank's vocabulary.
694 *
695 * Yoast stores `company_or_person` ('company' | 'person') alongside separate
696 * name fields; ThinkRank models the same split as organization/person.
697 *
698 * @param array $titles `wpseo_titles` option
699 * @param array $wpseo `wpseo` option
700 * @return array{type:string,name:string}
701 */
702 private function extract_yoast_knowledge_graph(array $titles, array $wpseo): array {
703 $raw = strtolower(trim($this->yoast_identity($titles, $wpseo, 'company_or_person')));
704
705 if ($raw === 'person') {
706 return [
707 'type' => 'person',
708 'name' => $this->yoast_identity($titles, $wpseo, 'person_name'),
709 ];
710 }
711
712 if ($raw === 'company') {
713 return [
714 'type' => 'organization',
715 'name' => $this->yoast_identity($titles, $wpseo, 'company_name'),
716 ];
717 }
718
719 // Yoast also allows "neither" (empty), which the migrator skips.
720 return ['type' => '', 'name' => ''];
721 }
722
723 /**
724 * Map Yoast's per-context title formats onto ThinkRank's Site Identity keys.
725 *
726 * A DIFFERENT token dialect from `post_type_settings`: the Site Identity
727 * renderer resolves %site_title%/%post_title%/%category_title%/… while the
728 * Global SEO renderer resolves %title%/%sitename%/%excerpt%. Converting with
729 * the wrong one renders the token literally on the front end.
730 *
731 * @param array $titles `wpseo_titles` option
732 * @return array Map of ThinkRank title-format key => converted template
733 */
734 private function extract_yoast_title_formats(array $titles): array {
735 // ThinkRank key => [Yoast key, token Yoast's %%title%%/%%term_title%%
736 // stands for in that context].
737 $map = [
738 'homepage_title' => ['title-home-wpseo', ''],
739 'post_title' => ['title-post', '%post_title%'],
740 'page_title' => ['title-page', '%page_title%'],
741 'category_title' => ['title-tax-category', '%category_title%'],
742 'tag_title' => ['title-tax-post_tag', '%tag_title%'],
743 'search_title' => ['title-search-wpseo', '%search_term%'],
744 'archive_title' => ['title-archive-wpseo', '%archive_title%'],
745 'author_title' => ['title-author-wpseo', '%author_name%'],
746 ];
747
748 $formats = [];
749 foreach ($map as $tr_key => [$yoast_key, $context_token]) {
750 $raw = (string) ($titles[$yoast_key] ?? '');
751 if ($raw === '') {
752 continue;
753 }
754
755 $converted = $this->convert_identity_pattern($raw, $context_token);
756 if ($converted !== '') {
757 $formats[$tr_key] = $converted;
758 }
759 }
760
761 return $formats;
762 }
763
764 /**
765 * Convert a Yoast title template into ThinkRank's Site Identity token
766 * vocabulary, preserving structure.
767 *
768 * Tokens ThinkRank cannot resolve (%%page%%, %%pt_single%%, %%currentyear%%)
769 * are stripped so they never render literally, and a separator left dangling
770 * by that strip is dropped rather than rendering as a leading/trailing dash.
771 *
772 * @param string $template Raw Yoast template
773 * @param string $context_token Token %%title%%/%%term_title%% stands for (may be '')
774 * @return string ThinkRank Site Identity template
775 */
776 private function convert_identity_pattern(string $template, string $context_token): string {
777 if ($template === '' || strpos($template, '%%') === false) {
778 return trim($template);
779 }
780
781 $map = [
782 '%%sitename%%' => '%site_title%',
783 '%%sitedesc%%' => '%site_description%',
784 '%%sep%%' => '%sep%',
785 '%%date%%' => '%date%',
786 '%%searchphrase%%' => '%search_term%',
787 '%%name%%' => '%author_name%',
788 // Yoast's own archive/term variables, which ThinkRank resolves under
789 // the same names.
790 '%%archive_title%%' => '%archive_title%',
791 '%%category%%' => '%category_title%',
792 '%%tag%%' => '%tag_title%',
793 // Rank Math leftovers. Yoast's own Rank Math importer rewrites title
794 // settings with a blind str_replace('%', '%%') and NO token
795 // translation, so a site that came RankMath -> Yoast carries Rank
796 // Math tokens wrapped in Yoast's double-percent syntax — tokens Yoast
797 // itself cannot resolve either. Map them rather than stripping them,
798 // or the format silently loses its search term / term name.
799 '%%search_query%%' => '%search_term%',
800 // NOT the WooCommerce product variables, deliberately. This
801 // converter writes Site Identity title formats, and those are
802 // resolved by SEO_Manager::process_title_template() against its own
803 // vocabulary — %site_title%, %separator%, %search_term% and the
804 // rest — which has no %price%/%sku%/%brand% and, unlike
805 // Pattern_Resolver, does not strip what it cannot resolve. Mapping
806 // them here published "Search pennant - %price% - Media Wipe" in a
807 // live <title>: a silent loss traded for the literal leak #715 is
808 // about. The formats this feeds are the homepage, search, archive,
809 // author, category and tag titles, where there is no one product
810 // for a price to belong to either way, so the strip below is the
811 // right answer. The per-post converter keeps them, because
812 // Pattern_Resolver does resolve them: see
813 // convert_template_pattern().
814 ];
815 if ($context_token !== '') {
816 $map['%%title%%'] = $context_token;
817 $map['%%term_title%%'] = $context_token;
818 $map['%%term%%'] = $context_token; // Rank Math leftover (see above)
819 }
820 $template = str_replace(array_keys($map), array_values($map), $template);
821
822 // Drop any remaining Yoast token ThinkRank cannot resolve.
823 $template = preg_replace('/%%[a-z0-9_-]+%%/i', '', $template);
824
825 $template = preg_replace('/\s{2,}/', ' ', (string) $template);
826 $template = trim((string) $template);
827 $template = preg_replace('/^(?:%sep%)\s*/', '', $template);
828 $template = preg_replace('/\s*(?:%sep%)$/', '', (string) $template);
829
830 return trim((string) $template);
831 }
832
833 /**
834 * Extract Yoast's author-archive behaviour for ThinkRank's Author Archives
835 * feature (author_archives_enabled / _title / _meta_desc).
836 *
837 * The archive noindex flag travels separately in `data.noindex_archives`.
838 *
839 * @param array $titles `wpseo_titles` option
840 * @return array Author archive settings
841 */
842 private function extract_yoast_author_archives(array $titles): array {
843 return [
844 // Yoast stores the NEGATIVE `disable-author`; ThinkRank stores the
845 // positive `enabled`, so invert. Cast loosely — Yoast has stored this
846 // as both a real boolean and the string 'on'.
847 'enabled' => !filter_var($titles['disable-author'] ?? false, FILTER_VALIDATE_BOOLEAN),
848 'title' => $this->convert_identity_pattern((string) ($titles['title-author-wpseo'] ?? ''), '%author_name%'),
849 'description' => $this->convert_identity_pattern((string) ($titles['metadesc-author-wpseo'] ?? ''), '%author_name%'),
850 ];
851 }
852
853 /**
854 * Extract News/Video sitemap post types from the Yoast News SEO and Video
855 * SEO add-ons, when installed. Both are separate paid plugins, so their
856 * options are usually absent — an empty result means "nothing to migrate".
857 *
858 * @return array News/Video post type lists (absent keys omitted)
859 */
860 private function extract_yoast_publisher_sitemaps(): array {
861 $out = [];
862
863 $news = get_option('wpseo_news', []);
864 $news = is_array($news) ? $news : [];
865 if (is_array($news) && !empty($news['news_sitemap_include_post_types'])) {
866 // Yoast News stores this as [post_type => 'on'] rather than a list.
867 $types = $news['news_sitemap_include_post_types'];
868 $out['news_post_types'] = is_array($types) && $this->is_assoc_toggle_map($types)
869 ? array_keys(array_filter($types))
870 : array_values(array_map('strval', (array) $types));
871 }
872
873 $video = get_option('wpseo_video', []);
874 $video = is_array($video) ? $video : [];
875 if (is_array($video) && !empty($video['videositemap_posttypes'])) {
876 $types = $video['videositemap_posttypes'];
877 $out['video_post_types'] = is_array($types) && $this->is_assoc_toggle_map($types)
878 ? array_keys(array_filter($types))
879 : array_values(array_map('strval', (array) $types));
880 }
881
882 return $out;
883 }
884
885 /**
886 * Whether an array is a [slug => truthy] toggle map rather than a plain list
887 * of slugs. Yoast's add-ons have used both shapes across versions.
888 *
889 * @param array $value Candidate array
890 * @return bool
891 */
892 private function is_assoc_toggle_map(array $value): bool {
893 return array_keys($value) !== range(0, count($value) - 1);
894 }
895
896 private function map_yoast_separator($separator): string {
897 // Foreign data first: a non-string separator setting degrades to '' so
898 // the default fallback applies instead of fatalling (see abstract).
899 $separator = $this->stringify_template_value($separator);
900
901 $map = [
902 'sc-dash' => '-',
903 'sc-ndash' => '–',
904 'sc-mdash' => '—',
905 'sc-colon' => ':',
906 'sc-middot' => '·',
907 'sc-bull' => '•',
908 'sc-star' => '*',
909 'sc-smstar' => '⋆',
910 'sc-pipe' => '|',
911 'sc-tilde' => '~',
912 'sc-laquo' => '«',
913 'sc-raquo' => '»',
914 'sc-lt' => '>',
915 'sc-gt' => '<',
916 ];
917
918 return $map[$separator] ?? ($separator !== '' ? $separator : '-');
919 }
920
921 /**
922 * Parse Yoast's additional focus keywords JSON
923 *
924 * @param mixed $json JSON string from _yoast_wpseo_focuskeywords
925 * @return array Array of additional keyword strings
926 */
927 private function parse_yoast_additional_keywords($json): array {
928 // Foreign data first: non-string meta degrades to '' → no keywords (see abstract).
929 $json = $this->stringify_template_value($json);
930
931 if (empty($json)) {
932 return [];
933 }
934
935 $decoded = json_decode($json, true);
936 if (!is_array($decoded)) {
937 return [];
938 }
939
940 $keywords = [];
941 foreach ($decoded as $item) {
942 if (isset($item['keyword']) && !empty($item['keyword'])) {
943 $keywords[] = $item['keyword'];
944 }
945 }
946
947 return $keywords;
948 }
949
950 /**
951 * Extract post type SEO settings from Yoast titles option
952 *
953 * @param array $titles Yoast wpseo_titles option
954 * @return array Post type settings
955 */
956 private function extract_post_type_settings(array $titles): array {
957 $settings = [];
958 $post_types = get_post_types(['public' => true], 'names');
959
960 // Yoast's link suggestions is a single GLOBAL toggle, not per-post-type
961 // like Rank Math's and ThinkRank's. Only a deliberate "off" carries
962 // information (both plugins default it on), so an off is fanned out to
963 // every public type and an on is left alone.
964 $wpseo = get_option('wpseo', []);
965 $link_suggestions_off = is_array($wpseo)
966 && array_key_exists('enable_link_suggestions', $wpseo)
967 && !$wpseo['enable_link_suggestions'];
968
969 foreach ($post_types as $pt) {
970 $pt_settings = [];
971 if ($link_suggestions_off) {
972 $pt_settings['link_suggestions'] = false;
973 }
974 if (isset($titles["title-{$pt}"])) {
975 $pt_settings['title_template'] = $this->convert_template_pattern($titles["title-{$pt}"]);
976 }
977 if (isset($titles["metadesc-{$pt}"])) {
978 $pt_settings['description_template'] = $this->convert_template_pattern($titles["metadesc-{$pt}"]);
979 }
980 if (isset($titles["noindex-{$pt}"])) {
981 $pt_settings['noindex'] = (bool) $titles["noindex-{$pt}"];
982 }
983 if (!empty($pt_settings)) {
984 $settings[$pt] = $pt_settings;
985 }
986 }
987
988 return $settings;
989 }
990
991 /**
992 * Extract taxonomy SEO settings from Yoast titles option
993 *
994 * @param array $titles Yoast wpseo_titles option
995 * @return array Taxonomy settings
996 */
997 private function extract_taxonomy_settings(array $titles): array {
998 $settings = [];
999 $taxonomies = get_taxonomies(['public' => true], 'names');
1000
1001 foreach ($taxonomies as $tax) {
1002 $tax_settings = [];
1003 if (isset($titles["title-tax-{$tax}"])) {
1004 $tax_settings['title_template'] = $this->convert_template_pattern($titles["title-tax-{$tax}"]);
1005 }
1006 if (isset($titles["metadesc-tax-{$tax}"])) {
1007 $tax_settings['description_template'] = $this->convert_template_pattern($titles["metadesc-tax-{$tax}"]);
1008 }
1009 if (isset($titles["noindex-tax-{$tax}"])) {
1010 $tax_settings['noindex'] = (bool) $titles["noindex-tax-{$tax}"];
1011 }
1012 if (!empty($tax_settings)) {
1013 $settings[$tax] = $tax_settings;
1014 }
1015 }
1016
1017 return $settings;
1018 }
1019
1020 /**
1021 * A product's identifier from Yoast WooCommerce SEO.
1022 *
1023 * Yoast stores every identifier type in one serialised map keyed by type
1024 * (`gtin8`, `gtin12`, `gtin13`, `gtin14`, `isbn`, `mpn`) rather than in a
1025 * field per type. ThinkRank Pro holds a single identifier, so the first
1026 * non-empty one wins, in the order Google prefers: a GTIN identifies the
1027 * product itself, an MPN only identifies it within one manufacturer, and
1028 * an ISBN applies to books alone.
1029 *
1030 * @since 2.10.1
1031 *
1032 * @param array $meta Post meta for the product.
1033 * @return string Identifier, or '' when the product has none.
1034 */
1035 private function extract_product_identifier(array $meta): string {
1036 $raw = $meta['wpseo_global_identifier_values'] ?? '';
1037
1038 if (is_string($raw) && '' !== $raw) {
1039 $decoded = maybe_unserialize($raw);
1040 $raw = is_array($decoded) ? $decoded : [];
1041 }
1042
1043 if (!is_array($raw) || empty($raw)) {
1044 return '';
1045 }
1046
1047 foreach (['gtin13', 'gtin14', 'gtin12', 'gtin8', 'mpn', 'isbn'] as $type) {
1048 $value = trim((string) ($raw[$type] ?? ''));
1049
1050 if ('' !== $value) {
1051 return $value;
1052 }
1053 }
1054
1055 return '';
1056 }
1057 }
1058