Global-2026-07-14

This commit is contained in:
Dekart Deploy Bot 2026-07-14 12:46:14 +00:00
parent d8f11ba4d1
commit caecf7da52
6 changed files with 379 additions and 425 deletions

3
.gitignore vendored
View File

@ -126,9 +126,10 @@ wp-content/upgrade-temp-backup/
# === Agent/Test files — dev-мусор (не в git) === # === Agent/Test files — dev-мусор (не в git) ===
data/ data/
llms.txt
test.php test.php
.agents/ .agents/
.claude/
skills-lock.json
# === Audit files — dev-мусор (не в git) === # === Audit files — dev-мусор (не в git) ===
*.audit.txt *.audit.txt

251
AGENTS.md
View File

@ -1,251 +0,0 @@
# Site1 — WordPress Project
## Agent
Primary agent:
- @site1
This agent manages the WordPress project architecture, development workflow, frontend changes, SEO safety, and deployment preparation.
---
# Project Context
Project type:
- Custom WordPress website.
Architecture:
- WordPress core.
- Custom theme:
`wp-content/themes/dekart/`
Theme contains:
- Templates
- Custom frontend styles
- Carbon Fields integration
- Schema.org JSON-LD logic
- Custom business data logic
Repository:
- Git is the source of truth.
- Theme changes are deployed through Git.
- Production and development environments are separated.
Important:
Before any changes, inspect existing architecture.
Do not assume standard WordPress structure.
---
# Critical Rules
## Forbidden without explicit confirmation
NEVER modify:
- wp-config.php
- database structure
- production environment files
- uploads directory
- user data
- existing production configuration
Before executing:
- rm
- delete
- overwrite
- database write
- git push
Ask for confirmation.
---
# WordPress Architecture Rules
## Theme
Location:
wp-content/themes/dekart/
Rules:
Preserve:
- existing PHP templates
- template hierarchy
- hooks
- filters
- Carbon Fields compatibility
- schema.org output
- SEO logic
Do not:
- rebuild templates
- replace theme architecture
- move functionality into plugins without approval
- introduce page builders
---
# Carbon Fields
Carbon Fields is intentionally located inside the theme.
Rules:
Do not:
- move Carbon Fields into a plugin
- rename fields
- change option keys
- modify stored data structure
Any changes must preserve backward compatibility.
---
# Frontend / Redesign Rules
Goal:
Improve visual design without changing markup.
Allowed:
- CSS
- SCSS
- CSS variables
- typography
- colors
- spacing
- borders
- shadows
- animations
- responsive improvements
Forbidden:
- changing HTML structure
- changing block layout
- changing forms
- replacing components
- adding Elementor
- adding visual builders
Priority:
1. Preserve functionality
2. Preserve SEO
3. Preserve accessibility
4. Improve UI consistency
---
# Modern Web Development
Use Google Chrome Modern Web Guidance principles.
Priorities:
- Accessibility
- Performance
- Responsive design
- Maintainable CSS
- Browser compatibility
Prefer:
- CSS variables
- reusable design tokens
- semantic CSS naming
- modern CSS compatible with Baseline
Avoid:
- unnecessary JavaScript
- duplicated CSS
- layout shifts
- heavy dependencies
Consider:
- Core Web Vitals
- CLS
- LCP
- accessibility contrast
- responsive behavior
---
# Frontend Workflow
Before changing styles:
1. Audit existing CSS.
2. Identify:
- colors
- typography
- spacing
- components
- duplicated rules
- inconsistent UI patterns
Create design tokens when possible:
Example:
```css
:root {
--color-primary:
--color-secondary:
--color-text:
--color-background:
--border-radius:
--shadow:
}
Do not make blind global replacements.
SEO Safety
Never break:
title/meta logic
schema.org JSON-LD
breadcrumbs
canonical URLs
indexing behavior
structured data
Frontend changes must not affect SEO output.
Performance
Keep:
Core Web Vitals
minimal CSS overhead
optimized assets
no unnecessary scripts
Avoid:
additional libraries without approval
duplicate dependencies
blocking resources
Redis Object Cache
Current architecture:
Redis Object Cache plugin
mu-plugin bootstrap
WordPress object cache API
Rules:
Do not create another caching layer.
Do not create custom Redis clients.
Do not create custom object-cache.php.
Use:
wp_cache_get()
wp_cache_set()
Runtime files:
Never commit:
wp-content/object-cache.php
Development Standards
PHP
Use:
PSR-12
type declarations where appropriate
WordPress coding standards
JavaScript
Use:
TypeScript strict
ESM
async/await
Avoid unnecessary frameworks.
Git Rules
Commit format:
feat:
fix:
refactor:
style:
chore:
docs:
Before commit:
Run:
git status
git diff
Never commit:
.env
uploads/
logs/
wp-content/object-cache.php
cache files
temporary files
Testing
PHP:
vendor/bin/phpunit
vendor/bin/phpstan analyse
JavaScript:
npm run build
npm test
npm run lint
Run only relevant checks for changed area.
Deployment Rules
Before deployment verify:
changed files
dependencies
environment requirements
database impact
cache impact
Never assume production environment matches development.
Required Work Process
For complex tasks:
Analyze current implementation.
Explain planned changes.
Wait for confirmation if architecture changes are required.
Implement minimal changes.
Verify result.
Provide report.
Final Response Format
After completing tasks always provide:
Summary
What was changed.
Files Changed
List files.
Technical Details
Explain implementation.
Validation
Tests and checks performed.
Risks
Possible issues.
Deployment Notes
Required production actions.

View File

@ -32,7 +32,7 @@
<img width="19" height="32" <img width="19" height="32"
src="<?php echo esc_url( get_template_directory_uri() ); ?>/assets/images/icon-location.svg" src="<?php echo esc_url( get_template_directory_uri() ); ?>/assets/images/icon-location.svg"
alt="" role="presentation" loading="lazy"> alt="" role="presentation" loading="lazy">
<span>г. <?php echo esc_html( carbon_get_theme_option('site_city') ); ?> <br class="mobile"> <?php echo esc_html( carbon_get_theme_option('site_address') ); ?></span> <span>г. <?php echo esc_html( get_option('branch_city') ?: carbon_get_theme_option('site_city') ); ?> <br class="mobile"> <?php echo esc_html( get_option('branch_address') ?: carbon_get_theme_option('site_address') ); ?></span>
</div> </div>
<a class="footer__link-icon" <a class="footer__link-icon"
href="tel:<?php echo preg_replace('/[^0-9+]/', '', carbon_get_theme_option('site_phone')); ?>"> href="tel:<?php echo preg_replace('/[^0-9+]/', '', carbon_get_theme_option('site_phone')); ?>">

View File

@ -1915,7 +1915,10 @@ function dekart_branch_register_settings() {
register_setting( 'dekart_branch_group', 'branch_remaining_seats', array( 'sanitize_callback' => 'absint' ) ); register_setting( 'dekart_branch_group', 'branch_remaining_seats', array( 'sanitize_callback' => 'absint' ) );
register_setting( 'dekart_branch_group', 'branch_schema_description', array( 'sanitize_callback' => 'sanitize_textarea_field' ) ); register_setting( 'dekart_branch_group', 'branch_schema_description', array( 'sanitize_callback' => 'sanitize_textarea_field' ) );
register_setting( 'dekart_branch_group', 'branch_area_served', array( 'sanitize_callback' => 'sanitize_text_field' ) ); register_setting( 'dekart_branch_group', 'branch_area_served', array( 'sanitize_callback' => 'sanitize_text_field' ) );
register_setting( 'dekart_branch_group', 'branch_logistics_walk', array( 'sanitize_callback' => 'sanitize_textarea_field' ) ); register_setting( 'dekart_branch_group', 'branch_google_place_id', array( 'sanitize_callback' => 'sanitize_text_field' ) );
register_setting( 'dekart_branch_group', 'branch_google_maps_url', array( 'sanitize_callback' => 'esc_url_raw' ) );
register_setting( 'dekart_branch_group', 'branch_google_business_url', array( 'sanitize_callback' => 'esc_url_raw' ) );
register_setting( 'dekart_branch_group', 'branch_logistics_walk', array( 'sanitize_callback' => 'sanitize_textarea_field' ) );
register_setting( 'dekart_branch_group', 'branch_logistics_parking', array( 'sanitize_callback' => 'sanitize_textarea_field' ) ); register_setting( 'dekart_branch_group', 'branch_logistics_parking', array( 'sanitize_callback' => 'sanitize_textarea_field' ) );
register_setting( 'dekart_branch_group', 'branch_logistics_nearby', array( 'sanitize_callback' => 'sanitize_textarea_field' ) ); register_setting( 'dekart_branch_group', 'branch_logistics_nearby', array( 'sanitize_callback' => 'sanitize_textarea_field' ) );
// Поля branch_map_url, branch_founded, branch_photos — удалены. // Поля branch_map_url, branch_founded, branch_photos — удалены.
@ -1976,6 +1979,21 @@ function dekart_branch_settings_page() {
<label>Почтовый индекс</label> <label>Почтовый индекс</label>
<input type="text" name="branch_postal_code" value="<?php echo esc_attr( get_option( 'branch_postal_code', '' ) ); ?>"> <input type="text" name="branch_postal_code" value="<?php echo esc_attr( get_option( 'branch_postal_code', '' ) ); ?>">
</div> </div>
<div class="branch-field">
<label>Города обслуживания (areaServed)</label>
<input type="text" name="branch_area_served" value="<?php echo esc_attr( get_option( 'branch_area_served', 'Щёлково, Фрязино, Монино, Ивантеевка, Королёв' ) ); ?>">
<p class="desc">Через запятую. Для Schema.org LocalBusiness.areaServed</p>
</div>
<div class="branch-field">
<label>Google Place ID</label>
<input type="text" name="branch_google_place_id" value="<?php echo esc_attr( get_option( 'branch_google_place_id', '' ) ); ?>">
<p class="desc">Из Google Business Profile. Для Schema.org identifier + @id</p>
</div>
<div class="branch-field">
<label>Google Maps URL</label>
<input type="url" name="branch_google_maps_url" value="<?php echo esc_attr( get_option( 'branch_google_maps_url', '' ) ); ?>">
<p class="desc">Полная ссылка на профиль в Google Maps. Для Schema.org hasMap</p>
</div>
<div class="branch-field"> <div class="branch-field">
<label>Пешком от ориентира</label> <label>Пешком от ориентира</label>
<input type="text" name="branch_logistics_walk" value="<?php echo esc_attr( get_option( 'branch_logistics_walk', '5 минут пешком от ТЦ «Глобус»' ) ); ?>"> <input type="text" name="branch_logistics_walk" value="<?php echo esc_attr( get_option( 'branch_logistics_walk', '5 минут пешком от ТЦ «Глобус»' ) ); ?>">
@ -2169,6 +2187,11 @@ function dekart_branch_settings_page() {
<h2>🔗 E-E-A-T: Внешние платформы</h2> <h2>🔗 E-E-A-T: Внешние платформы</h2>
<div class="branch-section-body"> <div class="branch-section-body">
<p class="desc">Ссылки на внешние площадки с отзывами для повышения E-E-A-T. Отображаются под заголовком «Отзывы на внешних площадках».</p> <p class="desc">Ссылки на внешние площадки с отзывами для повышения E-E-A-T. Отображаются под заголовком «Отзывы на внешних площадках».</p>
<div class="branch-field">
<label>Google Business Profile (ссылка на профиль)</label>
<input type="url" name="branch_google_business_url" value="<?php echo esc_attr( get_option( 'branch_google_business_url', '' ) ); ?>" style="width:100%;max-width:500px">
<p class="desc">Полная ссылка на профиль в Google Business Profile. Добавляется в sameAs[] Schema.org и выводится как кнопка на странице. <br><strong>Где взять:</strong> откройте Google Карты найдите свою организацию нажмите «Поделиться» скопируйте ссылку. Пример: https://maps.google.com/?cid=1234567890 или https://g.page/r/your-business-slug</p>
</div>
<div class="branch-field"> <div class="branch-field">
<label>Яндекс Карты (ссылка на отзывы)</label> <label>Яндекс Карты (ссылка на отзывы)</label>
<input type="url" name="branch_ext_yandex" value="<?php echo esc_attr( get_option( 'branch_ext_yandex', '' ) ); ?>" style="width:100%;max-width:500px"> <input type="url" name="branch_ext_yandex" value="<?php echo esc_attr( get_option( 'branch_ext_yandex', '' ) ); ?>" style="width:100%;max-width:500px">

View File

@ -140,7 +140,7 @@ if ( is_front_page() ) {
<img width="15" height="25" <img width="15" height="25"
src="<?php echo esc_url( get_template_directory_uri() ); ?>/assets/images/icon-location.svg" src="<?php echo esc_url( get_template_directory_uri() ); ?>/assets/images/icon-location.svg"
alt="Адрес" loading="lazy"> alt="Адрес" loading="lazy">
<span><?php echo esc_html( carbon_get_theme_option('site_address') ); ?></span> <span><?php echo esc_html( get_option('branch_address') ?: carbon_get_theme_option('site_address') ); ?></span>
</div> </div>
<a target="_blank" rel="noopener noreferrer" <a target="_blank" rel="noopener noreferrer"
href="<?php echo esc_url( carbon_get_theme_option('social_header_wt') ); ?>" href="<?php echo esc_url( carbon_get_theme_option('social_header_wt') ); ?>"

View File

@ -20,24 +20,24 @@ defined( 'ABSPATH' ) || exit;
add_filter( 'wpseo_json_ld_output', '__return_false' ); add_filter( 'wpseo_json_ld_output', '__return_false' );
/** /**
* Принудительно меняем og:type на 'website' для страницы Подготовки к школе (ID=2), * Принудительно меняем og:type на 'website' для страницы Подготовки к школе,
* чтобы избежать несоответствия article/website в социальных сниппетах. * чтобы избежать несоответствия article/website в социальных сниппетах.
*/ */
add_filter( 'wpseo_opengraph_type', 'dekart_schema_og_type', 10, 1 ); add_filter( 'wpseo_opengraph_type', 'dekart_schema_og_type', 10, 1 );
function dekart_schema_og_type( string $type ): string { function dekart_schema_og_type( string $type ): string {
if ( is_page( 2 ) || is_page_template( 'page-camp.php' ) ) { if ( is_page_template( 'page-podgotovka-k-shkole.php' ) || is_page_template( 'page-camp.php' ) ) {
return 'website'; return 'website';
} }
return $type; return $type;
} }
/** /**
* Убираем article:* OG-теги для страницы Подготовки к школе (ID=2), * Убираем article:* OG-теги для страницы Подготовки к школе,
* чтобы не было конфликта og:type=website с article:modified_time. * чтобы не было конфликта og:type=website с article:modified_time.
* Аналог page-camp.php:15-27. * Аналог page-camp.php:15-27.
*/ */
add_filter( 'wpseo_frontend_presenter_classes', function( array $presenters ): array { add_filter( 'wpseo_frontend_presenter_classes', function( array $presenters ): array {
if ( ! is_page( 2 ) ) { if ( ! is_page_template( 'page-podgotovka-k-shkole.php' ) ) {
return $presenters; return $presenters;
} }
$presenters = array_filter( $presenters, function( string $p ): bool { $presenters = array_filter( $presenters, function( string $p ): bool {
@ -53,12 +53,21 @@ add_filter( 'wpseo_frontend_presenter_classes', function( array $presenters ): a
/** /**
* Главная функция: сборка @graph и вывод в wp_head. * Главная функция: сборка @graph и вывод в wp_head.
* Срабатывает только на странице ID=2 (Подготовка к школе). * Срабатывает только на странице с шаблоном page-podgotovka-k-shkole.php.
*/ */
add_action( 'wp_head', 'dekart_schema_output', 1 ); add_action( 'wp_head', 'dekart_schema_output', 1 );
/**
* Возвращает URL текущей страницы для Schema.org @id.
* Динамически через get_permalink() не зависит от хардкода ID.
*/
function dekart_schema_page_url(): string {
$id = get_the_ID();
return $id ? get_permalink( $id ) : home_url( '/podgotovka-k-shkole/' );
}
function dekart_schema_output(): void { function dekart_schema_output(): void {
if ( ! is_page( 2 ) ) { if ( ! is_page_template( 'page-podgotovka-k-shkole.php' ) ) {
return; return;
} }
@ -146,12 +155,24 @@ function dekart_schema_output(): void {
$graph[] = $faq; $graph[] = $faq;
} }
// 11b. QAPage — ключевые вопросы для AI citation readiness
$qa = dekart_schema_qapage();
if ( $qa ) {
$graph[] = $qa;
}
// 11c. Event — бесплатное пробное занятие
$event = dekart_schema_trial_event();
if ( $event ) {
$graph[] = $event;
}
// 12. ItemList — обёртка для отзывов (conditional) // 12. ItemList — обёртка для отзывов (conditional)
$reviews = dekart_schema_reviews(); $reviews = dekart_schema_reviews();
if ( ! empty( $reviews ) && is_array( $reviews ) ) { if ( ! empty( $reviews ) && is_array( $reviews ) ) {
$graph[] = array( $graph[] = array(
'@type' => 'ItemList', '@type' => 'ItemList',
'@id' => home_url( '/podgotovka-k-shkole/' ) . '#reviews', '@id' => dekart_schema_page_url() . '#reviews',
'name' => 'Отзывы о подготовке к школе', 'name' => 'Отзывы о подготовке к школе',
'description' => 'Отзывы родителей о курсе подготовки к школе в Детском центре «Декарт»', 'description' => 'Отзывы родителей о курсе подготовки к школе в Детском центре «Декарт»',
'itemListElement' => array_map( function( $review, $index ) { 'itemListElement' => array_map( function( $review, $index ) {
@ -206,26 +227,27 @@ function dekart_schema_output(): void {
* @see https://schema.org/WebPage * @see https://schema.org/WebPage
*/ */
function dekart_schema_webpage(): ?array { function dekart_schema_webpage(): ?array {
$url = home_url( '/podgotovka-k-shkole/' ); $post_id = get_the_ID();
$url = get_permalink( $post_id ) ?: dekart_schema_page_url();
// _yoast_wpseo_title — Yoast SEO заголовок // _yoast_wpseo_title — Yoast SEO заголовок
$yoast_title = get_post_meta( 2, '_yoast_wpseo_title', true ); $yoast_title = get_post_meta( $post_id, '_yoast_wpseo_title', true );
$name = $yoast_title ?: wp_get_document_title(); $name = $yoast_title ?: wp_get_document_title();
// _yoast_wpseo_metadesc — Yoast SEO описание // _yoast_wpseo_metadesc — Yoast SEO описание
$description = get_post_meta( 2, '_yoast_wpseo_metadesc', true ); $description = get_post_meta( $post_id, '_yoast_wpseo_metadesc', true );
$description = $description ?: get_post_meta( 2, 'rank_math_description', true ); $description = $description ?: get_post_meta( $post_id, 'rank_math_description', true );
$description = $description ?: carbon_get_post_meta( 2, 'banner_subtitle' ); $description = $description ?: carbon_get_post_meta( $post_id, 'banner_subtitle' );
$description = $description ?: 'Подготовка к школе для детей 57 лет в Щёлково. Чтение, письмо, математика, логика.'; $description = $description ?: 'Подготовка к школе для детей 57 лет в Щёлково. Чтение, письмо, математика, логика.';
// has_post_thumbnail — изображение страницы (OG-совместимость) // has_post_thumbnail — изображение страницы (OG-совместимость)
$image_url = ''; $image_url = '';
if ( has_post_thumbnail( 2 ) ) { if ( has_post_thumbnail( get_the_ID() ) ) {
$image_url = get_the_post_thumbnail_url( 2, 'full' ); $image_url = get_the_post_thumbnail_url( 2, 'full' );
} }
// pksh_level — уровень образования (about) // pksh_level — уровень образования (about)
$about_text = carbon_get_post_meta( 2, 'pksh_level' ) ?: 'Подготовка к школе'; $about_text = carbon_get_post_meta( get_the_ID(), 'pksh_level' ) ?: 'Подготовка к школе';
// branch_city — город для about и keywords // branch_city — город для about и keywords
$city = dekart_branch_option( 'branch_city', 'Щёлково' ); $city = dekart_branch_option( 'branch_city', 'Щёлково' );
@ -280,7 +302,7 @@ function dekart_schema_webpage(): ?array {
} }
// hasPart — ссылка на FAQPage этой страницы // hasPart — ссылка на FAQPage этой страницы
$faq_items = carbon_get_post_meta( 2, 'pksh_faq_items' ); $faq_items = carbon_get_post_meta( get_the_ID(), 'pksh_faq_items' );
if ( ! empty( $faq_items ) && is_array( $faq_items ) ) { if ( ! empty( $faq_items ) && is_array( $faq_items ) ) {
$webpage['hasPart'] = array( $webpage['hasPart'] = array(
'@id' => $url . '#faq', '@id' => $url . '#faq',
@ -318,7 +340,7 @@ function dekart_schema_website(): ?array {
* BreadcrumbList хлебные крошки для страницы. * BreadcrumbList хлебные крошки для страницы.
*/ */
function dekart_schema_breadcrumb(): ?array { function dekart_schema_breadcrumb(): ?array {
$page_url = home_url( '/podgotovka-k-shkole/' ); $page_url = dekart_schema_page_url();
$home_url = home_url( '/' ); $home_url = home_url( '/' );
return array( return array(
@ -430,7 +452,7 @@ function dekart_resolve_logo(): string {
} }
// 2. banner_img — CF поле страницы // 2. banner_img — CF поле страницы
$banner_img = carbon_get_post_meta( 2, 'banner_img' ); $banner_img = carbon_get_post_meta( get_the_ID(), 'banner_img' );
if ( ! empty( $banner_img ) ) { if ( ! empty( $banner_img ) ) {
if ( is_array( $banner_img ) && isset( $banner_img['url'] ) ) { if ( is_array( $banner_img ) && isset( $banner_img['url'] ) ) {
return $banner_img['url']; return $banner_img['url'];
@ -451,7 +473,7 @@ function dekart_resolve_logo(): string {
} }
// 4. post_thumbnail страницы // 4. post_thumbnail страницы
if ( has_post_thumbnail( 2 ) ) { if ( has_post_thumbnail( get_the_ID() ) ) {
$url = get_the_post_thumbnail_url( 2, 'full' ); $url = get_the_post_thumbnail_url( 2, 'full' );
if ( $url ) { if ( $url ) {
return $url; return $url;
@ -471,13 +493,13 @@ function dekart_resolve_logo(): string {
* @see https://schema.org/EducationalOrganization * @see https://schema.org/EducationalOrganization
*/ */
function dekart_schema_local_business(): ?array { function dekart_schema_local_business(): ?array {
$page_url = home_url( '/podgotovka-k-shkole/' ); $page_url = dekart_schema_page_url();
$home_url = home_url( '/' ); $home_url = home_url( '/' );
// branch_name — название филиала (get_option) // branch_name — название филиала (get_option)
$site_name = dekart_branch_option( 'branch_name', 'Детский центр «Декарт»' ); $site_name = dekart_branch_option( 'branch_name', 'Детский центр «Декарт»' );
// branch_address — улица // branch_address — улица (приоритет: Settings API → CF theme option → хардкод)
$street = dekart_branch_option( 'branch_address', 'Комсомольская ул., 11' ); $street = get_option( 'branch_address' ) ?: carbon_get_theme_option( 'site_address' ) ?: 'Комсомольская ул., 11';
// branch_city — город // branch_city — город
$city = dekart_branch_option( 'branch_city', 'Щёлково' ); $city = dekart_branch_option( 'branch_city', 'Щёлково' );
// branch_region — регион/область // branch_region — регион/область
@ -505,6 +527,12 @@ function dekart_schema_local_business(): ?array {
$founded = dekart_schema_option( 'schema_founded', 'branch_founded' ); $founded = dekart_schema_option( 'schema_founded', 'branch_founded' );
// branch_map_url — ссылка на карту для hasMap (CF schema_map_url → Settings API) // branch_map_url — ссылка на карту для hasMap (CF schema_map_url → Settings API)
$map_url = dekart_schema_option( 'schema_map_url', 'branch_map_url' ); $map_url = dekart_schema_option( 'schema_map_url', 'branch_map_url' );
// branch_google_place_id — Google Place ID
$google_place_id = get_option( 'branch_google_place_id' );
// branch_google_maps_url — Google Maps URL для hasMap
$google_maps_url = get_option( 'branch_google_maps_url' );
// branch_area_served — города обслуживания (через запятую)
$area_served_raw = get_option( 'branch_area_served', 'Щёлково, Фрязино, Монино, Ивантеевка, Королёв' );
// branch_hours — часы работы: CF complex (schema_hours) → textarea (branch_hours) // branch_hours — часы работы: CF complex (schema_hours) → textarea (branch_hours)
$hours_cf = carbon_get_theme_option( 'schema_hours' ); $hours_cf = carbon_get_theme_option( 'schema_hours' );
@ -630,11 +658,28 @@ function dekart_schema_local_business(): ?array {
$lb['priceRange'] = trim( $price_range ); $lb['priceRange'] = trim( $price_range );
} }
// areaServed — город присутствия (условно) // Google Place ID — identifier + @id для Google Business Profile
$lb['areaServed'] = array( if ( ! empty( $google_place_id ) && is_string( $google_place_id ) ) {
'@type' => 'City', $lb['identifier'] = array(
'name' => wp_strip_all_tags( $city ), '@type' => 'PropertyValue',
'name' => 'Google Place ID',
'value' => wp_strip_all_tags( $google_place_id ),
); );
$lb['@id'] = 'https://search.google.com/local/writereview?placeid=' . urlencode( $google_place_id );
}
// areaServed — массив городов (branch_area_served: "Щёлково, Фрязино, Монино...")
$area_cities = array_map( 'trim', explode( ',', $area_served_raw ) );
$lb['areaServed'] = array_map( function( $c ) {
return array( '@type' => 'City', 'name' => wp_strip_all_tags( $c ) );
}, array_filter( $area_cities ) );
// hasMap — Google Maps URL (приоритет Google, потом Yandex)
if ( ! empty( $google_maps_url ) && is_string( $google_maps_url ) ) {
$lb['hasMap'] = esc_url( trim( $google_maps_url ) );
} elseif ( ! empty( $map_url ) && is_string( $map_url ) ) {
$lb['hasMap'] = esc_url( trim( $map_url ) );
}
// foundingDate — год основания (условно) // foundingDate — год основания (условно)
if ( ! empty( $founded ) && is_string( $founded ) ) { if ( ! empty( $founded ) && is_string( $founded ) ) {
@ -687,6 +732,21 @@ function dekart_schema_local_business(): ?array {
} }
} }
} }
// Google Business Profile — добавляем в sameAs, если ссылка задана
$gbp_url = get_option( 'branch_google_business_url', '' );
if ( ! empty( $gbp_url ) && filter_var( $gbp_url, FILTER_VALIDATE_URL ) ) {
$found_gbp = false;
foreach ( $same_as as $existing ) {
if ( false !== stripos( $existing, parse_url( $gbp_url, PHP_URL_HOST ) ) ) {
$found_gbp = true;
break;
}
}
if ( ! $found_gbp ) {
$same_as[] = esc_url( $gbp_url );
}
}
// Если после добавления появились элементы, обновляем sameAs // Если после добавления появились элементы, обновляем sameAs
if ( ! empty( $same_as ) && ! isset( $lb['sameAs'] ) ) { if ( ! empty( $same_as ) && ! isset( $lb['sameAs'] ) ) {
$lb['sameAs'] = $same_as; $lb['sameAs'] = $same_as;
@ -728,7 +788,7 @@ function dekart_schema_local_business(): ?array {
} }
// employee — преподаватели (Person), если есть // employee — преподаватели (Person), если есть
$teachers = carbon_get_post_meta( 2, 'pksh_teachers' ); $teachers = carbon_get_post_meta( get_the_ID(), 'pksh_teachers' );
if ( ! empty( $teachers ) && is_array( $teachers ) ) { if ( ! empty( $teachers ) && is_array( $teachers ) ) {
$employee_ids = array(); $employee_ids = array();
foreach ( $teachers as $i => $t ) { foreach ( $teachers as $i => $t ) {
@ -755,32 +815,32 @@ function dekart_schema_local_business(): ?array {
* @see https://schema.org/CourseInstance * @see https://schema.org/CourseInstance
*/ */
function dekart_schema_course(): ?array { function dekart_schema_course(): ?array {
$page_url = home_url( '/podgotovka-k-shkole/' ); $page_url = dekart_schema_page_url();
$home_url = home_url( '/' ); $home_url = home_url( '/' );
// banner_h1 — заголовок H1 (Course.name) // banner_h1 — заголовок H1 (Course.name)
$name = carbon_get_post_meta( 2, 'banner_h1' ) ?: 'Подготовка к школе 57 лет в Щёлково'; $name = carbon_get_post_meta( get_the_ID(), 'banner_h1' ) ?: 'Подготовка к школе 57 лет в Щёлково';
// pksh_description — полное описание курса для микроразметки (textarea) // pksh_description — полное описание курса для микроразметки (textarea)
// Если заполнено — приоритет над banner_subtitle // Если заполнено — приоритет над banner_subtitle
$pksh_description = carbon_get_post_meta( 2, 'pksh_description' ); $pksh_description = carbon_get_post_meta( get_the_ID(), 'pksh_description' );
// banner_subtitle — подзаголовок (Course.description) // banner_subtitle — подзаголовок (Course.description)
$description = ! empty( $pksh_description ) ? $pksh_description : ( carbon_get_post_meta( 2, 'banner_subtitle' ) ?: 'Научим читать, считать и писать до школы. Бесплатное пробное занятие.' ); $description = ! empty( $pksh_description ) ? $pksh_description : ( carbon_get_post_meta( get_the_ID(), 'banner_subtitle' ) ?: 'Научим читать, считать и писать до школы. Бесплатное пробное занятие.' );
// Пробное занятие — есть ли в подзаголовке упоминание бесплатно // Пробное занятие — есть ли в подзаголовке упоминание бесплатно
$has_trial = ! empty( $description ) && false !== mb_stripos( $description, 'бесплатно' ); $has_trial = ! empty( $description ) && false !== mb_stripos( $description, 'бесплатно' );
// pksh_level — уровень образования // pksh_level — уровень образования
$level = carbon_get_post_meta( 2, 'pksh_level' ); $level = carbon_get_post_meta( get_the_ID(), 'pksh_level' );
// pksh_age — возраст // pksh_age — возраст
$age = carbon_get_post_meta( 2, 'pksh_age' ); $age = carbon_get_post_meta( get_the_ID(), 'pksh_age' );
// pksh_audience — целевая аудитория // pksh_audience — целевая аудитория
$audience = carbon_get_post_meta( 2, 'pksh_audience' ); $audience = carbon_get_post_meta( get_the_ID(), 'pksh_audience' );
// pksh_course_mode — формат обучения (очная/ONLINE/OFFLINE) // pksh_course_mode — формат обучения (очная/ONLINE/OFFLINE)
$course_mode = carbon_get_post_meta( 2, 'pksh_course_mode' ); $course_mode = carbon_get_post_meta( get_the_ID(), 'pksh_course_mode' );
// pksh_skills — список навыков // pksh_skills — список навыков
$skills = carbon_get_post_meta( 2, 'pksh_skills' ); $skills = carbon_get_post_meta( get_the_ID(), 'pksh_skills' );
// banner_img — изображение курса // banner_img — изображение курса
$course_img = carbon_get_post_meta( 2, 'banner_img' ); $course_img = carbon_get_post_meta( get_the_ID(), 'banner_img' );
// pksh_programs — программы (для timeRequired + CourseInstance) // pksh_programs — программы (для timeRequired + CourseInstance)
$programs = carbon_get_post_meta( 2, 'pksh_programs' ); $programs = carbon_get_post_meta( get_the_ID(), 'pksh_programs' );
$course = array( $course = array(
'@type' => 'Course', '@type' => 'Course',
@ -801,7 +861,7 @@ function dekart_schema_course(): ?array {
); );
// review — 1-2 отзыва прямо в Course (Google требует для звёзд в rich snippet) // review — 1-2 отзыва прямо в Course (Google требует для звёзд в rich snippet)
$reviews_raw = carbon_get_post_meta( 2, 'pksh_reviews_list' ); $reviews_raw = carbon_get_post_meta( get_the_ID(), 'pksh_reviews_list' );
if ( ! empty( $reviews_raw ) && is_array( $reviews_raw ) ) { if ( ! empty( $reviews_raw ) && is_array( $reviews_raw ) ) {
$review_items = array(); $review_items = array();
foreach ( $reviews_raw as $rv ) { foreach ( $reviews_raw as $rv ) {
@ -889,7 +949,7 @@ function dekart_schema_course(): ?array {
$course['image'] = esc_url( $url ); $course['image'] = esc_url( $url );
} }
} }
} elseif ( has_post_thumbnail( 2 ) ) { } elseif ( has_post_thumbnail( get_the_ID() ) ) {
// fallback: post_thumbnail страницы // fallback: post_thumbnail страницы
$url = get_the_post_thumbnail_url( 2, 'full' ); $url = get_the_post_thumbnail_url( 2, 'full' );
if ( $url ) { if ( $url ) {
@ -992,131 +1052,14 @@ function dekart_schema_course(): ?array {
return $course; return $course;
} }
/**
* AggregateOffer цены программ + бесплатное пробное.
* Извлекает числовые значения из program_price_subscription / program_price_single.
* Если программ нет возвращает Offer на бесплатное пробное.
* Если есть программы возвращает AggregateOffer + отдельный Offer для free trial.
*
* @see https://schema.org/AggregateOffer
* @see https://schema.org/Offer
*/
function dekart_schema_offer(): ?array {
$page_url = home_url( '/podgotovka-k-shkole/' );
$programs = carbon_get_post_meta( 2, 'pksh_programs' );
// priceValidUntil — самая поздняя дата из всех программ
$price_valid = '';
if ( ! empty( $programs ) && is_array( $programs ) ) {
foreach ( $programs as $prog ) {
if ( ! empty( $prog['program_price_valid'] ) ) {
$d = trim( $prog['program_price_valid'] );
if ( $d > $price_valid ) {
$price_valid = $d;
}
}
}
}
$prices = array();
if ( ! empty( $programs ) && is_array( $programs ) ) {
foreach ( $programs as $program ) {
// program_price_subscription — цена абонемента
if ( ! empty( $program['program_price_subscription'] ) ) {
$num = dekart_parse_price( $program['program_price_subscription'] );
if ( $num > 0 ) {
$prices[] = $num;
}
}
// program_price_single — цена разового занятия
if ( ! empty( $program['program_price_single'] ) ) {
$num = dekart_parse_price( $program['program_price_single'] );
if ( $num > 0 ) {
$prices[] = $num;
}
}
}
}
if ( empty( $prices ) ) {
// Нет цен в программах — Offer на бесплатное пробное
$offer = array(
'@type' => 'Offer',
'@id' => $page_url . '#offer',
'price' => '0',
'priceCurrency' => 'RUB',
'availability' => 'https://schema.org/LimitedAvailability',
'description' => 'Бесплатное пробное занятие',
);
// priceValidUntil — дата действия (условно)
if ( ! empty( $price_valid ) ) {
$offer['priceValidUntil'] = trim( $price_valid );
}
return $offer;
}
$low = min( $prices );
$high = max( $prices );
$aggregate = array(
'@type' => 'AggregateOffer',
'@id' => $page_url . '#offer',
'priceCurrency' => 'RUB',
'lowPrice' => (string) $low,
'highPrice' => (string) $high,
'offerCount' => (string) count( $prices ),
'availability' => 'https://schema.org/InStock',
// category — категория услуги
'category' => 'Образовательные услуги',
// offeredBy — кто предоставляет
'offeredBy' => array(
'@id' => $page_url . '#localbusiness',
),
);
// priceValidUntil — дата действия цен (условно)
if ( ! empty( $price_valid ) ) {
$aggregate['priceValidUntil'] = trim( $price_valid );
}
// Возвращаем массив: сначала AggregateOffer, потом (опционально) Offer на бесплатное пробное
$offers = array( $aggregate );
// banner_subtitle содержит фразу "бесплатное пробное" — создаём отдельный Offer
$subtitle = carbon_get_post_meta( 2, 'banner_subtitle' );
if ( ! empty( $subtitle ) && false !== mb_stripos( $subtitle, 'бесплатно' ) ) {
$trial = array(
'@type' => 'Offer',
'@id' => $page_url . '#offer-trial',
'price' => '0',
'priceCurrency' => 'RUB',
'availability' => 'https://schema.org/LimitedAvailability',
'description' => wp_strip_all_tags( $subtitle ),
'category' => 'Бесплатное пробное занятие',
'offeredBy' => array(
'@id' => $page_url . '#localbusiness',
),
);
if ( ! empty( $price_valid ) ) {
$trial['priceValidUntil'] = trim( $price_valid );
}
$offers[] = $trial;
}
return $offers;
}
/** /**
* AggregateRating рейтинг из отзывов Carbon Fields. * AggregateRating рейтинг из отзывов Carbon Fields.
* Вычисляется динамически из pksh_reviews_list. * Вычисляется динамически из pksh_reviews_list.
* Если отзывов < 1 блок не выводится. * Если отзывов < 1 блок не выводится.
*/ */
function dekart_schema_rating(): ?array { function dekart_schema_rating(): ?array {
$page_url = home_url( '/podgotovka-k-shkole/' ); $page_url = dekart_schema_page_url();
$reviews = carbon_get_post_meta( 2, 'pksh_reviews_list' ); $reviews = carbon_get_post_meta( get_the_ID(), 'pksh_reviews_list' );
if ( empty( $reviews ) || ! is_array( $reviews ) ) { if ( empty( $reviews ) || ! is_array( $reviews ) ) {
return null; return null;
@ -1144,7 +1087,7 @@ function dekart_schema_rating(): ?array {
'worstRating' => '1', 'worstRating' => '1',
'ratingCount' => (string) count( $ratings ), 'ratingCount' => (string) count( $ratings ),
'itemReviewed' => array( 'itemReviewed' => array(
'@id' => home_url( '/' ) . '#organization', '@id' => $page_url . '#course',
), ),
); );
} }
@ -1157,11 +1100,11 @@ function dekart_schema_rating(): ?array {
* @see https://schema.org/Service * @see https://schema.org/Service
*/ */
function dekart_schema_service(): ?array { function dekart_schema_service(): ?array {
$page_url = home_url( '/podgotovka-k-shkole/' ); $page_url = dekart_schema_page_url();
$home_url = home_url( '/' ); $home_url = home_url( '/' );
$name = carbon_get_post_meta( 2, 'banner_h1' ) ?: 'Подготовка к школе'; $name = carbon_get_post_meta( get_the_ID(), 'banner_h1' ) ?: 'Подготовка к школе';
$description = carbon_get_post_meta( 2, 'banner_subtitle' ) ?: ''; $description = carbon_get_post_meta( get_the_ID(), 'banner_subtitle' ) ?: '';
$service = array( $service = array(
'@type' => 'Service', '@type' => 'Service',
@ -1188,8 +1131,8 @@ function dekart_schema_service(): ?array {
* Если вопросов нет блок не выводится. * Если вопросов нет блок не выводится.
*/ */
function dekart_schema_faq(): ?array { function dekart_schema_faq(): ?array {
$page_url = home_url( '/podgotovka-k-shkole/' ); $page_url = dekart_schema_page_url();
$faq_items = carbon_get_post_meta( 2, 'pksh_faq_items' ); $faq_items = carbon_get_post_meta( get_the_ID(), 'pksh_faq_items' );
if ( empty( $faq_items ) || ! is_array( $faq_items ) ) { if ( empty( $faq_items ) || ! is_array( $faq_items ) ) {
return null; return null;
@ -1234,9 +1177,9 @@ function dekart_schema_faq(): ?array {
* @see https://schema.org/Review * @see https://schema.org/Review
*/ */
function dekart_schema_reviews(): array { function dekart_schema_reviews(): array {
$page_url = home_url( '/podgotovka-k-shkole/' ); $page_url = dekart_schema_page_url();
// pksh_reviews_list — complex-поле отзывов // pksh_reviews_list — complex-поле отзывов
$reviews = carbon_get_post_meta( 2, 'pksh_reviews_list' ); $reviews = carbon_get_post_meta( get_the_ID(), 'pksh_reviews_list' );
if ( empty( $reviews ) || ! is_array( $reviews ) ) { if ( empty( $reviews ) || ! is_array( $reviews ) ) {
return array(); return array();
@ -1273,7 +1216,7 @@ function dekart_schema_reviews(): array {
), ),
'reviewBody' => wp_strip_all_tags( $body ), 'reviewBody' => wp_strip_all_tags( $body ),
'itemReviewed' => array( 'itemReviewed' => array(
'@id' => home_url( '/' ) . '#organization', '@id' => $page_url . '#course',
), ),
'reviewRating' => array( 'reviewRating' => array(
'@type' => 'Rating', '@type' => 'Rating',
@ -1304,11 +1247,11 @@ function dekart_schema_reviews(): array {
* @see https://schema.org/Person * @see https://schema.org/Person
*/ */
function dekart_schema_teachers(): array { function dekart_schema_teachers(): array {
$page_url = home_url( '/podgotovka-k-shkole/' ); $page_url = dekart_schema_page_url();
// pksh_teachers — complex-поле преподавателей // pksh_teachers — complex-поле преподавателей
// Subfields: teacher_id (select → post ID), teacher_subject, teacher_about // Subfields: teacher_id (select → post ID), teacher_subject, teacher_about
// teacher_name и teacher_photo загружаются из карточки преподавателя // teacher_name и teacher_photo загружаются из карточки преподавателя
$teachers = carbon_get_post_meta( 2, 'pksh_teachers' ); $teachers = carbon_get_post_meta( get_the_ID(), 'pksh_teachers' );
if ( empty( $teachers ) || ! is_array( $teachers ) ) { if ( empty( $teachers ) || ! is_array( $teachers ) ) {
return array(); return array();
@ -1382,6 +1325,244 @@ function dekart_schema_teachers(): array {
return $result; return $result;
} }
/* =====================================================================
Новые сущности для Phase 2 (Local SEO + AI)
===================================================================== */
/**
* Event бесплатное пробное занятие.
* Google Events carousel, высокая конверсия на "пробное занятие [город]".
*
* @see https://schema.org/Event
*/
function dekart_schema_trial_event(): ?array {
if ( ! is_page( 2 ) ) {
return null;
}
$page_url = dekart_schema_page_url();
// Проверяем, есть ли упоминание бесплатного пробного в подзаголовке
$subtitle = carbon_get_post_meta( get_the_ID(), 'banner_subtitle' );
$has_trial = ! empty( $subtitle ) && false !== mb_stripos( $subtitle, 'бесплатно' );
if ( ! $has_trial ) {
return null;
}
// Дата события: ближайший понедельник или "ежедневно"
$start_date = carbon_get_post_meta( get_the_ID(), 'pksh_trial_event_start_date' );
if ( empty( $start_date ) ) {
// Fallback: следующий понедельник
$start_date = date( 'c', strtotime( 'next monday 10:00' ) );
}
$end_date = carbon_get_post_meta( get_the_ID(), 'pksh_trial_event_end_date' );
if ( empty( $end_date ) ) {
$end_date = date( 'c', strtotime( $start_date . ' +1 hour' ) );
}
$location = array(
'@type' => 'Place',
'name' => 'Детский центр «Декарт»',
'address' => array(
'@type' => 'PostalAddress',
'streetAddress' => get_option( 'branch_address' ) ?: carbon_get_theme_option( 'site_address' ),
'addressLocality' => get_option( 'branch_city', 'Щёлково' ),
'addressRegion' => get_option( 'branch_region', 'Московская область' ),
'addressCountry' => 'RU',
),
);
return array(
'@type' => 'Event',
'@id' => $page_url . '#trial-event',
'name' => 'Бесплатное пробное занятие: Подготовка к школе',
'description' => 'Полноценное занятие + диагностика готовности к школе. Ребёнок познакомится с педагогом, выполнит тестовые задания. Родители получат план подготовки.',
'eventStatus' => 'https://schema.org/EventScheduled',
'eventAttendanceMode' => 'https://schema.org/OfflineEventAttendanceMode',
'startDate' => $start_date,
'endDate' => $end_date,
'location' => $location,
'offers' => array(
'@type' => 'Offer',
'@id' => $page_url . '#offer-trial',
'price' => '0',
'priceCurrency' => 'RUB',
'availability' => 'https://schema.org/LimitedAvailability',
'description' => 'Бесплатное пробное занятие',
'category' => 'Бесплатное пробное занятие',
),
'organizer' => array(
'@id' => home_url( '/' ) . '#organization',
),
'image' => get_the_post_thumbnail_url( 2, 'full' ) ?: '',
);
}
/**
* QAPage ключевые вопросы для AI citation readiness.
* Google retired FAQ rich results May 2026, но QAPage важен для AI Overviews/ChatGPT/Perplexity.
*
* @see https://schema.org/QAPage
*/
function dekart_schema_qapage(): ?array {
if ( ! is_page( 2 ) ) {
return null;
}
$page_url = dekart_schema_page_url();
$faq_items = carbon_get_post_meta( get_the_ID(), 'pksh_faq_items' );
if ( empty( $faq_items ) || ! is_array( $faq_items ) ) {
return null;
}
// Берём только 3-5 ключевых вопросов (самые частые)
$key_questions = array_slice( $faq_items, 0, 5 );
$main_entity = array();
foreach ( $key_questions as $item ) {
$question = isset( $item['faq_question'] ) ? trim( $item['faq_question'] ) : '';
$answer = isset( $item['faq_answer'] ) ? trim( $item['faq_answer'] ) : '';
if ( empty( $question ) || empty( $answer ) ) {
continue;
}
$main_entity[] = array(
'@type' => 'Question',
'name' => wp_strip_all_tags( $question ),
'acceptedAnswer' => array(
'@type' => 'Answer',
'text' => wp_strip_all_tags( $answer ),
),
);
}
if ( empty( $main_entity ) ) {
return null;
}
return array(
'@type' => 'QAPage',
'@id' => $page_url . '#qapage',
'mainEntity' => $main_entity,
);
}
/**
* Offer / AggregateOffer с UnitPriceSpecification.
* Добавляет priceSpecification с unitCode: 'MON' для цен за месяц.
*
* @see https://schema.org/UnitPriceSpecification
*/
function dekart_schema_offer(): ?array {
$page_url = dekart_schema_page_url();
$programs = carbon_get_post_meta( get_the_ID(), 'pksh_programs' );
$price_valid = '';
if ( ! empty( $programs ) && is_array( $programs ) ) {
foreach ( $programs as $prog ) {
if ( ! empty( $prog['program_price_valid'] ) ) {
$d = trim( $prog['program_price_valid'] );
if ( $d > $price_valid ) {
$price_valid = $d;
}
}
}
}
$prices = array();
if ( ! empty( $programs ) && is_array( $programs ) ) {
foreach ( $programs as $program ) {
if ( ! empty( $program['program_price_subscription'] ) ) {
$num = dekart_parse_price( $program['program_price_subscription'] );
if ( $num > 0 ) {
$prices[] = $num;
}
}
if ( ! empty( $program['program_price_single'] ) ) {
$num = dekart_parse_price( $program['program_price_single'] );
if ( $num > 0 ) {
$prices[] = $num;
}
}
}
}
if ( empty( $prices ) ) {
$offer = array(
'@type' => 'Offer',
'@id' => $page_url . '#offer',
'price' => '0',
'priceCurrency' => 'RUB',
'availability' => 'https://schema.org/LimitedAvailability',
'description' => 'Бесплатное пробное занятие',
'category' => 'Бесплатное пробное занятие',
);
if ( ! empty( $price_valid ) ) {
$offer['priceValidUntil'] = trim( $price_valid );
}
return $offer;
}
$low = min( $prices );
$high = max( $prices );
$aggregate = array(
'@type' => 'AggregateOffer',
'@id' => $page_url . '#offer',
'priceCurrency' => 'RUB',
'lowPrice' => (string) $low,
'highPrice' => (string) $high,
'offerCount' => (string) count( $prices ),
'availability' => 'https://schema.org/InStock',
'category' => 'Образовательные услуги',
'offeredBy' => array(
'@id' => $page_url . '#localbusiness',
),
// UnitPriceSpecification для цен за месяц
'priceSpecification' => array(
'@type' => 'UnitPriceSpecification',
'priceCurrency' => 'RUB',
'minPrice' => (string) $low,
'maxPrice' => (string) $high,
'unitCode' => 'MON', // месяц
'unitText' => 'месяц',
),
);
if ( ! empty( $price_valid ) ) {
$aggregate['priceValidUntil'] = trim( $price_valid );
}
$offers = array( $aggregate );
$subtitle = carbon_get_post_meta( get_the_ID(), 'banner_subtitle' );
if ( ! empty( $subtitle ) && false !== mb_stripos( $subtitle, 'бесплатно' ) ) {
$trial = array(
'@type' => 'Offer',
'@id' => $page_url . '#offer-trial',
'price' => '0',
'priceCurrency' => 'RUB',
'availability' => 'https://schema.org/LimitedAvailability',
'description' => wp_strip_all_tags( $subtitle ),
'category' => 'Бесплатное пробное занятие',
'offeredBy' => array(
'@id' => $page_url . '#localbusiness',
),
);
if ( ! empty( $price_valid ) ) {
$trial['priceValidUntil'] = trim( $price_valid );
}
$offers[] = $trial;
}
return $offers;
}
/* ===================================================================== /* =====================================================================
Вспомогательные функции Вспомогательные функции
===================================================================== */ ===================================================================== */