PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.6.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.6.1
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Core / SiteProfiler.php

SiteProfiler.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.6.1, at includes/Core/SiteProfiler.php

635 lines 19.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\Core;
4
5 /**
6 * SiteProfiler — Phase 1 of the AI "Generate Sample Docs" feature.
7 *
8 * Runs entirely inside WordPress (no AI call). Produces a small, deterministic,
9 * privacy-safe profile of the site that the hosted proxy turns into tailored
10 * docs/FAQs. Detection only targets; the proxy writes.
11 *
12 * The profile contains only public-facing labels (titles, category names) — no
13 * user data, no order data, no PII.
14 *
15 * @since 4.5.3
16 */
17 class SiteProfiler {
18 /**
19 * Transient key for the cached profile (per-locale).
20 * @var string
21 */
22 const CACHE_KEY = 'betterdocs_site_profile';
23
24 /**
25 * How long to cache the computed profile so re-renders don't recompute.
26 * @var int
27 */
28 const CACHE_TTL = HOUR_IN_SECONDS;
29
30 /**
31 * Build the full site profile.
32 *
33 * @param bool $fresh Skip the cache and recompute.
34 * @return array
35 */
36 public function build( $fresh = false ) {
37 $cache_key = self::CACHE_KEY . '_' . get_locale();
38
39 if ( ! $fresh ) {
40 $cached = get_transient( $cache_key );
41 if ( is_array( $cached ) ) {
42 return $cached;
43 }
44 }
45
46 $site = [
47 'title' => sanitize_text_field( get_bloginfo( 'name' ) ),
48 'tagline' => sanitize_text_field( get_bloginfo( 'description' ) ),
49 'url' => esc_url_raw( home_url() ),
50 'locale' => get_locale(),
51 'theme' => sanitize_text_field( wp_get_theme()->get( 'Name' ) ),
52 ];
53
54 $type = $this->detect_type();
55 $topics = $this->topic_hints();
56 $niche = $this->detect_niche( $site, $type, $topics );
57
58 $profile = [
59 'site' => $site,
60 'type' => $type,
61 // Human-friendly niche descriptor for the detection UI and the proxy
62 // prompt (e.g. "AI / LLM platform"), inferred from real site content.
63 'niche' => $niche['key'],
64 'niche_label' => $niche['label'],
65 'signals' => $this->collect_signals(),
66 'woocommerce' => $this->woo_context(),
67 'topics' => $topics,
68 ];
69
70 /**
71 * Filter the computed site profile before it is cached/returned.
72 *
73 * @param array $profile
74 */
75 $profile = apply_filters( 'betterdocs_site_profile', $profile );
76
77 set_transient( $cache_key, $profile, self::CACHE_TTL );
78
79 return $profile;
80 }
81
82 /**
83 * Primary site type, keyed off active plugins (highest signal).
84 * First match wins.
85 *
86 * @return string
87 */
88 public function detect_type() {
89 $active = $this->active_plugins();
90 $has = function ( $needle ) use ( $active ) {
91 foreach ( $active as $plugin ) {
92 if ( stripos( $plugin, $needle ) !== false ) {
93 return true;
94 }
95 }
96 return false;
97 };
98
99 if ( $has( 'woocommerce' ) ) {
100 return 'ecommerce';
101 }
102 if ( $has( 'learndash' ) || $has( 'tutor' ) || $has( 'lifterlms' ) || $has( 'sensei' ) ) {
103 return 'course_lms';
104 }
105 if ( $has( 'easy-digital-downloads' ) ) {
106 return 'digital_downloads';
107 }
108 if ( $has( 'paid-memberships-pro' ) || $has( 'memberpress' ) || $has( 'restrict-content' ) ) {
109 return 'membership';
110 }
111 if ( $has( 'bbpress' ) || $has( 'buddypress' ) ) {
112 return 'community';
113 }
114 if ( $has( 'elementor' ) || $has( 'beaver' ) || $has( 'divi' ) ) {
115 return 'business_site';
116 }
117
118 return 'general';
119 }
120
121 /**
122 * Human-friendly site descriptor for the detection UI ("We detected …") and
123 * to ground the proxy prompt. Prefers a strong content-based niche signal
124 * (title/tagline/pages/categories/nav/existing docs); falls back to the
125 * plugin-detected type, then a generic label. No AI — keyword scoring only.
126 *
127 * @param array $site
128 * @param string $type
129 * @param array $topics
130 * @return array { key:string, label:string }
131 */
132 public function detect_niche( array $site, $type, array $topics ) {
133 $parts = array_merge(
134 [ isset( $site['title'] ) ? $site['title'] : '', isset( $site['tagline'] ) ? $site['tagline'] : '' ],
135 isset( $topics['page_titles'] ) ? (array) $topics['page_titles'] : [],
136 isset( $topics['post_categories'] ) ? (array) $topics['post_categories'] : [],
137 isset( $topics['nav_items'] ) ? (array) $topics['nav_items'] : [],
138 isset( $topics['doc_categories'] ) ? (array) $topics['doc_categories'] : [],
139 isset( $topics['doc_titles'] ) ? (array) $topics['doc_titles'] : []
140 );
141
142 // Pad + collapse whitespace so " ai " etc. match as whole words anywhere.
143 $text = ' ' . preg_replace( '/\s+/', ' ', strtolower( implode( ' ', $parts ) ) ) . ' ';
144
145 // Most-specific first. Leading spaces on short tokens avoid false positives
146 // (e.g. " api" never matches "therapist").
147 $niches = [
148 'ai_llm' => [
149 'label' => 'AI / LLM platform',
150 'keywords' => [ ' llm', ' gpt', ' ai ', 'a.i.', 'artificial intelligence', 'machine learning', 'language model', 'large language', 'prompt', 'openai', 'anthropic', 'claude', 'chatbot', 'embedding', 'fine-tun', 'inference', 'generative', 'neural' ],
151 ],
152 'developer' => [
153 'label' => 'developer platform',
154 'keywords' => [ ' api', ' sdk', 'developer', 'endpoint', 'webhook', ' cli ', 'integration', 'documentation', 'rate limit', 'oauth', 'deploy' ],
155 ],
156 'ecommerce' => [
157 'label' => 'online store',
158 'keywords' => [ ' shop', ' store', ' cart', 'checkout', 'product', 'shipping', 'returns', ' order', 'fashion', 'clothing', 'apparel' ],
159 ],
160 'education' => [
161 'label' => 'online learning site',
162 'keywords' => [ ' course', 'lesson', ' learn', 'student', 'curriculum', 'enroll', 'academy', 'tutorial', ' class', 'teacher' ],
163 ],
164 'agency' => [
165 'label' => 'agency or services site',
166 'keywords' => [ 'agency', 'clients', 'marketing', ' seo', 'branding', 'portfolio', 'services' ],
167 ],
168 'health' => [
169 'label' => 'health & wellness site',
170 'keywords' => [ 'health', 'clinic', 'medical', 'wellness', 'therapy', 'patient', 'fitness', 'nutrition' ],
171 ],
172 'finance' => [
173 'label' => 'finance site',
174 'keywords' => [ 'finance', ' bank', 'invest', 'crypto', 'trading', ' loan', 'insurance' ],
175 ],
176 'food' => [
177 'label' => 'food & restaurant site',
178 'keywords' => [ 'restaurant', ' menu', 'recipe', ' food', ' cafe', 'coffee', 'cuisine', ' dish' ],
179 ],
180 ];
181
182 $scores = [];
183 foreach ( $niches as $key => $def ) {
184 $score = 0;
185 foreach ( $def['keywords'] as $kw ) {
186 if ( strpos( $text, $kw ) !== false ) {
187 $score++;
188 }
189 }
190 $scores[ $key ] = $score;
191 }
192
193 // AI/LLM is the most notable/specific niche — let it win whenever there are
194 // a couple of independent AI signals, even if "developer" also scores high.
195 if ( $scores['ai_llm'] >= 2 ) {
196 return [ 'key' => 'ai_llm', 'label' => $niches['ai_llm']['label'] ];
197 }
198
199 arsort( $scores );
200 $best = key( $scores );
201 $top = current( $scores );
202 if ( $best && $top >= 2 ) {
203 return [ 'key' => $best, 'label' => $niches[ $best ]['label'] ];
204 }
205
206 // Fall back to the plugin-detected type, then a generic label.
207 $type_labels = [
208 'ecommerce' => 'online store',
209 'course_lms' => 'course / LMS site',
210 'digital_downloads' => 'digital downloads store',
211 'membership' => 'membership site',
212 'community' => 'community site',
213 'business_site' => 'business site',
214 ];
215 if ( isset( $type_labels[ $type ] ) ) {
216 return [ 'key' => $type, 'label' => $type_labels[ $type ] ];
217 }
218
219 return [ 'key' => 'general', 'label' => 'website' ];
220 }
221
222 /**
223 * Raw signals — give the AI evidence, not just a label.
224 *
225 * @return array
226 */
227 public function collect_signals() {
228 $active = $this->active_plugins();
229 $known = [
230 'woocommerce' => 'WooCommerce',
231 'learndash' => 'LearnDash',
232 'tutor' => 'Tutor LMS',
233 'lifterlms' => 'LifterLMS',
234 'easy-digital' => 'Easy Digital Downloads',
235 'memberpress' => 'MemberPress',
236 'bbpress' => 'bbPress',
237 'buddypress' => 'BuddyPress',
238 'elementor' => 'Elementor',
239 'wpforms' => 'WPForms',
240 'yoast' => 'Yoast SEO',
241 ];
242
243 $detected = [];
244 foreach ( $known as $needle => $label ) {
245 foreach ( $active as $plugin ) {
246 if ( stripos( $plugin, $needle ) !== false ) {
247 $detected[] = $label;
248 break;
249 }
250 }
251 }
252
253 return [
254 'active_plugins' => array_values( array_unique( $detected ) ),
255 'theme' => sanitize_text_field( wp_get_theme()->get( 'Name' ) ),
256 ];
257 }
258
259 /**
260 * WooCommerce context — only if Woo is active.
261 *
262 * @return array|null
263 */
264 public function woo_context() {
265 if ( ! class_exists( 'WooCommerce' ) ) {
266 return null;
267 }
268
269 $cats = get_terms(
270 [
271 'taxonomy' => 'product_cat',
272 'orderby' => 'count',
273 'order' => 'DESC',
274 'number' => 8,
275 'hide_empty' => true,
276 ]
277 );
278 $cat_names = is_wp_error( $cats ) ? [] : array_map( 'sanitize_text_field', wp_list_pluck( $cats, 'name' ) );
279
280 $products = get_posts(
281 [
282 'post_type' => 'product',
283 'posts_per_page' => 8,
284 'orderby' => 'date',
285 'order' => 'DESC',
286 'fields' => 'ids',
287 ]
288 );
289 $product_names = array_map(
290 function ( $id ) {
291 return sanitize_text_field( get_the_title( $id ) );
292 },
293 $products
294 );
295
296 $context = [
297 'product_categories' => array_values( $cat_names ),
298 'sample_products' => array_values( array_filter( $product_names ) ),
299 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '',
300 'currency_symbol' => function_exists( 'get_woocommerce_currency_symbol' ) ? html_entity_decode( get_woocommerce_currency_symbol() ) : '',
301 ];
302
303 // Store config used to ground store/product FAQs in real settings. Each
304 // value is read defensively — anything missing simply drops out so the
305 // FAQ copy can fall back to a generic-but-accurate answer. No PII.
306 $context['store_location'] = $this->store_location();
307 $context['payment_methods'] = $this->payment_methods();
308 $context['shipping_regions'] = $this->shipping_regions();
309 $context['tax_enabled'] = function_exists( 'wc_tax_enabled' ) ? (bool) wc_tax_enabled() : false;
310 $context['prices_include_tax'] = 'yes' === get_option( 'woocommerce_prices_include_tax' );
311
312 $terms_page = (int) get_option( 'woocommerce_terms_and_conditions_page_id', 0 );
313 if ( $terms_page > 0 ) {
314 $context['terms_page_url'] = esc_url_raw( (string) get_permalink( $terms_page ) );
315 }
316
317 $privacy_page = (int) get_option( 'wp_page_for_privacy_policy', 0 );
318 if ( $privacy_page > 0 ) {
319 $context['privacy_page_url'] = esc_url_raw( (string) get_permalink( $privacy_page ) );
320 }
321
322 // Return window only exists when a returns extension stores it; omit otherwise.
323 $return_window = get_option( 'woocommerce_return_requests_window', '' );
324 if ( '' !== $return_window && null !== $return_window ) {
325 $context['return_window'] = (int) $return_window;
326 }
327
328 // Key WooCommerce pages — so store FAQ answers can link the real account /
329 // checkout flow instead of describing it generically.
330 $this->add_wc_page( $context, 'account_page_url', 'myaccount' );
331 $this->add_wc_page( $context, 'checkout_page_url', 'checkout' );
332 $this->add_wc_page( $context, 'shop_page_url', 'shop' );
333
334 // Refund/returns policy page (WooCommerce has no canonical option — resolved
335 // heuristically), used to link the real policy in returns/refunds answers.
336 $refund = $this->refund_policy();
337 if ( ! empty( $refund['url'] ) ) {
338 $context['refund_policy_url'] = $refund['url'];
339 if ( ! empty( $refund['excerpt'] ) ) {
340 $context['refund_policy_excerpt'] = $refund['excerpt'];
341 }
342 }
343
344 // Free-shipping threshold, when a Free Shipping method defines a minimum.
345 $free_min = $this->free_shipping_min();
346 if ( null !== $free_min ) {
347 $context['free_shipping_min'] = $free_min;
348 }
349
350 return array_filter(
351 $context,
352 function ( $value ) {
353 return ! ( is_array( $value ) && empty( $value ) ) && '' !== $value;
354 }
355 );
356 }
357
358 /**
359 * Store base location label (city/region, country) for shipping/payment FAQs.
360 *
361 * @return string
362 */
363 private function store_location() {
364 if ( ! function_exists( 'wc_get_base_location' ) ) {
365 return '';
366 }
367
368 $base = wc_get_base_location();
369 $country = isset( $base['country'] ) ? (string) $base['country'] : '';
370 $city = (string) get_option( 'woocommerce_store_city', '' );
371
372 if ( function_exists( 'WC' ) && WC()->countries && $country ) {
373 $countries = WC()->countries->get_countries();
374 $country = isset( $countries[ $country ] ) ? $countries[ $country ] : $country;
375 }
376
377 $parts = array_filter( [ sanitize_text_field( $city ), sanitize_text_field( $country ) ] );
378
379 return implode( ', ', $parts );
380 }
381
382 /**
383 * Titles of the enabled payment gateways (e.g. "PayPal", "Credit/Debit Card").
384 *
385 * @return array
386 */
387 private function payment_methods() {
388 if ( ! function_exists( 'WC' ) || ! WC()->payment_gateways ) {
389 return [];
390 }
391
392 $gateways = WC()->payment_gateways->get_available_payment_gateways();
393 $titles = [];
394 foreach ( (array) $gateways as $gateway ) {
395 $title = isset( $gateway->title ) ? wp_strip_all_tags( (string) $gateway->title ) : '';
396 if ( '' !== trim( $title ) ) {
397 $titles[] = sanitize_text_field( $title );
398 }
399 }
400
401 return array_values( array_unique( $titles ) );
402 }
403
404 /**
405 * Shipping zone names (region labels the store ships to).
406 *
407 * @return array
408 */
409 private function shipping_regions() {
410 if ( ! class_exists( '\WC_Shipping_Zones' ) ) {
411 return [];
412 }
413
414 $zones = \WC_Shipping_Zones::get_zones();
415 $labels = [];
416 foreach ( (array) $zones as $zone ) {
417 $name = isset( $zone['zone_name'] ) ? sanitize_text_field( $zone['zone_name'] ) : '';
418 if ( '' !== trim( $name ) ) {
419 $labels[] = $name;
420 }
421 }
422
423 return array_values( array_unique( $labels ) );
424 }
425
426 /**
427 * Add a WooCommerce page's permalink to the context when the page exists.
428 *
429 * @param array $context Context array (by reference).
430 * @param string $key Context key to set.
431 * @param string $wc_page WooCommerce page id key (myaccount|checkout|shop|cart).
432 * @return void
433 */
434 private function add_wc_page( array &$context, $key, $wc_page ) {
435 if ( ! function_exists( 'wc_get_page_id' ) ) {
436 return;
437 }
438 $page_id = (int) wc_get_page_id( $wc_page );
439 if ( $page_id > 0 ) {
440 $url = get_permalink( $page_id );
441 if ( $url ) {
442 $context[ $key ] = esc_url_raw( (string) $url );
443 }
444 }
445 }
446
447 /**
448 * Resolve the store's Refund/Returns policy page. WooCommerce has no canonical
449 * option for it, so match common slugs; returns the URL + a short excerpt, or an
450 * empty array when none is found (callers fall back to the terms page).
451 *
452 * @return array { url?: string, excerpt?: string }
453 */
454 private function refund_policy() {
455 $slugs = [ 'refund_returns', 'refund-and-returns-policy', 'refund-policy', 'returns', 'return-policy', 'returns-policy' ];
456 $page = null;
457 foreach ( $slugs as $slug ) {
458 $found = get_page_by_path( $slug );
459 if ( $found instanceof \WP_Post && 'publish' === $found->post_status ) {
460 $page = $found;
461 break;
462 }
463 }
464
465 if ( ! $page instanceof \WP_Post ) {
466 return [];
467 }
468
469 return [
470 'url' => esc_url_raw( (string) get_permalink( $page->ID ) ),
471 'excerpt' => sanitize_text_field( wp_trim_words( wp_strip_all_tags( (string) $page->post_content ), 40, '' ) ),
472 ];
473 }
474
475 /**
476 * The lowest Free Shipping minimum-order amount configured across shipping zones,
477 * or null when no Free Shipping method defines a positive threshold.
478 *
479 * @return float|null
480 */
481 private function free_shipping_min() {
482 if ( ! class_exists( '\WC_Shipping_Zones' ) ) {
483 return null;
484 }
485
486 // All real zones plus the catch-all "Rest of the World" zone (id 0).
487 $zone_ids = [ 0 ];
488 foreach ( (array) \WC_Shipping_Zones::get_zones() as $zone ) {
489 if ( isset( $zone['id'] ) ) {
490 $zone_ids[] = (int) $zone['id'];
491 }
492 }
493
494 $min = null;
495 foreach ( array_unique( $zone_ids ) as $zone_id ) {
496 $zone = \WC_Shipping_Zones::get_zone( $zone_id );
497 if ( ! $zone ) {
498 continue;
499 }
500 foreach ( (array) $zone->get_shipping_methods( true ) as $method ) {
501 if ( ! isset( $method->id ) || 'free_shipping' !== $method->id ) {
502 continue;
503 }
504 $amount = method_exists( $method, 'get_option' ) ? $method->get_option( 'min_amount' ) : ( isset( $method->min_amount ) ? $method->min_amount : '' );
505 $amount = is_numeric( $amount ) ? (float) $amount : 0;
506 if ( $amount > 0 && ( null === $min || $amount < $min ) ) {
507 $min = $amount;
508 }
509 }
510 }
511
512 return $min;
513 }
514
515 /**
516 * Topic hints — primary nav menu + key pages + top post categories.
517 *
518 * @return array
519 */
520 public function topic_hints() {
521 // Primary nav menu item labels.
522 $nav = [];
523 $locations = get_nav_menu_locations();
524 if ( ! empty( $locations ) ) {
525 $menu_id = reset( $locations );
526 $items = wp_get_nav_menu_items( $menu_id );
527 if ( $items ) {
528 $nav = array_slice(
529 array_map(
530 function ( $item ) {
531 return sanitize_text_field( $item->title );
532 },
533 $items
534 ),
535 0,
536 12
537 );
538 }
539 }
540
541 // Key page titles (About, Pricing, Services, Contact, FAQ…).
542 $pages = get_posts(
543 [
544 'post_type' => 'page',
545 'posts_per_page' => 10,
546 'orderby' => 'menu_order',
547 'order' => 'ASC',
548 'fields' => 'ids',
549 ]
550 );
551 $page_titles = array_map(
552 function ( $id ) {
553 return sanitize_text_field( get_the_title( $id ) );
554 },
555 $pages
556 );
557
558 // Top post categories for blog topic hints.
559 $post_cats = get_terms(
560 [
561 'taxonomy' => 'category',
562 'orderby' => 'count',
563 'order' => 'DESC',
564 'number' => 6,
565 'hide_empty' => true,
566 ]
567 );
568 $post_cat_names = is_wp_error( $post_cats ) ? [] : array_map( 'sanitize_text_field', wp_list_pluck( $post_cats, 'name' ) );
569
570 // Existing documentation — lets the proxy build content that extends what
571 // the site already covers. In particular, FAQ generation derives questions
572 // from these doc topics ("detect FAQs from the docs too").
573 $docs = get_posts(
574 [
575 'post_type' => 'docs',
576 'posts_per_page' => 15,
577 'orderby' => 'date',
578 'order' => 'DESC',
579 'post_status' => 'publish',
580 'fields' => 'ids',
581 ]
582 );
583 $doc_titles = array_map(
584 function ( $id ) {
585 return sanitize_text_field( get_the_title( $id ) );
586 },
587 $docs
588 );
589
590 $doc_cats = get_terms(
591 [
592 'taxonomy' => 'doc_category',
593 'orderby' => 'count',
594 'order' => 'DESC',
595 'number' => 6,
596 'hide_empty' => true,
597 ]
598 );
599 $doc_cat_names = is_wp_error( $doc_cats ) ? [] : array_map( 'sanitize_text_field', wp_list_pluck( $doc_cats, 'name' ) );
600
601 return [
602 'nav_items' => array_values( array_filter( $nav ) ),
603 'page_titles' => array_values( array_filter( $page_titles ) ),
604 'post_categories' => array_values( $post_cat_names ),
605 'doc_titles' => array_values( array_filter( $doc_titles ) ),
606 'doc_categories' => array_values( $doc_cat_names ),
607 ];
608 }
609
610 /**
611 * Clear the cached profile (call after big content/plugin changes).
612 *
613 * @return void
614 */
615 public function flush() {
616 delete_transient( self::CACHE_KEY . '_' . get_locale() );
617 }
618
619 /**
620 * Active plugins on this site (network-active included).
621 *
622 * @return array
623 */
624 private function active_plugins() {
625 $active = (array) get_option( 'active_plugins', [] );
626
627 if ( is_multisite() ) {
628 $network = (array) get_site_option( 'active_sitewide_plugins', [] );
629 $active = array_merge( $active, array_keys( $network ) );
630 }
631
632 return $active;
633 }
634 }
635