PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.2.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.2.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 / 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.2.0, at includes/admin/importers/class-yoast-exporter.php

995 lines 42.2 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 'schema_type' => $meta['_yoast_wpseo_schema_page_type'] ?? '',
160 // Yoast "cornerstone content" maps to ThinkRank pillar content.
161 'pillar_content' => (int) ($meta['_yoast_wpseo_is_cornerstone'] ?? 0),
162 ],
163 'extended' => [
164 'is_cornerstone' => (bool) ($meta['_yoast_wpseo_is_cornerstone'] ?? false),
165 'focus_keywords_additional' => $this->parse_yoast_additional_keywords($meta['_yoast_wpseo_focuskeywords'] ?? ''),
166 'breadcrumb_title' => $meta['_yoast_wpseo_bctitle'] ?? '',
167 'advanced_robots' => $meta['_yoast_wpseo_meta-robots-adv'] ?? '',
168 'schema_article_type' => $meta['_yoast_wpseo_schema_article_type'] ?? '',
169 'og_image_id' => $meta['_yoast_wpseo_opengraph-image-id'] ?? '',
170 'twitter_image_id' => $meta['_yoast_wpseo_twitter-image-id'] ?? '',
171 'estimated_reading_time' => (int) ($meta['_yoast_wpseo_estimated-reading-time-minutes'] ?? 0),
172 'wordproof_timestamp' => $meta['_yoast_wpseo_wordproof_timestamp'] ?? '',
173 'inclusive_language_score' => $meta['_yoast_wpseo_inclusive-language-score'] ?? '',
174 'linkdex' => $meta['_yoast_wpseo_linkdex'] ?? '',
175 'content_score' => $meta['_yoast_wpseo_content_score'] ?? '',
176 ],
177 ];
178 }
179
180 return $records;
181 }
182
183 /**
184 * {@inheritDoc}
185 */
186 protected function export_termmeta_page(int $page): array {
187 // Yoast keeps term SEO in the wpseo_taxonomy_meta option
188 // ($option[$taxonomy][$term_id] = [...]), NOT the termmeta table.
189 // Keys are wpseo_*-prefixed; the term description is wpseo_desc (not
190 // wpseo_metadesc) and wpseo_noindex is a string (default/index/noindex).
191 $flat = $this->get_flattened_taxonomy_meta();
192 if (empty($flat)) {
193 return [];
194 }
195
196 $offset = ($page - 1) * $this->chunk_size;
197 $slice = array_slice($flat, $offset, $this->chunk_size, true);
198 if (empty($slice)) {
199 return [];
200 }
201
202 $records = [];
203 foreach ($slice as $term_id => $meta) {
204 $term_id = (int) $term_id;
205 $noindex = (($meta['wpseo_noindex'] ?? 'default') === 'noindex') ? 1 : 0;
206
207 $records[] = [
208 'object_id' => $term_id,
209 'object_type' => 'term',
210 'source_plugin' => $this->plugin_slug,
211 'data' => [
212 'seo_title' => $this->convert_template_variables($meta['wpseo_title'] ?? ''),
213 'meta_description' => $this->convert_template_variables($meta['wpseo_desc'] ?? ''),
214 'focus_keyword' => $meta['wpseo_focuskw'] ?? '',
215 'canonical_url' => $meta['wpseo_canonical'] ?? '',
216 'noindex' => $noindex,
217 'nofollow' => 0,
218 'og_title' => $this->convert_template_variables($meta['wpseo_opengraph-title'] ?? ''),
219 'og_description' => $this->convert_template_variables($meta['wpseo_opengraph-description'] ?? ''),
220 ],
221 'extended' => [
222 'breadcrumb_title' => $meta['wpseo_bctitle'] ?? '',
223 'og_image' => $meta['wpseo_opengraph-image'] ?? '',
224 'twitter_title' => $meta['wpseo_twitter-title'] ?? '',
225 'twitter_description' => $meta['wpseo_twitter-description'] ?? '',
226 ],
227 ];
228 }
229
230 return $records;
231 }
232
233 /**
234 * {@inheritDoc}
235 */
236 protected function export_usermeta_page(int $page): array {
237 global $wpdb;
238
239 $offset = ($page - 1) * $this->chunk_size;
240
241 $user_ids = $wpdb->get_col(
242 $wpdb->prepare(
243 "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key LIKE %s ORDER BY user_id ASC LIMIT %d OFFSET %d",
244 $wpdb->esc_like('wpseo_') . '%',
245 $this->chunk_size,
246 $offset
247 )
248 );
249
250 if (empty($user_ids)) {
251 return [];
252 }
253
254 $records = [];
255 foreach ($user_ids as $user_id) {
256 $user_id = (int) $user_id;
257
258 // Author title/description go in canonical `data` (seo_title /
259 // meta_description) so the plugin-agnostic migrator writes them to
260 // the `_thinkrank_seo_title` / `_thinkrank_meta_description` USER meta
261 // that Author_Archives_Manager reads. ThinkRank consumes those values
262 // verbatim, so resolve Yoast template tags to literals here — seeding
263 // %%name%% with the author display name (the generic converter only
264 // knows post context).
265 $display_name = (string) get_the_author_meta('display_name', $user_id);
266 $author_title = (string) (get_user_meta($user_id, 'wpseo_title', true) ?: '');
267 $author_desc = (string) (get_user_meta($user_id, 'wpseo_metadesc', true) ?: '');
268 $author_title = $this->convert_template_variables(str_replace('%%name%%', $display_name, $author_title));
269 $author_desc = $this->convert_template_variables(str_replace('%%name%%', $display_name, $author_desc));
270
271 $records[] = [
272 'object_id' => $user_id,
273 'object_type' => 'user',
274 'source_plugin' => $this->plugin_slug,
275 'data' => [
276 'seo_title' => $author_title,
277 'meta_description' => $author_desc,
278 ],
279 'extended' => [
280 'author_noindex' => (bool) get_user_meta($user_id, 'wpseo_noindex_author', true),
281 'social_profiles' => [
282 'facebook' => get_user_meta($user_id, 'facebook', true) ?: '',
283 'twitter' => get_user_meta($user_id, 'twitter', true) ?: '',
284 'linkedin' => get_user_meta($user_id, 'linkedin', true) ?: '',
285 'instagram' => get_user_meta($user_id, 'instagram', true) ?: '',
286 'youtube' => get_user_meta($user_id, 'youtube_url', true) ?: '',
287 'pinterest' => get_user_meta($user_id, 'pinterest_url', true) ?: '',
288 'wikipedia' => get_user_meta($user_id, 'wikipedia_url', true) ?: '',
289 ],
290 ],
291 ];
292 }
293
294 return $records;
295 }
296
297 /**
298 * {@inheritDoc}
299 */
300 protected function export_settings(): array {
301 // get_option()'s [] default only applies to a missing row; a row that
302 // exists but holds a scalar/false (reset, legacy, corrupted) would flow
303 // into the array-typed helpers below and throw a TypeError. Normalize.
304 $wpseo = get_option('wpseo', []);
305 $wpseo = is_array($wpseo) ? $wpseo : [];
306 $titles = get_option('wpseo_titles', []);
307 $titles = is_array($titles) ? $titles : [];
308 $social = get_option('wpseo_social', []);
309 $social = is_array($social) ? $social : [];
310
311 return [
312 [
313 'type' => 'settings',
314 'source_plugin' => $this->plugin_slug,
315 'data' => [
316 'separator' => $this->map_yoast_separator($titles['separator'] ?? 'sc-dash'),
317 'homepage_title' => $this->convert_template_variables($titles['title-home-wpseo'] ?? ''),
318 'homepage_description' => $this->convert_template_variables($titles['metadesc-home-wpseo'] ?? ''),
319 'organization_name' => $this->yoast_identity($titles, $wpseo, 'company_name'),
320 'organization_logo' => $this->yoast_identity($titles, $wpseo, 'company_logo'),
321 // Schema `alternateName` for the site.
322 'alternate_name' => $this->yoast_identity($titles, $wpseo, 'alternate_website_name'),
323 // Knowledge Graph entity: Yoast's company_or_person, with the
324 // name taken from whichever side it points at.
325 'knowledge_graph' => $this->extract_yoast_knowledge_graph($titles, $wpseo),
326 // Yoast's IndexNow key is a public verification token served
327 // at /{key}.txt, not an account secret — carrying it over
328 // avoids re-verifying the site with IndexNow.
329 'instant_indexing' => [
330 'api_key' => (string) ($wpseo['index_now_key'] ?? ''),
331 ],
332 'social_profiles' => [
333 'facebook' => $social['facebook_site'] ?? '',
334 'twitter' => $social['twitter_site'] ?? '',
335 'instagram' => $social['instagram_url'] ?? '',
336 'linkedin' => $social['linkedin_url'] ?? '',
337 'youtube' => $social['youtube_url'] ?? '',
338 'pinterest' => $social['pinterest_url'] ?? '',
339 'wikipedia' => $social['wikipedia_url'] ?? '',
340 ],
341 'noindex_archives' => [
342 'date' => !empty($titles['noindex-archive-wpseo']),
343 'author' => !empty($titles['noindex-author-wpseo']),
344 ],
345 // Default Twitter card size for ThinkRank's Social Meta
346 // settings.
347 'twitter_card_type' => (string) ($social['twitter_card_type'] ?? ''),
348 // Site-wide social defaults with direct ThinkRank homes.
349 'social_defaults' => [
350 'og_default_image' => (string) ($social['og_default_image'] ?? ''),
351 ],
352 ],
353 'extended' => [
354 'breadcrumb_settings' => [
355 'enabled' => !empty($titles['breadcrumbs-enable']),
356 'home_label' => $titles['breadcrumbs-home'] ?? 'Home',
357 'separator' => $titles['breadcrumbs-sep'] ?? '»',
358 'prefix' => (string) ($titles['breadcrumbs-prefix'] ?? ''),
359 ],
360 // Webmaster-tools verification codes. Pinterest is applied
361 // (the one ThinkRank renders); the rest is preserved and
362 // gates /import/cleanup.
363 'webmaster_tools' => array_filter([
364 'google' => (string) ($wpseo['googleverify'] ?? ''),
365 'bing' => (string) ($wpseo['msverify'] ?? ''),
366 'yandex' => (string) ($wpseo['yandexverify'] ?? ''),
367 'baidu' => (string) ($wpseo['baiduverify'] ?? ''),
368 'pinterest' => (string) ($social['pinterestverify'] ?? ''),
369 ]),
370 'post_type_settings' => $this->extract_post_type_settings($titles),
371 'taxonomy_settings' => $this->extract_taxonomy_settings($titles),
372 // Per-context title formats in ThinkRank's SITE IDENTITY token
373 // vocabulary, which is a different dialect from the Global SEO
374 // one used by post_type_settings above.
375 'title_formats' => $this->extract_yoast_title_formats($titles),
376 // Author archive behaviour (Author Archives feature).
377 'author_archives' => $this->extract_yoast_author_archives($titles),
378 // Role Manager assignments (live on the roles, not in wpseo_*).
379 'role_capabilities' => $this->extract_role_capabilities('wpseo_'),
380 // News/Video sitemap post types, when the Yoast News SEO /
381 // Video SEO add-ons are installed.
382 'publisher_sitemaps' => $this->extract_yoast_publisher_sitemaps(),
383 'og_frontpage_title' => $social['og_frontpage_title'] ?? '',
384 'og_frontpage_desc' => $social['og_frontpage_desc'] ?? '',
385 'og_frontpage_image' => $social['og_frontpage_image'] ?? '',
386 // Yoast's XML sitemap is otherwise filter-driven: per-type
387 // inclusion follows the noindex settings (already captured in
388 // post_type_settings / taxonomy_settings) and it exposes no
389 // links/images/ping options. The one real toggle is the global
390 // enable flag; only flag has_data when it is actually present so
391 // the migrator does not disable ThinkRank's sitemap by default.
392 'sitemap_settings' => [
393 'enabled' => !empty($wpseo['enable_xml_sitemap']),
394 'has_data' => array_key_exists('enable_xml_sitemap', $wpseo),
395 ],
396 ],
397 ],
398 ];
399 }
400
401 /**
402 * {@inheritDoc}
403 */
404 protected function export_redirections_page(int $page): array {
405 // Yoast Premium stores redirections in a custom table
406 global $wpdb;
407
408 $table_name = $wpdb->prefix . 'yoast_seo_redirects';
409 $table_exists = $wpdb->get_var(
410 $wpdb->prepare("SHOW TABLES LIKE %s", $table_name)
411 );
412
413 if (!$table_exists) {
414 return [];
415 }
416
417 $offset = ($page - 1) * $this->chunk_size;
418
419 $rows = $wpdb->get_results(
420 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
421 $wpdb->prepare(
422 "SELECT * FROM {$table_name} ORDER BY id ASC LIMIT %d OFFSET %d",
423 $this->chunk_size,
424 $offset
425 ),
426 ARRAY_A
427 );
428 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
429
430 if (empty($rows)) {
431 return [];
432 }
433
434 $records = [];
435 foreach ($rows as $row) {
436 $records[] = [
437 'object_type' => 'redirection',
438 'source_plugin' => $this->plugin_slug,
439 'data' => [],
440 'extended' => [
441 'source_url' => $row['origin'] ?? '',
442 'target_url' => $row['url'] ?? '',
443 'http_code' => (int) ($row['type'] ?? 301),
444 'is_regex' => !empty($row['format'] ?? '') && $row['format'] === 'regex',
445 'enabled' => true,
446 ],
447 ];
448 }
449
450 return $records;
451 }
452
453 /**
454 * {@inheritDoc}
455 */
456 protected function convert_template_variables($value, ?int $post_id = null): string {
457 // Foreign data first: booleans/arrays in the source plugin's options
458 // must degrade to '' here, not fatal the migration (see abstract).
459 $value = $this->stringify_template_value($value);
460
461 if (empty($value) || strpos($value, '%%') === false) {
462 return $value;
463 }
464
465 $replacements = [
466 '%%sitename%%' => get_bloginfo('name'),
467 '%%sitedesc%%' => get_bloginfo('description'),
468 '%%sep%%' => '-',
469 '%%page%%' => '',
470 '%%pagenumber%%' => '',
471 '%%pagetotal%%' => '',
472 '%%currentyear%%' => gmdate('Y'),
473 '%%currentdate%%' => gmdate('Y-m-d'),
474 '%%currentmonth%%' => gmdate('F'),
475 '%%currentday%%' => gmdate('j'),
476 ];
477
478 // Add post-specific replacements
479 if ($post_id) {
480 $post = get_post($post_id);
481 if ($post) {
482 $replacements['%%title%%'] = $post->post_title;
483 $replacements['%%excerpt%%'] = wp_trim_words($post->post_excerpt ?: wp_trim_words(wp_strip_all_tags($post->post_content), 55), 55);
484 $replacements['%%date%%'] = get_the_date('', $post);
485 $replacements['%%modified%%'] = get_the_modified_date('', $post);
486 $replacements['%%id%%'] = (string) $post_id;
487 $replacements['%%name%%'] = get_the_author_meta('display_name', (int) $post->post_author);
488
489 // Post type
490 $post_type_obj = get_post_type_object($post->post_type);
491 $replacements['%%pt_single%%'] = $post_type_obj ? $post_type_obj->labels->singular_name : '';
492 $replacements['%%pt_plural%%'] = $post_type_obj ? $post_type_obj->labels->name : '';
493
494 // Category
495 $categories = get_the_category($post_id);
496 $replacements['%%category%%'] = !empty($categories) ? $categories[0]->name : '';
497 $replacements['%%primary_category%%'] = !empty($categories) ? $categories[0]->name : '';
498
499 // Tags
500 $tags = get_the_tags($post_id);
501 $replacements['%%tag%%'] = !empty($tags) ? $tags[0]->name : '';
502 }
503 }
504
505 $value = str_replace(array_keys($replacements), array_values($replacements), $value);
506
507 // Strip any remaining unknown variables
508 $value = preg_replace('/%%[a-z0-9_-]+%%/i', '', $value);
509
510 return trim($value);
511 }
512
513 /**
514 * Translate a Yoast title/description *template* into ThinkRank pattern syntax.
515 *
516 * Unlike convert_template_variables() — which resolves a per-post value to a
517 * literal string — post-type and taxonomy templates must stay templates so the
518 * frontend can resolve them per request. Yoast uses double-percent tokens
519 * (`%%title%%`); ThinkRank's Pattern_Resolver uses single-percent tokens
520 * (`%title%`) with a smaller vocabulary. Map the tokens ThinkRank can resolve
521 * and strip any Yoast token it has no equivalent for, so an imported template
522 * never renders stray `%` characters.
523 *
524 * @param mixed $template Raw Yoast template (e.g. "%%title%% %%sep%% %%sitename%%").
525 * @return string ThinkRank pattern (e.g. "%title% %sep% %sitename%").
526 */
527 private function convert_template_pattern($template): string {
528 // Foreign data first: booleans/arrays in the source plugin's options
529 // must degrade to '' here, not fatal the migration (see abstract).
530 $template = $this->stringify_template_value($template);
531
532 if ($template === '' || strpos($template, '%%') === false) {
533 return $template;
534 }
535
536 // Yoast token => ThinkRank Pattern_Resolver token. Only tokens the
537 // resolver understands (see Pattern_Resolver::placeholders_for()) are
538 // mapped; everything else is stripped below.
539 $map = [
540 '%%title%%' => '%title%',
541 '%%sitename%%' => '%sitename%',
542 '%%sep%%' => '%sep%',
543 '%%excerpt%%' => '%excerpt%',
544 '%%excerpt_only%%' => '%excerpt%',
545 '%%date%%' => '%date%',
546 '%%modified%%' => '%modified%',
547 '%%name%%' => '%author%',
548 '%%category%%' => '%category%',
549 '%%primary_category%%' => '%category%',
550 ];
551
552 $template = str_replace(array_keys($map), array_values($map), $template);
553
554 // Drop any remaining Yoast tokens ThinkRank cannot resolve
555 // (e.g. %%page%%, %%sitedesc%%, %%pt_single%%, %%tag%%).
556 $template = preg_replace('/%%[a-z0-9_-]+%%/i', '', $template);
557
558 // Tidy whitespace left by removed tokens. Pattern_Resolver also collapses
559 // whitespace/separators at render time, so this is mostly cosmetic.
560 $template = preg_replace('/\s+/', ' ', $template);
561
562 return trim($template);
563 }
564
565 /**
566 * Parse Yoast's `_yoast_wpseo_meta-robots-adv` value.
567 *
568 * Stored as a comma-separated list of advanced directives
569 * (e.g. "noimageindex,nosnippet"), or "-" / "none" when no
570 * extras are selected.
571 *
572 * @param mixed $value Raw meta value from Yoast
573 * @return array{noarchive:int,noimageindex:int,nosnippet:int}
574 */
575 private function parse_yoast_advanced_robots($value): array {
576 $result = ['noarchive' => 0, 'noimageindex' => 0, 'nosnippet' => 0];
577
578 // Foreign data first: non-string meta degrades to '' → no extras (see abstract).
579 $value = $this->stringify_template_value($value);
580
581 if ($value === '' || $value === '-' || $value === 'none') {
582 return $result;
583 }
584
585 $tokens = array_map('trim', explode(',', $value));
586 foreach ($tokens as $token) {
587 if (isset($result[$token])) {
588 $result[$token] = 1;
589 }
590 }
591
592 return $result;
593 }
594
595 /**
596 * Map Yoast's tri-state post robots values to canonical 0/1 flags.
597 *
598 * Yoast stores `_yoast_wpseo_meta-robots-noindex` as 0 (post-type default),
599 * 1 (noindex) or 2 (index) — so ONLY an explicit 1 means noindex; 2 (index)
600 * and 0 (default) must not. `_yoast_wpseo_meta-robots-nofollow` is a plain
601 * 0/1 boolean (1 = nofollow).
602 *
603 * @param mixed $noindex_raw Raw meta-robots-noindex value
604 * @param mixed $nofollow_raw Raw meta-robots-nofollow value
605 * @return array{noindex:int,nofollow:int}
606 */
607 private function map_yoast_post_robots($noindex_raw, $nofollow_raw): array {
608 return [
609 'noindex' => ((int) $noindex_raw === 1) ? 1 : 0,
610 'nofollow' => ((int) $nofollow_raw === 1) ? 1 : 0,
611 ];
612 }
613
614 /**
615 * Flatten the wpseo_taxonomy_meta option to a term_id => meta map.
616 *
617 * The option is nested as $option[$taxonomy][$term_id] = [...]. term_id is
618 * globally unique in WordPress, so keying by it is safe and gives a stable,
619 * paginatable order.
620 *
621 * @return array<int,array> Map of term_id => Yoast term meta array
622 */
623 private function get_flattened_taxonomy_meta(): array {
624 $tax_meta = get_option('wpseo_taxonomy_meta', []);
625 $tax_meta = is_array($tax_meta) ? $tax_meta : [];
626 if (!is_array($tax_meta)) {
627 return [];
628 }
629
630 $flat = [];
631 foreach ($tax_meta as $terms) {
632 if (!is_array($terms)) {
633 continue;
634 }
635 foreach ($terms as $term_id => $meta) {
636 if (is_array($meta) && (int) $term_id > 0) {
637 $flat[(int) $term_id] = $meta;
638 }
639 }
640 }
641
642 ksort($flat);
643
644 return $flat;
645 }
646
647 /**
648 * Convert a Yoast separator slug (e.g. "sc-dash") to its literal character.
649 *
650 * Yoast stores the title separator as a slug, not the glyph. An unknown or
651 * already-literal value is returned unchanged.
652 *
653 * @param string $separator Raw separator value from wpseo_titles
654 * @return string Literal separator character
655 */
656 /**
657 * Read a Yoast site-identity value, tolerating where the version stores it.
658 *
659 * Yoast moved company_name / company_logo / company_or_person / person_name /
660 * website_name / alternate_website_name from the `wpseo` option into
661 * `wpseo_titles` (around Yoast 14). Reading only `wpseo` — as this exporter
662 * originally did — silently returned nothing on every modern install, so the
663 * organization name and logo never migrated. Check the modern location
664 * first, then fall back for older installs.
665 *
666 * @param array $titles `wpseo_titles` option
667 * @param array $wpseo `wpseo` option
668 * @param string $key Setting key
669 * @return string Value, or '' when absent from both
670 */
671 private function yoast_identity(array $titles, array $wpseo, string $key): string {
672 foreach ([$titles, $wpseo] as $source) {
673 if (!empty($source[$key]) && is_scalar($source[$key])) {
674 return (string) $source[$key];
675 }
676 }
677
678 return '';
679 }
680
681 /**
682 * Extract Yoast's Knowledge Graph entity into ThinkRank's vocabulary.
683 *
684 * Yoast stores `company_or_person` ('company' | 'person') alongside separate
685 * name fields; ThinkRank models the same split as organization/person.
686 *
687 * @param array $titles `wpseo_titles` option
688 * @param array $wpseo `wpseo` option
689 * @return array{type:string,name:string}
690 */
691 private function extract_yoast_knowledge_graph(array $titles, array $wpseo): array {
692 $raw = strtolower(trim($this->yoast_identity($titles, $wpseo, 'company_or_person')));
693
694 if ($raw === 'person') {
695 return [
696 'type' => 'person',
697 'name' => $this->yoast_identity($titles, $wpseo, 'person_name'),
698 ];
699 }
700
701 if ($raw === 'company') {
702 return [
703 'type' => 'organization',
704 'name' => $this->yoast_identity($titles, $wpseo, 'company_name'),
705 ];
706 }
707
708 // Yoast also allows "neither" (empty), which the migrator skips.
709 return ['type' => '', 'name' => ''];
710 }
711
712 /**
713 * Map Yoast's per-context title formats onto ThinkRank's Site Identity keys.
714 *
715 * A DIFFERENT token dialect from `post_type_settings`: the Site Identity
716 * renderer resolves %site_title%/%post_title%/%category_title%/… while the
717 * Global SEO renderer resolves %title%/%sitename%/%excerpt%. Converting with
718 * the wrong one renders the token literally on the front end.
719 *
720 * @param array $titles `wpseo_titles` option
721 * @return array Map of ThinkRank title-format key => converted template
722 */
723 private function extract_yoast_title_formats(array $titles): array {
724 // ThinkRank key => [Yoast key, token Yoast's %%title%%/%%term_title%%
725 // stands for in that context].
726 $map = [
727 'homepage_title' => ['title-home-wpseo', ''],
728 'post_title' => ['title-post', '%post_title%'],
729 'page_title' => ['title-page', '%page_title%'],
730 'category_title' => ['title-tax-category', '%category_title%'],
731 'tag_title' => ['title-tax-post_tag', '%tag_title%'],
732 'search_title' => ['title-search-wpseo', '%search_term%'],
733 'archive_title' => ['title-archive-wpseo', '%archive_title%'],
734 'author_title' => ['title-author-wpseo', '%author_name%'],
735 ];
736
737 $formats = [];
738 foreach ($map as $tr_key => [$yoast_key, $context_token]) {
739 $raw = (string) ($titles[$yoast_key] ?? '');
740 if ($raw === '') {
741 continue;
742 }
743
744 $converted = $this->convert_identity_pattern($raw, $context_token);
745 if ($converted !== '') {
746 $formats[$tr_key] = $converted;
747 }
748 }
749
750 return $formats;
751 }
752
753 /**
754 * Convert a Yoast title template into ThinkRank's Site Identity token
755 * vocabulary, preserving structure.
756 *
757 * Tokens ThinkRank cannot resolve (%%page%%, %%pt_single%%, %%currentyear%%)
758 * are stripped so they never render literally, and a separator left dangling
759 * by that strip is dropped rather than rendering as a leading/trailing dash.
760 *
761 * @param string $template Raw Yoast template
762 * @param string $context_token Token %%title%%/%%term_title%% stands for (may be '')
763 * @return string ThinkRank Site Identity template
764 */
765 private function convert_identity_pattern(string $template, string $context_token): string {
766 if ($template === '' || strpos($template, '%%') === false) {
767 return trim($template);
768 }
769
770 $map = [
771 '%%sitename%%' => '%site_title%',
772 '%%sitedesc%%' => '%site_description%',
773 '%%sep%%' => '%sep%',
774 '%%date%%' => '%date%',
775 '%%searchphrase%%' => '%search_term%',
776 '%%name%%' => '%author_name%',
777 // Yoast's own archive/term variables, which ThinkRank resolves under
778 // the same names.
779 '%%archive_title%%' => '%archive_title%',
780 '%%category%%' => '%category_title%',
781 '%%tag%%' => '%tag_title%',
782 // Rank Math leftovers. Yoast's own Rank Math importer rewrites title
783 // settings with a blind str_replace('%', '%%') and NO token
784 // translation, so a site that came RankMath -> Yoast carries Rank
785 // Math tokens wrapped in Yoast's double-percent syntax — tokens Yoast
786 // itself cannot resolve either. Map them rather than stripping them,
787 // or the format silently loses its search term / term name.
788 '%%search_query%%' => '%search_term%',
789 ];
790 if ($context_token !== '') {
791 $map['%%title%%'] = $context_token;
792 $map['%%term_title%%'] = $context_token;
793 $map['%%term%%'] = $context_token; // Rank Math leftover (see above)
794 }
795 $template = str_replace(array_keys($map), array_values($map), $template);
796
797 // Drop any remaining Yoast token ThinkRank cannot resolve.
798 $template = preg_replace('/%%[a-z0-9_-]+%%/i', '', $template);
799
800 $template = preg_replace('/\s{2,}/', ' ', (string) $template);
801 $template = trim((string) $template);
802 $template = preg_replace('/^(?:%sep%)\s*/', '', $template);
803 $template = preg_replace('/\s*(?:%sep%)$/', '', (string) $template);
804
805 return trim((string) $template);
806 }
807
808 /**
809 * Extract Yoast's author-archive behaviour for ThinkRank's Author Archives
810 * feature (author_archives_enabled / _title / _meta_desc).
811 *
812 * The archive noindex flag travels separately in `data.noindex_archives`.
813 *
814 * @param array $titles `wpseo_titles` option
815 * @return array Author archive settings
816 */
817 private function extract_yoast_author_archives(array $titles): array {
818 return [
819 // Yoast stores the NEGATIVE `disable-author`; ThinkRank stores the
820 // positive `enabled`, so invert. Cast loosely — Yoast has stored this
821 // as both a real boolean and the string 'on'.
822 'enabled' => !filter_var($titles['disable-author'] ?? false, FILTER_VALIDATE_BOOLEAN),
823 'title' => $this->convert_identity_pattern((string) ($titles['title-author-wpseo'] ?? ''), '%author_name%'),
824 'description' => $this->convert_identity_pattern((string) ($titles['metadesc-author-wpseo'] ?? ''), '%author_name%'),
825 ];
826 }
827
828 /**
829 * Extract News/Video sitemap post types from the Yoast News SEO and Video
830 * SEO add-ons, when installed. Both are separate paid plugins, so their
831 * options are usually absent — an empty result means "nothing to migrate".
832 *
833 * @return array News/Video post type lists (absent keys omitted)
834 */
835 private function extract_yoast_publisher_sitemaps(): array {
836 $out = [];
837
838 $news = get_option('wpseo_news', []);
839 $news = is_array($news) ? $news : [];
840 if (is_array($news) && !empty($news['news_sitemap_include_post_types'])) {
841 // Yoast News stores this as [post_type => 'on'] rather than a list.
842 $types = $news['news_sitemap_include_post_types'];
843 $out['news_post_types'] = is_array($types) && $this->is_assoc_toggle_map($types)
844 ? array_keys(array_filter($types))
845 : array_values(array_map('strval', (array) $types));
846 }
847
848 $video = get_option('wpseo_video', []);
849 $video = is_array($video) ? $video : [];
850 if (is_array($video) && !empty($video['videositemap_posttypes'])) {
851 $types = $video['videositemap_posttypes'];
852 $out['video_post_types'] = is_array($types) && $this->is_assoc_toggle_map($types)
853 ? array_keys(array_filter($types))
854 : array_values(array_map('strval', (array) $types));
855 }
856
857 return $out;
858 }
859
860 /**
861 * Whether an array is a [slug => truthy] toggle map rather than a plain list
862 * of slugs. Yoast's add-ons have used both shapes across versions.
863 *
864 * @param array $value Candidate array
865 * @return bool
866 */
867 private function is_assoc_toggle_map(array $value): bool {
868 return array_keys($value) !== range(0, count($value) - 1);
869 }
870
871 private function map_yoast_separator($separator): string {
872 // Foreign data first: a non-string separator setting degrades to '' so
873 // the default fallback applies instead of fatalling (see abstract).
874 $separator = $this->stringify_template_value($separator);
875
876 $map = [
877 'sc-dash' => '-',
878 'sc-ndash' => '',
879 'sc-mdash' => '',
880 'sc-colon' => ':',
881 'sc-middot' => '·',
882 'sc-bull' => '',
883 'sc-star' => '*',
884 'sc-smstar' => '',
885 'sc-pipe' => '|',
886 'sc-tilde' => '~',
887 'sc-laquo' => '«',
888 'sc-raquo' => '»',
889 'sc-lt' => '>',
890 'sc-gt' => '<',
891 ];
892
893 return $map[$separator] ?? ($separator !== '' ? $separator : '-');
894 }
895
896 /**
897 * Parse Yoast's additional focus keywords JSON
898 *
899 * @param mixed $json JSON string from _yoast_wpseo_focuskeywords
900 * @return array Array of additional keyword strings
901 */
902 private function parse_yoast_additional_keywords($json): array {
903 // Foreign data first: non-string meta degrades to '' → no keywords (see abstract).
904 $json = $this->stringify_template_value($json);
905
906 if (empty($json)) {
907 return [];
908 }
909
910 $decoded = json_decode($json, true);
911 if (!is_array($decoded)) {
912 return [];
913 }
914
915 $keywords = [];
916 foreach ($decoded as $item) {
917 if (isset($item['keyword']) && !empty($item['keyword'])) {
918 $keywords[] = $item['keyword'];
919 }
920 }
921
922 return $keywords;
923 }
924
925 /**
926 * Extract post type SEO settings from Yoast titles option
927 *
928 * @param array $titles Yoast wpseo_titles option
929 * @return array Post type settings
930 */
931 private function extract_post_type_settings(array $titles): array {
932 $settings = [];
933 $post_types = get_post_types(['public' => true], 'names');
934
935 // Yoast's link suggestions is a single GLOBAL toggle, not per-post-type
936 // like Rank Math's and ThinkRank's. Only a deliberate "off" carries
937 // information (both plugins default it on), so an off is fanned out to
938 // every public type and an on is left alone.
939 $wpseo = get_option('wpseo', []);
940 $link_suggestions_off = is_array($wpseo)
941 && array_key_exists('enable_link_suggestions', $wpseo)
942 && !$wpseo['enable_link_suggestions'];
943
944 foreach ($post_types as $pt) {
945 $pt_settings = [];
946 if ($link_suggestions_off) {
947 $pt_settings['link_suggestions'] = false;
948 }
949 if (isset($titles["title-{$pt}"])) {
950 $pt_settings['title_template'] = $this->convert_template_pattern($titles["title-{$pt}"]);
951 }
952 if (isset($titles["metadesc-{$pt}"])) {
953 $pt_settings['description_template'] = $this->convert_template_pattern($titles["metadesc-{$pt}"]);
954 }
955 if (isset($titles["noindex-{$pt}"])) {
956 $pt_settings['noindex'] = (bool) $titles["noindex-{$pt}"];
957 }
958 if (!empty($pt_settings)) {
959 $settings[$pt] = $pt_settings;
960 }
961 }
962
963 return $settings;
964 }
965
966 /**
967 * Extract taxonomy SEO settings from Yoast titles option
968 *
969 * @param array $titles Yoast wpseo_titles option
970 * @return array Taxonomy settings
971 */
972 private function extract_taxonomy_settings(array $titles): array {
973 $settings = [];
974 $taxonomies = get_taxonomies(['public' => true], 'names');
975
976 foreach ($taxonomies as $tax) {
977 $tax_settings = [];
978 if (isset($titles["title-tax-{$tax}"])) {
979 $tax_settings['title_template'] = $this->convert_template_pattern($titles["title-tax-{$tax}"]);
980 }
981 if (isset($titles["metadesc-tax-{$tax}"])) {
982 $tax_settings['description_template'] = $this->convert_template_pattern($titles["metadesc-tax-{$tax}"]);
983 }
984 if (isset($titles["noindex-tax-{$tax}"])) {
985 $tax_settings['noindex'] = (bool) $titles["noindex-tax-{$tax}"];
986 }
987 if (!empty($tax_settings)) {
988 $settings[$tax] = $tax_settings;
989 }
990 }
991
992 return $settings;
993 }
994 }
995