feat: Integrate DataForSEO for indexed pages tracking
- Updated CRON documentation to include DataForSEO metrics synchronization. - Enhanced SettingsController to manage DataForSEO API credentials and settings. - Modified SiteController to handle DataForSEO domain input. - Updated Site model to accommodate DataForSEO data handling. - Added methods in SiteSeoMetric model for DataForSEO data retrieval and validation. - Implemented SiteSeoSyncService to synchronize SEO metrics from both SEMSTORM and DataForSEO. - Enhanced dashboard templates to display indexed pages data. - Updated settings and site creation/edit templates to include DataForSEO fields. - Created migration for adding DataForSEO related columns in the database. - Developed DataForSeoService to fetch indexed pages count from DataForSEO API.
This commit is contained in:
@@ -13,6 +13,12 @@ SEMSTORM_LOGIN=
|
|||||||
SEMSTORM_PASSWORD=
|
SEMSTORM_PASSWORD=
|
||||||
SEMSTORM_API_BASE=https://api.semstorm.com
|
SEMSTORM_API_BASE=https://api.semstorm.com
|
||||||
SEMSTORM_TIMEOUT_SECONDS=30
|
SEMSTORM_TIMEOUT_SECONDS=30
|
||||||
|
DATAFORSEO_LOGIN=
|
||||||
|
DATAFORSEO_PASSWORD=
|
||||||
|
DATAFORSEO_API_BASE=https://api.dataforseo.com
|
||||||
|
DATAFORSEO_TIMEOUT_SECONDS=30
|
||||||
|
DATAFORSEO_LOCATION_CODE=2616
|
||||||
|
DATAFORSEO_LANGUAGE_CODE=pl
|
||||||
SEO_TRIGGER_TOKEN=change-this-to-long-random-token
|
SEO_TRIGGER_TOKEN=change-this-to-long-random-token
|
||||||
|
|
||||||
APP_URL=https://backpro.projectpro.pl
|
APP_URL=https://backpro.projectpro.pl
|
||||||
|
|||||||
350
.vscode/ftp-kr.sync.cache.json
vendored
350
.vscode/ftp-kr.sync.cache.json
vendored
@@ -5,127 +5,72 @@
|
|||||||
"css": {
|
"css": {
|
||||||
"app.css": {
|
"app.css": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 1982,
|
"size": 1982,
|
||||||
"lmtime": 1771375416079,
|
"lmtime": 1771375416079,
|
||||||
=======
|
|
||||||
"size": 1915,
|
|
||||||
"lmtime": 1771354445514,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"js": {
|
"js": {
|
||||||
"app.js": {
|
"app.js": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 12352,
|
"size": 12352,
|
||||||
"lmtime": 1771375416080,
|
"lmtime": 1771375416080,
|
||||||
=======
|
|
||||||
"size": 12059,
|
|
||||||
"lmtime": 1771354440500,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"wp-theme-backpro-news": {
|
"wp-theme-backpro-news": {
|
||||||
"archive.php": {
|
"archive.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 908,
|
"size": 908,
|
||||||
"lmtime": 1771375416081,
|
"lmtime": 1771375416081,
|
||||||
=======
|
|
||||||
"size": 880,
|
|
||||||
"lmtime": 1771352690031,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"footer.php": {
|
"footer.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 324,
|
"size": 324,
|
||||||
"lmtime": 1771375416082,
|
"lmtime": 1771375416082,
|
||||||
=======
|
|
||||||
"size": 308,
|
|
||||||
"lmtime": 1771352651733,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"front-page.php": {
|
"front-page.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 4350,
|
"size": 4350,
|
||||||
"lmtime": 1771375416082,
|
"lmtime": 1771375416082,
|
||||||
=======
|
|
||||||
"size": 4256,
|
|
||||||
"lmtime": 1771353361367,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"functions.php": {
|
"functions.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 847,
|
"size": 847,
|
||||||
"lmtime": 1771375416083,
|
"lmtime": 1771375416083,
|
||||||
=======
|
|
||||||
"size": 811,
|
|
||||||
"lmtime": 1771352633840,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"header.php": {
|
"header.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 966,
|
"size": 966,
|
||||||
"lmtime": 1771375416084,
|
"lmtime": 1771375416084,
|
||||||
=======
|
|
||||||
"size": 937,
|
|
||||||
"lmtime": 1771352640810,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"index.php": {
|
"index.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 1029,
|
"size": 1029,
|
||||||
"lmtime": 1771375416085,
|
"lmtime": 1771375416085,
|
||||||
=======
|
|
||||||
"size": 995,
|
|
||||||
"lmtime": 1771352675013,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"search.php": {
|
"search.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 683,
|
"size": 683,
|
||||||
"lmtime": 1771375416085,
|
"lmtime": 1771375416085,
|
||||||
=======
|
|
||||||
"size": 660,
|
|
||||||
"lmtime": 1771352696638,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"single.php": {
|
"single.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 6116,
|
"size": 6116,
|
||||||
"lmtime": 1771375416086,
|
"lmtime": 1771375416086,
|
||||||
=======
|
|
||||||
"size": 5988,
|
|
||||||
"lmtime": 1771353631148,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"style.css": {
|
"style.css": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 10723,
|
"size": 10723,
|
||||||
"lmtime": 1771375416087,
|
"lmtime": 1771375416087,
|
||||||
=======
|
|
||||||
"size": 10119,
|
|
||||||
"lmtime": 1771353528618,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,8 +116,8 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"routes.php": {
|
"routes.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 3489,
|
"size": 3968,
|
||||||
"lmtime": 1771375416088,
|
"lmtime": 1771627464051,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -182,6 +127,12 @@
|
|||||||
"size": 1078,
|
"size": 1078,
|
||||||
"lmtime": 1771149803282,
|
"lmtime": 1771149803282,
|
||||||
"modified": false
|
"modified": false
|
||||||
|
},
|
||||||
|
"semstorm.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1874,
|
||||||
|
"lmtime": 1771620180848,
|
||||||
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
@@ -193,14 +144,14 @@
|
|||||||
},
|
},
|
||||||
"CRON.md": {
|
"CRON.md": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 4340,
|
"size": 4784,
|
||||||
"lmtime": 1771274893249,
|
"lmtime": 1771620216534,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"DATABASE.md": {
|
"DATABASE.md": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 7265,
|
"size": 8354,
|
||||||
"lmtime": 1771274893237,
|
"lmtime": 1771620224191,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"PLAN.md": {
|
"PLAN.md": {
|
||||||
@@ -212,14 +163,14 @@
|
|||||||
},
|
},
|
||||||
".env": {
|
".env": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 330,
|
"size": 389,
|
||||||
"lmtime": 1771446981282,
|
"lmtime": 1771626264475,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
".env.example": {
|
".env.example": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 285,
|
"size": 442,
|
||||||
"lmtime": 1771275211060,
|
"lmtime": 1771626490188,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
".htaccess": {
|
".htaccess": {
|
||||||
@@ -282,6 +233,18 @@
|
|||||||
"size": 333,
|
"size": 333,
|
||||||
"lmtime": 1771375416090,
|
"lmtime": 1771375416090,
|
||||||
"modified": false
|
"modified": false
|
||||||
|
},
|
||||||
|
"008_cron_logs.sql": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 480,
|
||||||
|
"lmtime": 1771618047955,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"009_site_seo_metrics.sql": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 829,
|
||||||
|
"lmtime": 1771620035555,
|
||||||
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src": {
|
"src": {
|
||||||
@@ -306,8 +269,8 @@
|
|||||||
},
|
},
|
||||||
"DashboardController.php": {
|
"DashboardController.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 858,
|
"size": 1339,
|
||||||
"lmtime": 1771149691435,
|
"lmtime": 1771627595983,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"GlobalTopicController.php": {
|
"GlobalTopicController.php": {
|
||||||
@@ -322,6 +285,12 @@
|
|||||||
"lmtime": 1771270380033,
|
"lmtime": 1771270380033,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
|
"LogController.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2597,
|
||||||
|
"lmtime": 1771618047955,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
"PublishController.php": {
|
"PublishController.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 2668,
|
"size": 2668,
|
||||||
@@ -330,20 +299,20 @@
|
|||||||
},
|
},
|
||||||
"SettingsController.php": {
|
"SettingsController.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 1645,
|
"size": 1868,
|
||||||
"lmtime": 1771274258130,
|
"lmtime": 1771626466574,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"SiteController.php": {
|
"SiteController.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 10788,
|
"size": 16907,
|
||||||
"lmtime": 1771375416092,
|
"lmtime": 1771628191087,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"TopicController.php": {
|
"TopicController.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 3446,
|
"size": 4009,
|
||||||
"lmtime": 1771271990709,
|
"lmtime": 1771628730441,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -400,8 +369,8 @@
|
|||||||
"Helpers": {
|
"Helpers": {
|
||||||
"Logger.php": {
|
"Logger.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 2028,
|
"size": 3120,
|
||||||
"lmtime": 1771375416092,
|
"lmtime": 1771618047955,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"Validator.php": {
|
"Validator.php": {
|
||||||
@@ -414,8 +383,8 @@
|
|||||||
"Models": {
|
"Models": {
|
||||||
"Article.php": {
|
"Article.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 4960,
|
"size": 5674,
|
||||||
"lmtime": 1771375416093,
|
"lmtime": 1771617909267,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"GlobalTopic.php": {
|
"GlobalTopic.php": {
|
||||||
@@ -426,8 +395,14 @@
|
|||||||
},
|
},
|
||||||
"Site.php": {
|
"Site.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 858,
|
"size": 1441,
|
||||||
"lmtime": 1771274660626,
|
"lmtime": 1771627194103,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"SiteSeoMetric.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 4817,
|
||||||
|
"lmtime": 1771627676032,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"Topic.php": {
|
"Topic.php": {
|
||||||
@@ -470,8 +445,20 @@
|
|||||||
},
|
},
|
||||||
"PublisherService.php": {
|
"PublisherService.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 8311,
|
"size": 11532,
|
||||||
"lmtime": 1771375416098,
|
"lmtime": 1771617909267,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"SemstormService.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 5897,
|
||||||
|
"lmtime": 1771627080134,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"SiteSeoSyncService.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 3146,
|
||||||
|
"lmtime": 1771620084332,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"TopicBalancer.php": {
|
"TopicBalancer.php": {
|
||||||
@@ -492,18 +479,12 @@
|
|||||||
"logs": {
|
"logs": {
|
||||||
"image_2026-02-17.log": {
|
"image_2026-02-17.log": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 72,
|
"size": 72,
|
||||||
"lmtime": 1771375416100,
|
"lmtime": 1771375416100,
|
||||||
=======
|
|
||||||
"size": 71,
|
|
||||||
"lmtime": 1771350104612,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"installer_2026-02-17.log": {
|
"installer_2026-02-17.log": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 4379,
|
"size": 4379,
|
||||||
"lmtime": 1771375416101,
|
"lmtime": 1771375416101,
|
||||||
"modified": false
|
"modified": false
|
||||||
@@ -512,21 +493,10 @@
|
|||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 118,
|
"size": 118,
|
||||||
"lmtime": 1771375416102,
|
"lmtime": 1771375416102,
|
||||||
=======
|
|
||||||
"size": 4436,
|
|
||||||
"lmtime": 1771350104684,
|
|
||||||
"modified": true
|
|
||||||
},
|
|
||||||
"openai_2026-02-17.log": {
|
|
||||||
"type": "-",
|
|
||||||
"size": 117,
|
|
||||||
"lmtime": 1771350104754,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"publish_2026-02-17.log": {
|
"publish_2026-02-17.log": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 2539,
|
"size": 2539,
|
||||||
"lmtime": 1771375416102,
|
"lmtime": 1771375416102,
|
||||||
"modified": false
|
"modified": false
|
||||||
@@ -535,21 +505,12 @@
|
|||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 702,
|
"size": 702,
|
||||||
"lmtime": 0,
|
"lmtime": 0,
|
||||||
=======
|
|
||||||
"size": 2508,
|
|
||||||
"lmtime": 1771350104824,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"wordpress_2026-02-17.log": {
|
"wordpress_2026-02-17.log": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 1471,
|
"size": 1471,
|
||||||
"lmtime": 1771375416103,
|
"lmtime": 1771375416103,
|
||||||
=======
|
|
||||||
"size": 1459,
|
|
||||||
"lmtime": 1771350285395,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,13 +519,8 @@
|
|||||||
"articles": {
|
"articles": {
|
||||||
"index.php": {
|
"index.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 6036,
|
"size": 6036,
|
||||||
"lmtime": 1771375416104,
|
"lmtime": 1771375416104,
|
||||||
=======
|
|
||||||
"size": 5926,
|
|
||||||
"lmtime": 1771354089325,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"show.php": {
|
"show.php": {
|
||||||
@@ -602,6 +558,12 @@
|
|||||||
"size": 8067,
|
"size": 8067,
|
||||||
"lmtime": 1771375416107,
|
"lmtime": 1771375416107,
|
||||||
"modified": false
|
"modified": false
|
||||||
|
},
|
||||||
|
"seo-stats.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 7955,
|
||||||
|
"lmtime": 1771627707865,
|
||||||
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"global-topics": {
|
"global-topics": {
|
||||||
@@ -612,78 +574,6 @@
|
|||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"layout": {
|
|
||||||
"header.php": {
|
|
||||||
"type": "-",
|
|
||||||
"size": 623,
|
|
||||||
<<<<<<< HEAD
|
|
||||||
"lmtime": 1771375416109,
|
|
||||||
=======
|
|
||||||
"lmtime": 1771354088998,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
|
||||||
},
|
|
||||||
"main.php": {
|
|
||||||
"type": "-",
|
|
||||||
<<<<<<< HEAD
|
|
||||||
"size": 1821,
|
|
||||||
"lmtime": 1771375416109,
|
|
||||||
"modified": false
|
|
||||||
=======
|
|
||||||
"size": 1816,
|
|
||||||
"lmtime": 1771149813250,
|
|
||||||
"modified": true
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
},
|
|
||||||
"sidebar.php": {
|
|
||||||
"type": "-",
|
|
||||||
"size": 1894,
|
|
||||||
"lmtime": 1771375416110,
|
|
||||||
"modified": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"index.php": {
|
|
||||||
"type": "-",
|
|
||||||
"size": 5509,
|
|
||||||
"lmtime": 1771274394027,
|
|
||||||
"modified": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sites": {
|
|
||||||
"create.php": {
|
|
||||||
"type": "-",
|
|
||||||
"size": 4883,
|
|
||||||
"lmtime": 1771274870419,
|
|
||||||
"modified": false
|
|
||||||
},
|
|
||||||
"dashboard.php": {
|
|
||||||
"type": "-",
|
|
||||||
"size": 12823,
|
|
||||||
"lmtime": 1771375416111,
|
|
||||||
"modified": false
|
|
||||||
},
|
|
||||||
"edit.php": {
|
|
||||||
"type": "-",
|
|
||||||
"size": 18800,
|
|
||||||
"lmtime": 1771375416112,
|
|
||||||
"modified": false
|
|
||||||
},
|
|
||||||
"index.php": {
|
|
||||||
"type": "-",
|
|
||||||
"size": 4961,
|
|
||||||
"lmtime": 1771375416113,
|
|
||||||
"modified": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"topics": {
|
|
||||||
"index.php": {
|
|
||||||
"type": "-",
|
|
||||||
"size": 11477,
|
|
||||||
"lmtime": 1771375416114,
|
|
||||||
"modified": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"installer": {
|
"installer": {
|
||||||
"index.php": {
|
"index.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
@@ -691,16 +581,94 @@
|
|||||||
"lmtime": 1771270472738,
|
"lmtime": 1771270472738,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"header.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 623,
|
||||||
|
"lmtime": 1771375416109,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"main.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1821,
|
||||||
|
"lmtime": 1771375416109,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"sidebar.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2261,
|
||||||
|
"lmtime": 1771627469556,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"index.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 7022,
|
||||||
|
"lmtime": 1771626482753,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sites": {
|
||||||
|
"create.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 5295,
|
||||||
|
"lmtime": 1771620136407,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"dashboard.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 12963,
|
||||||
|
"lmtime": 1771625982185,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"edit.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 13837,
|
||||||
|
"lmtime": 1771628695879,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"index.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 5194,
|
||||||
|
"lmtime": 1771625987517,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"seo.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 7545,
|
||||||
|
"lmtime": 1771626686595,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"topics": {
|
||||||
|
"index.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 14943,
|
||||||
|
"lmtime": 1771628879393,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"index.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 3405,
|
||||||
|
"lmtime": 1771617998906,
|
||||||
|
"modified": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"tmp_debug_metrics.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 393,
|
||||||
|
"lmtime": 1771626947881,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
"TODO.md": {
|
"TODO.md": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 233,
|
"size": 233,
|
||||||
<<<<<<< HEAD
|
|
||||||
"lmtime": 1771373773622,
|
"lmtime": 1771373773622,
|
||||||
=======
|
|
||||||
"lmtime": 0,
|
|
||||||
>>>>>>> 2461cde97a6a75a38bee9437aae52847c968b5b5
|
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"vendor": {
|
"vendor": {
|
||||||
@@ -824,6 +792,12 @@
|
|||||||
"lmtime": 1771150407034,
|
"lmtime": 1771150407034,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tmp_semstorm_test.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1027,
|
||||||
|
"lmtime": 1771627094790,
|
||||||
|
"modified": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ Przycisk "Opublikuj teraz" na dashboardzie wywołuje `PublishController@run`, kt
|
|||||||
| 401 z WordPress | Złe dane API | Sprawdź api_user/api_token w konfiguracji strony |
|
| 401 z WordPress | Złe dane API | Sprawdź api_user/api_token w konfiguracji strony |
|
||||||
| 429 z OpenAI | Rate limit | Zwiększ interwał CRON lub poczekaj |
|
| 429 z OpenAI | Rate limit | Zwiększ interwał CRON lub poczekaj |
|
||||||
|
|
||||||
## Miesieczna synchronizacja metryk SEO (SEMSTORM)
|
## Miesieczna synchronizacja metryk SEO (SEMSTORM + DataForSEO)
|
||||||
|
|
||||||
Dodaj osobne zadanie CRON uruchamiane raz w miesiacu:
|
Dodaj osobne zadanie CRON uruchamiane raz w miesiacu:
|
||||||
|
|
||||||
@@ -128,5 +128,6 @@ Dodaj osobne zadanie CRON uruchamiane raz w miesiacu:
|
|||||||
Skrypt:
|
Skrypt:
|
||||||
- pobiera aktywne strony (`sites.is_active = 1`),
|
- pobiera aktywne strony (`sites.is_active = 1`),
|
||||||
- pobiera metryki z SEMSTORM dla domeny strony,
|
- pobiera metryki z SEMSTORM dla domeny strony,
|
||||||
|
- pobiera liczbe zaindeksowanych stron (zapytanie `site:domena`) z DataForSEO,
|
||||||
- zapisuje dane do `site_seo_metrics` dla biezacego miesiaca,
|
- zapisuje dane do `site_seo_metrics` dla biezacego miesiaca,
|
||||||
- pomija rekord, jesli miesiac jest juz zapisany (idempotencja).
|
- pomija rekord, jesli miesiac jest juz zapisany (idempotencja).
|
||||||
|
|||||||
7
migrations/010_dataforseo_indexed_pages.sql
Normal file
7
migrations/010_dataforseo_indexed_pages.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- BackPRO DataForSEO indexed pages integration
|
||||||
|
ALTER TABLE sites
|
||||||
|
ADD COLUMN dataforseo_domain VARCHAR(255) NULL AFTER semstorm_domain;
|
||||||
|
|
||||||
|
ALTER TABLE site_seo_metrics
|
||||||
|
ADD COLUMN indexed_pages INT NOT NULL DEFAULT 0 AFTER traffic;
|
||||||
|
|
||||||
@@ -25,6 +25,12 @@ class SettingsController extends Controller
|
|||||||
'semstorm_password',
|
'semstorm_password',
|
||||||
'semstorm_api_base',
|
'semstorm_api_base',
|
||||||
'semstorm_timeout_seconds',
|
'semstorm_timeout_seconds',
|
||||||
|
'dataforseo_login',
|
||||||
|
'dataforseo_password',
|
||||||
|
'dataforseo_api_base',
|
||||||
|
'dataforseo_timeout_seconds',
|
||||||
|
'dataforseo_location_code',
|
||||||
|
'dataforseo_language_code',
|
||||||
];
|
];
|
||||||
|
|
||||||
private array $settingDefaults = [
|
private array $settingDefaults = [
|
||||||
@@ -36,6 +42,10 @@ class SettingsController extends Controller
|
|||||||
'image_generation_prompt' => ImageService::DEFAULT_FREEPIK_PROMPT_TEMPLATE,
|
'image_generation_prompt' => ImageService::DEFAULT_FREEPIK_PROMPT_TEMPLATE,
|
||||||
'semstorm_api_base' => 'https://api.semstorm.com',
|
'semstorm_api_base' => 'https://api.semstorm.com',
|
||||||
'semstorm_timeout_seconds' => '30',
|
'semstorm_timeout_seconds' => '30',
|
||||||
|
'dataforseo_api_base' => 'https://api.dataforseo.com',
|
||||||
|
'dataforseo_timeout_seconds' => '30',
|
||||||
|
'dataforseo_location_code' => '2616',
|
||||||
|
'dataforseo_language_code' => 'pl',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function index(): void
|
public function index(): void
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class SiteController extends Controller
|
|||||||
'name' => $this->input('name'),
|
'name' => $this->input('name'),
|
||||||
'url' => rtrim($this->input('url'), '/'),
|
'url' => rtrim($this->input('url'), '/'),
|
||||||
'semstorm_domain' => $this->input('semstorm_domain') ?: null,
|
'semstorm_domain' => $this->input('semstorm_domain') ?: null,
|
||||||
|
'dataforseo_domain' => $this->input('dataforseo_domain') ?: null,
|
||||||
'api_user' => $this->input('api_user'),
|
'api_user' => $this->input('api_user'),
|
||||||
'api_token' => $this->input('api_token'),
|
'api_token' => $this->input('api_token'),
|
||||||
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
|
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
|
||||||
@@ -149,6 +150,7 @@ class SiteController extends Controller
|
|||||||
'name' => $this->input('name'),
|
'name' => $this->input('name'),
|
||||||
'url' => rtrim($this->input('url'), '/'),
|
'url' => rtrim($this->input('url'), '/'),
|
||||||
'semstorm_domain' => $this->input('semstorm_domain') ?: null,
|
'semstorm_domain' => $this->input('semstorm_domain') ?: null,
|
||||||
|
'dataforseo_domain' => $this->input('dataforseo_domain') ?: null,
|
||||||
'api_user' => $this->input('api_user'),
|
'api_user' => $this->input('api_user'),
|
||||||
'api_token' => $this->input('api_token'),
|
'api_token' => $this->input('api_token'),
|
||||||
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
|
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ class Site extends Model
|
|||||||
ON m.site_id = s.id
|
ON m.site_id = s.id
|
||||||
AND m.metric_month = :metric_month
|
AND m.metric_month = :metric_month
|
||||||
WHERE s.is_active = 1
|
WHERE s.is_active = 1
|
||||||
AND m.id IS NULL
|
AND (
|
||||||
|
m.id IS NULL
|
||||||
|
OR m.source_payload IS NULL
|
||||||
|
OR m.source_payload NOT LIKE '%\"dataforseo\"%'
|
||||||
|
OR m.source_payload LIKE '%\"dataforseo\":null%'
|
||||||
|
)
|
||||||
ORDER BY s.id ASC
|
ORDER BY s.id ASC
|
||||||
LIMIT 1";
|
LIMIT 1";
|
||||||
|
|
||||||
|
|||||||
@@ -21,19 +21,57 @@ class SiteSeoMetric extends Model
|
|||||||
return (bool) $stmt->fetchColumn();
|
return (bool) $stmt->fetchColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function findForMonth(int $siteId, string $metricMonth): ?array
|
||||||
|
{
|
||||||
|
$stmt = self::db()->prepare(
|
||||||
|
'SELECT id, metric_month, top3, top10, top20, top50, traffic, indexed_pages, source_payload, updated_at
|
||||||
|
FROM site_seo_metrics
|
||||||
|
WHERE site_id = :site_id AND metric_month = :metric_month
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
'site_id' => $siteId,
|
||||||
|
'metric_month' => $metricMonth,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hasDataforseoForMonth(int $siteId, string $metricMonth): bool
|
||||||
|
{
|
||||||
|
$row = self::findForMonth($siteId, $metricMonth);
|
||||||
|
if (!$row) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payloadRaw = trim((string) ($row['source_payload'] ?? ''));
|
||||||
|
if ($payloadRaw === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($payloadRaw, true);
|
||||||
|
if (!is_array($decoded) || !array_key_exists('dataforseo', $decoded)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($decoded['dataforseo']) && trim($decoded['dataforseo']) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
public static function upsertMonthly(int $siteId, string $metricMonth, array $metrics, ?string $payload = null): void
|
public static function upsertMonthly(int $siteId, string $metricMonth, array $metrics, ?string $payload = null): void
|
||||||
{
|
{
|
||||||
$stmt = self::db()->prepare(
|
$stmt = self::db()->prepare(
|
||||||
'INSERT INTO site_seo_metrics
|
'INSERT INTO site_seo_metrics
|
||||||
(site_id, metric_month, top3, top10, top20, top50, traffic, source_payload)
|
(site_id, metric_month, top3, top10, top20, top50, traffic, indexed_pages, source_payload)
|
||||||
VALUES
|
VALUES
|
||||||
(:site_id, :metric_month, :top3, :top10, :top20, :top50, :traffic, :source_payload)
|
(:site_id, :metric_month, :top3, :top10, :top20, :top50, :traffic, :indexed_pages, :source_payload)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
top3 = VALUES(top3),
|
top3 = VALUES(top3),
|
||||||
top10 = VALUES(top10),
|
top10 = VALUES(top10),
|
||||||
top20 = VALUES(top20),
|
top20 = VALUES(top20),
|
||||||
top50 = VALUES(top50),
|
top50 = VALUES(top50),
|
||||||
traffic = VALUES(traffic),
|
traffic = VALUES(traffic),
|
||||||
|
indexed_pages = VALUES(indexed_pages),
|
||||||
source_payload = VALUES(source_payload),
|
source_payload = VALUES(source_payload),
|
||||||
updated_at = CURRENT_TIMESTAMP'
|
updated_at = CURRENT_TIMESTAMP'
|
||||||
);
|
);
|
||||||
@@ -46,6 +84,7 @@ class SiteSeoMetric extends Model
|
|||||||
'top20' => max(0, (int) ($metrics['top20'] ?? 0)),
|
'top20' => max(0, (int) ($metrics['top20'] ?? 0)),
|
||||||
'top50' => max(0, (int) ($metrics['top50'] ?? 0)),
|
'top50' => max(0, (int) ($metrics['top50'] ?? 0)),
|
||||||
'traffic' => max(0, (int) ($metrics['traffic'] ?? 0)),
|
'traffic' => max(0, (int) ($metrics['traffic'] ?? 0)),
|
||||||
|
'indexed_pages' => max(0, (int) ($metrics['indexed_pages'] ?? 0)),
|
||||||
'source_payload' => $payload,
|
'source_payload' => $payload,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -53,7 +92,7 @@ class SiteSeoMetric extends Model
|
|||||||
public static function findBySite(int $siteId, int $limit = 12): array
|
public static function findBySite(int $siteId, int $limit = 12): array
|
||||||
{
|
{
|
||||||
$stmt = self::db()->prepare(
|
$stmt = self::db()->prepare(
|
||||||
'SELECT metric_month, top3, top10, top20, top50, traffic, created_at, updated_at
|
'SELECT metric_month, top3, top10, top20, top50, traffic, indexed_pages, created_at, updated_at
|
||||||
FROM site_seo_metrics
|
FROM site_seo_metrics
|
||||||
WHERE site_id = :site_id
|
WHERE site_id = :site_id
|
||||||
ORDER BY metric_month DESC
|
ORDER BY metric_month DESC
|
||||||
@@ -70,7 +109,7 @@ class SiteSeoMetric extends Model
|
|||||||
public static function latestForSite(int $siteId): ?array
|
public static function latestForSite(int $siteId): ?array
|
||||||
{
|
{
|
||||||
$stmt = self::db()->prepare(
|
$stmt = self::db()->prepare(
|
||||||
'SELECT metric_month, top3, top10, top20, top50, traffic, updated_at
|
'SELECT metric_month, top3, top10, top20, top50, traffic, indexed_pages, updated_at
|
||||||
FROM site_seo_metrics
|
FROM site_seo_metrics
|
||||||
WHERE site_id = :site_id
|
WHERE site_id = :site_id
|
||||||
ORDER BY metric_month DESC
|
ORDER BY metric_month DESC
|
||||||
@@ -84,7 +123,7 @@ class SiteSeoMetric extends Model
|
|||||||
|
|
||||||
public static function latestForAllSites(string $sort = 'traffic', string $dir = 'desc'): array
|
public static function latestForAllSites(string $sort = 'traffic', string $dir = 'desc'): array
|
||||||
{
|
{
|
||||||
$allowedSort = ['top3', 'top10', 'top20', 'top50', 'traffic', 'metric_month', 'updated_at', 'site_name'];
|
$allowedSort = ['top3', 'top10', 'top20', 'top50', 'traffic', 'indexed_pages', 'metric_month', 'updated_at', 'site_name'];
|
||||||
if (!in_array($sort, $allowedSort, true)) {
|
if (!in_array($sort, $allowedSort, true)) {
|
||||||
$sort = 'traffic';
|
$sort = 'traffic';
|
||||||
}
|
}
|
||||||
@@ -109,6 +148,7 @@ class SiteSeoMetric extends Model
|
|||||||
m.top20,
|
m.top20,
|
||||||
m.top50,
|
m.top50,
|
||||||
m.traffic,
|
m.traffic,
|
||||||
|
m.indexed_pages,
|
||||||
m.updated_at,
|
m.updated_at,
|
||||||
m.created_at,
|
m.created_at,
|
||||||
pm.metric_month AS prev_metric_month,
|
pm.metric_month AS prev_metric_month,
|
||||||
@@ -116,7 +156,8 @@ class SiteSeoMetric extends Model
|
|||||||
pm.top10 AS prev_top10,
|
pm.top10 AS prev_top10,
|
||||||
pm.top20 AS prev_top20,
|
pm.top20 AS prev_top20,
|
||||||
pm.top50 AS prev_top50,
|
pm.top50 AS prev_top50,
|
||||||
pm.traffic AS prev_traffic
|
pm.traffic AS prev_traffic,
|
||||||
|
pm.indexed_pages AS prev_indexed_pages
|
||||||
FROM sites s
|
FROM sites s
|
||||||
LEFT JOIN site_seo_metrics m
|
LEFT JOIN site_seo_metrics m
|
||||||
ON m.id = (
|
ON m.id = (
|
||||||
|
|||||||
118
src/Services/DataForSeoService.php
Normal file
118
src/Services/DataForSeoService.php
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Core\Config;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
|
||||||
|
class DataForSeoService
|
||||||
|
{
|
||||||
|
private Client $http;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$timeout = (float) Config::getDbSetting(
|
||||||
|
'dataforseo_timeout_seconds',
|
||||||
|
Config::get('DATAFORSEO_TIMEOUT_SECONDS', '30')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->http = new Client([
|
||||||
|
'timeout' => max(5, $timeout),
|
||||||
|
'verify' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchIndexedPagesCount(string $domain): array
|
||||||
|
{
|
||||||
|
$baseUrl = rtrim((string) Config::getDbSetting(
|
||||||
|
'dataforseo_api_base',
|
||||||
|
Config::get('DATAFORSEO_API_BASE', 'https://api.dataforseo.com')
|
||||||
|
), '/');
|
||||||
|
$login = trim((string) Config::getDbSetting('dataforseo_login', Config::get('DATAFORSEO_LOGIN', '')));
|
||||||
|
$password = trim((string) Config::getDbSetting('dataforseo_password', Config::get('DATAFORSEO_PASSWORD', '')));
|
||||||
|
$locationCode = (int) Config::getDbSetting('dataforseo_location_code', Config::get('DATAFORSEO_LOCATION_CODE', '2616'));
|
||||||
|
$languageCode = strtolower(trim((string) Config::getDbSetting(
|
||||||
|
'dataforseo_language_code',
|
||||||
|
Config::get('DATAFORSEO_LANGUAGE_CODE', 'pl')
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($login === '' || $password === '') {
|
||||||
|
throw new \RuntimeException('Brak danych logowania DataForSEO (login/haslo).');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($locationCode <= 0) {
|
||||||
|
$locationCode = 2616;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($languageCode === '') {
|
||||||
|
$languageCode = 'pl';
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [[
|
||||||
|
'keyword' => 'site:' . $domain,
|
||||||
|
'location_code' => $locationCode,
|
||||||
|
'language_code' => $languageCode,
|
||||||
|
'device' => 'desktop',
|
||||||
|
'os' => 'windows',
|
||||||
|
'depth' => 10,
|
||||||
|
]];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->http->post($baseUrl . '/v3/serp/google/organic/live/regular', [
|
||||||
|
'headers' => [
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'User-Agent' => 'BackPRO/1.0',
|
||||||
|
],
|
||||||
|
'auth' => [$login, $password],
|
||||||
|
'json' => $payload,
|
||||||
|
]);
|
||||||
|
} catch (GuzzleException $e) {
|
||||||
|
throw new \RuntimeException('Blad pobierania statystyk DataForSEO: ' . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = (string) $response->getBody();
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new \RuntimeException('DataForSEO zwrocil niepoprawny JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'indexed_pages' => $this->extractIndexedPages($decoded),
|
||||||
|
'payload' => $raw,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractIndexedPages(array $payload): int
|
||||||
|
{
|
||||||
|
$tasks = $payload['tasks'] ?? null;
|
||||||
|
if (!is_array($tasks) || empty($tasks) || !is_array($tasks[0] ?? null)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$task = $tasks[0];
|
||||||
|
$result = null;
|
||||||
|
if (is_array($task['result'] ?? null) && !empty($task['result'][0]) && is_array($task['result'][0])) {
|
||||||
|
$result = $task['result'][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($result)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer dedicated total counters from SERP response.
|
||||||
|
if (isset($result['se_results_count'])) {
|
||||||
|
return max(0, (int) $result['se_results_count']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($result['items_count'])) {
|
||||||
|
return max(0, (int) $result['items_count']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,10 +10,12 @@ use App\Models\SiteSeoMetric;
|
|||||||
class SiteSeoSyncService
|
class SiteSeoSyncService
|
||||||
{
|
{
|
||||||
private SemstormService $semstorm;
|
private SemstormService $semstorm;
|
||||||
|
private DataForSeoService $dataforseo;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->semstorm = new SemstormService();
|
$this->semstorm = new SemstormService();
|
||||||
|
$this->dataforseo = new DataForSeoService();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncSite(array $site, ?\DateTimeImmutable $month = null, bool $force = false): array
|
public function syncSite(array $site, ?\DateTimeImmutable $month = null, bool $force = false): array
|
||||||
@@ -24,53 +26,96 @@ class SiteSeoSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$metricMonth = ($month ?? new \DateTimeImmutable('first day of this month'))->format('Y-m-01');
|
$metricMonth = ($month ?? new \DateTimeImmutable('first day of this month'))->format('Y-m-01');
|
||||||
|
$existing = SiteSeoMetric::findForMonth($siteId, $metricMonth);
|
||||||
|
$hasMonth = $existing !== null;
|
||||||
|
$hasDataforseo = SiteSeoMetric::hasDataforseoForMonth($siteId, $metricMonth);
|
||||||
|
|
||||||
if (!$force && SiteSeoMetric::existsForMonth($siteId, $metricMonth)) {
|
if (!$force && $hasMonth && $hasDataforseo) {
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'message' => 'Dane SEO dla tego miesiaca juz istnieja.',
|
'message' => 'Dane SEO dla tego miesiaca juz istnieja (SEMSTORM + DataForSEO).',
|
||||||
'metric_month' => $metricMonth,
|
'metric_month' => $metricMonth,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$domain = $this->resolveDomain($site);
|
$metricsToSave = [
|
||||||
if ($domain === '') {
|
'top3' => (int) ($existing['top3'] ?? 0),
|
||||||
return ['success' => false, 'status' => 'error', 'message' => 'Brak domeny SEMSTORM dla strony.'];
|
'top10' => (int) ($existing['top10'] ?? 0),
|
||||||
}
|
'top20' => (int) ($existing['top20'] ?? 0),
|
||||||
|
'top50' => (int) ($existing['top50'] ?? 0),
|
||||||
|
'traffic' => (int) ($existing['traffic'] ?? 0),
|
||||||
|
'indexed_pages' => (int) ($existing['indexed_pages'] ?? 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
$payload = $this->extractPayloadParts($existing['source_payload'] ?? null);
|
||||||
|
$semstormDomain = $this->resolveDomain($site);
|
||||||
|
$dataforseoDomain = $this->resolveDataforseoDomain($site, $semstormDomain);
|
||||||
|
$errors = [];
|
||||||
|
$didSync = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$metrics = $this->semstorm->fetchDomainMetrics($domain, new \DateTimeImmutable($metricMonth));
|
if ($force || !$hasMonth) {
|
||||||
SiteSeoMetric::upsertMonthly($siteId, $metricMonth, $metrics, $metrics['payload'] ?? null);
|
if ($semstormDomain !== '') {
|
||||||
|
$semstormMetrics = $this->semstorm->fetchDomainMetrics($semstormDomain, new \DateTimeImmutable($metricMonth));
|
||||||
|
$metricsToSave['top3'] = (int) ($semstormMetrics['top3'] ?? 0);
|
||||||
|
$metricsToSave['top10'] = (int) ($semstormMetrics['top10'] ?? 0);
|
||||||
|
$metricsToSave['top20'] = (int) ($semstormMetrics['top20'] ?? 0);
|
||||||
|
$metricsToSave['top50'] = (int) ($semstormMetrics['top50'] ?? 0);
|
||||||
|
$metricsToSave['traffic'] = (int) ($semstormMetrics['traffic'] ?? 0);
|
||||||
|
$payload['semstorm'] = is_string($semstormMetrics['payload'] ?? null) ? $semstormMetrics['payload'] : null;
|
||||||
|
$didSync = true;
|
||||||
|
} else {
|
||||||
|
$errors[] = 'Brak domeny SEMSTORM.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($force || !$hasDataforseo) {
|
||||||
|
if ($dataforseoDomain !== '') {
|
||||||
|
$dataforseo = $this->dataforseo->fetchIndexedPagesCount($dataforseoDomain);
|
||||||
|
$metricsToSave['indexed_pages'] = max(0, (int) ($dataforseo['indexed_pages'] ?? 0));
|
||||||
|
$payload['dataforseo'] = is_string($dataforseo['payload'] ?? null) ? $dataforseo['payload'] : null;
|
||||||
|
$didSync = true;
|
||||||
|
} else {
|
||||||
|
$errors[] = 'Brak domeny DataForSEO.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$didSync) {
|
||||||
|
throw new \RuntimeException(!empty($errors) ? implode(' ', $errors) : 'Brak danych do synchronizacji.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
SiteSeoMetric::upsertMonthly($siteId, $metricMonth, $metricsToSave, is_string($payloadJson) ? $payloadJson : null);
|
||||||
|
|
||||||
Logger::info(
|
Logger::info(
|
||||||
"SEMSTORM sync OK: site_id={$siteId}, domain={$domain}, month={$metricMonth}",
|
"SEO sync OK: site_id={$siteId}, semstorm_domain={$semstormDomain}, dataforseo_domain={$dataforseoDomain}, month={$metricMonth}",
|
||||||
'semstorm'
|
'semstorm'
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'status' => 'saved',
|
'status' => 'saved',
|
||||||
'message' => 'Zapisano dane SEO z SEMSTORM.',
|
'message' => 'Zapisano/uzupelniono dane SEO (SEMSTORM + DataForSEO).',
|
||||||
'metric_month' => $metricMonth,
|
'metric_month' => $metricMonth,
|
||||||
'metrics' => [
|
'metrics' => $metricsToSave,
|
||||||
'top3' => (int) ($metrics['top3'] ?? 0),
|
|
||||||
'top10' => (int) ($metrics['top10'] ?? 0),
|
|
||||||
'top20' => (int) ($metrics['top20'] ?? 0),
|
|
||||||
'top50' => (int) ($metrics['top50'] ?? 0),
|
|
||||||
'traffic' => (int) ($metrics['traffic'] ?? 0),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Logger::error(
|
if (str_contains($e->getMessage(), 'DataForSEO')) {
|
||||||
"SEMSTORM sync FAIL: site_id={$siteId}, domain={$domain}, month={$metricMonth}, error={$e->getMessage()}",
|
Logger::warning(
|
||||||
|
"DataForSEO sync WARN: site_id={$siteId}, semstorm_domain={$semstormDomain}, dataforseo_domain={$dataforseoDomain}, month={$metricMonth}, error={$e->getMessage()}",
|
||||||
|
'semstorm'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Logger::info(
|
||||||
|
"SEO sync FAIL: site_id={$siteId}, semstorm_domain={$semstormDomain}, dataforseo_domain={$dataforseoDomain}, month={$metricMonth}, error={$e->getMessage()}",
|
||||||
'semstorm'
|
'semstorm'
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => 'Blad pobierania SEMSTORM: ' . $e->getMessage(),
|
'message' => 'Blad synchronizacji SEO: ' . $e->getMessage(),
|
||||||
'metric_month' => $metricMonth,
|
'metric_month' => $metricMonth,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -95,4 +140,36 @@ class SiteSeoSyncService
|
|||||||
|
|
||||||
return strtolower($host);
|
return strtolower($host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveDataforseoDomain(array $site, string $fallbackDomain): string
|
||||||
|
{
|
||||||
|
$manual = trim((string) ($site['dataforseo_domain'] ?? ''));
|
||||||
|
if ($manual !== '') {
|
||||||
|
return strtolower($manual);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallbackDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractPayloadParts($sourcePayload): array
|
||||||
|
{
|
||||||
|
$default = ['semstorm' => null, 'dataforseo' => null];
|
||||||
|
$raw = trim((string) $sourcePayload);
|
||||||
|
if ($raw === '') {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return [
|
||||||
|
'semstorm' => $raw,
|
||||||
|
'dataforseo' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'semstorm' => is_string($decoded['semstorm'] ?? null) ? $decoded['semstorm'] : null,
|
||||||
|
'dataforseo' => is_string($decoded['dataforseo'] ?? null) ? $decoded['dataforseo'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('top20')) ?>">TOP20<?= htmlspecialchars($sortMark('top20')) ?></a></th>
|
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('top20')) ?>">TOP20<?= htmlspecialchars($sortMark('top20')) ?></a></th>
|
||||||
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('top50')) ?>">TOP50<?= htmlspecialchars($sortMark('top50')) ?></a></th>
|
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('top50')) ?>">TOP50<?= htmlspecialchars($sortMark('top50')) ?></a></th>
|
||||||
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('traffic')) ?>">Ruch<?= htmlspecialchars($sortMark('traffic')) ?></a></th>
|
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('traffic')) ?>">Ruch<?= htmlspecialchars($sortMark('traffic')) ?></a></th>
|
||||||
|
<th class="text-nowrap"><a class="link-dark text-decoration-none" href="<?= htmlspecialchars($sortLink('indexed_pages')) ?>">Zaindeksowane<?= htmlspecialchars($sortMark('indexed_pages')) ?></a></th>
|
||||||
<th>Aktualizacja</th>
|
<th>Aktualizacja</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -59,7 +60,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($rows)): ?>
|
<?php if (empty($rows)): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="10" class="text-center text-muted py-4">Brak danych SEO.</td>
|
<td colspan="11" class="text-center text-muted py-4">Brak danych SEO.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($rows as $row): ?>
|
<?php foreach ($rows as $row): ?>
|
||||||
@@ -128,6 +129,14 @@
|
|||||||
-
|
-
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
<?php if (!empty($row['metric_month'])): ?>
|
||||||
|
<?= (int) ($row['indexed_pages'] ?? 0) ?>
|
||||||
|
<small><?= $formatDelta($row['indexed_pages'] ?? 0, $row['prev_indexed_pages'] ?? null) ?></small>
|
||||||
|
<?php else: ?>
|
||||||
|
-
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
<?php if (!empty($row['updated_at'])): ?>
|
<?php if (!empty($row['updated_at'])): ?>
|
||||||
<?= htmlspecialchars(date('d.m.Y H:i', strtotime((string) $row['updated_at']))) ?>
|
<?= htmlspecialchars(date('d.m.Y H:i', strtotime((string) $row['updated_at']))) ?>
|
||||||
|
|||||||
@@ -112,6 +112,46 @@
|
|||||||
value="<?= htmlspecialchars($settings['semstorm_timeout_seconds']) ?>" min="5" max="120">
|
value="<?= htmlspecialchars($settings['semstorm_timeout_seconds']) ?>" min="5" max="120">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h5 class="mb-3 mt-4 border-bottom pb-2">DataForSEO (indeksacja domeny)</h5>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="dataforseo_login" class="form-label">Login DataForSEO</label>
|
||||||
|
<input type="text" class="form-control" id="dataforseo_login" name="dataforseo_login"
|
||||||
|
value="<?= htmlspecialchars($settings['dataforseo_login']) ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="dataforseo_password" class="form-label">Haslo DataForSEO</label>
|
||||||
|
<input type="password" class="form-control" id="dataforseo_password" name="dataforseo_password"
|
||||||
|
value="<?= htmlspecialchars($settings['dataforseo_password']) ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="dataforseo_api_base" class="form-label">Bazowy URL API</label>
|
||||||
|
<input type="text" class="form-control" id="dataforseo_api_base" name="dataforseo_api_base"
|
||||||
|
value="<?= htmlspecialchars($settings['dataforseo_api_base']) ?>"
|
||||||
|
placeholder="https://api.dataforseo.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="dataforseo_location_code" class="form-label">Location code</label>
|
||||||
|
<input type="number" class="form-control" id="dataforseo_location_code" name="dataforseo_location_code"
|
||||||
|
value="<?= htmlspecialchars($settings['dataforseo_location_code']) ?>" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="dataforseo_language_code" class="form-label">Language code</label>
|
||||||
|
<input type="text" class="form-control" id="dataforseo_language_code" name="dataforseo_language_code"
|
||||||
|
value="<?= htmlspecialchars($settings['dataforseo_language_code']) ?>" placeholder="pl">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="dataforseo_timeout_seconds" class="form-label">Timeout API DataForSEO (sekundy)</label>
|
||||||
|
<input type="number" class="form-control" id="dataforseo_timeout_seconds" name="dataforseo_timeout_seconds"
|
||||||
|
value="<?= htmlspecialchars($settings['dataforseo_timeout_seconds']) ?>" min="5" max="120">
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button>
|
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
<input type="text" class="form-control" id="semstorm_domain" name="semstorm_domain" placeholder="example.com">
|
<input type="text" class="form-control" id="semstorm_domain" name="semstorm_domain" placeholder="example.com">
|
||||||
<div class="form-text">Jeśli puste, system użyje hosta z URL WordPressa.</div>
|
<div class="form-text">Jeśli puste, system użyje hosta z URL WordPressa.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="dataforseo_domain" class="form-label">Domena DataForSEO (opcjonalnie)</label>
|
||||||
|
<input type="text" class="form-control" id="dataforseo_domain" name="dataforseo_domain" placeholder="example.com">
|
||||||
|
<div class="form-text">Jesli puste, system uzyje domeny SEMSTORM lub hosta z URL.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="api_user" class="form-label">Użytkownik API (WordPress)</label>
|
<label for="api_user" class="form-label">Użytkownik API (WordPress)</label>
|
||||||
|
|||||||
@@ -36,6 +36,12 @@
|
|||||||
<input type="text" class="form-control" id="semstorm_domain" name="semstorm_domain" value="<?= htmlspecialchars($site['semstorm_domain'] ?? '') ?>" placeholder="example.com">
|
<input type="text" class="form-control" id="semstorm_domain" name="semstorm_domain" value="<?= htmlspecialchars($site['semstorm_domain'] ?? '') ?>" placeholder="example.com">
|
||||||
<div class="form-text">Jeśli puste, system użyje hosta z URL WordPressa.</div>
|
<div class="form-text">Jeśli puste, system użyje hosta z URL WordPressa.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="dataforseo_domain" class="form-label">Domena DataForSEO (opcjonalnie)</label>
|
||||||
|
<input type="text" class="form-control" id="dataforseo_domain" name="dataforseo_domain" value="<?= htmlspecialchars($site['dataforseo_domain'] ?? '') ?>" placeholder="example.com">
|
||||||
|
<div class="form-text">Jesli puste, system uzyje domeny SEMSTORM lub hosta z URL.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="api_user" class="form-label">Użytkownik API</label>
|
<label for="api_user" class="form-label">Użytkownik API</label>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<div class="card h-100">
|
<div class="card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0">Widocznosc SEO (SEMSTORM)</h5>
|
<h5 class="mb-0">Widocznosc SEO (SEMSTORM)</h5>
|
||||||
<form method="post" action="/sites/<?= (int) $site['id'] ?>/seo/sync" data-confirm="Pobrac i nadpisac dane SEO dla biezacego miesiaca?">
|
<form method="post" action="/sites/<?= (int) $site['id'] ?>/seo/sync" data-confirm="Pobrac i nadpisac dane SEO dla biezacego miesiaca?">
|
||||||
@@ -25,7 +25,26 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<canvas id="seoVisibilityChart" height="320"></canvas>
|
<div class="position-relative" style="height: 340px;">
|
||||||
|
<canvas id="seoVisibilityChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4 border-secondary-subtle">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0">Zaindeksowane strony (DataForSEO)</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if (!empty($seoLatest)): ?>
|
||||||
|
<div class="display-6 fw-semibold mb-1"><?= (int) ($seoLatest['indexed_pages'] ?? 0) ?></div>
|
||||||
|
<p class="small text-muted mb-0">
|
||||||
|
Ostatnia aktualizacja miesieczna:
|
||||||
|
<?= htmlspecialchars(date('m.Y', strtotime((string) $seoLatest['metric_month']))) ?>
|
||||||
|
</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="text-muted small mb-0">Brak danych o indeksacji. Uzyj przycisku "Synchronizuj teraz".</p>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,6 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="small mb-2"><strong>Ruch:</strong> <?= (int) $seoLatest['traffic'] ?></p>
|
<p class="small mb-2"><strong>Ruch:</strong> <?= (int) $seoLatest['traffic'] ?></p>
|
||||||
|
<p class="small mb-2"><strong>Zaindeksowane strony:</strong> <?= (int) ($seoLatest['indexed_pages'] ?? 0) ?></p>
|
||||||
<p class="small text-muted mb-0">
|
<p class="small text-muted mb-0">
|
||||||
Ostatni zapis: <?= htmlspecialchars(date('d.m.Y H:i', strtotime((string) $seoLatest['updated_at']))) ?><br>
|
Ostatni zapis: <?= htmlspecialchars(date('d.m.Y H:i', strtotime((string) $seoLatest['updated_at']))) ?><br>
|
||||||
Miesiac: <?= htmlspecialchars(date('m.Y', strtotime((string) $seoLatest['metric_month']))) ?>
|
Miesiac: <?= htmlspecialchars(date('m.Y', strtotime((string) $seoLatest['metric_month']))) ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user