This commit is contained in:
2026-04-09 11:44:45 +02:00
parent 7ff7ff3a92
commit 61c66bfd55
79 changed files with 13667 additions and 144 deletions

6
.env
View File

@@ -15,4 +15,8 @@ APP_URL=https://backpro.projectpro.pl
APP_SECRET=bP7x9kR3mW2vN5qT8sY1 APP_SECRET=bP7x9kR3mW2vN5qT8sY1
PUBLISH_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1 PUBLISH_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1
SEO_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1 SEO_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1
statlink_url = https://statlink.pl/
statlink_login = pyziak84@gmail.com
statlink_password = Legia1916

28
.paul/PROJECT.md Normal file
View File

@@ -0,0 +1,28 @@
# Project: backPRO
## Description
Panel webowy do zarządzania siecią stron zapleczowych SEO — generowanie artykułów AI, publikacja na WordPress, linkowanie wewnętrzne.
## Core Value
Zautomatyzowane tworzenie zaplecza SEO — budowanie sieci stron wspierających pozycjonowanie.
## Requirements
### Must Have
- [To be defined during planning]
### Should Have
- [To be defined during planning]
### Nice to Have
- [To be defined during planning]
## Constraints
- [To be identified during planning]
## Success Criteria
- Zautomatyzowane tworzenie zaplecza SEO jest osiągnięte
- [To be refined during planning]
---
*Created: 2026-04-09*

45
.paul/ROADMAP.md Normal file
View File

@@ -0,0 +1,45 @@
# Roadmap: backPRO
## Overview
Panel webowy do zarządzania siecią stron zapleczowych SEO — od generowania artykułów AI, przez publikację na WordPress, po linkowanie wewnętrzne, monitoring SEO i automatyczne linkowanie zewnętrzne via StatLink.pl.
## Current Milestone
**v0.1 Initial Release** (v0.1.0)
Status: In progress
Phases: 1 of 2 complete
## Phases
| Phase | Name | Plans | Status | Completed |
|-------|------|-------|--------|-----------|
| 1 | StatLink Autolinking | 1 | Complete ✓ | 2026-04-09 |
| 2 | Admin Panel Upgrade | 1 | Planning | - |
## Phase Details
### Phase 1: StatLink Autolinking
**Goal:** Automatyczne dodawanie opublikowanych artykułów do StatLink.pl na 60 dni i usuwanie po wygaśnięciu.
**Depends on:** Nothing
**Status:** Complete ✓ (2026-04-09)
**Plans:**
- [x] 01-01: StatLinkService + migracja + cron endpoint
### Phase 2: Admin Panel Upgrade
**Goal:** Panel migracji bazy danych, nowoczesny sidebar (jak orderPRO), lista publikacji StatLink.
**Depends on:** Phase 1 (tabela statlink_links)
**Research:** Done (analiza orderPRO: Migrator, sidebar, CSS)
**Scope:**
- Migrator engine (port z orderPRO) + panel /settings/database
- Nowy sidebar z grupami, ikonami SVG, collapse
- Widok /statlink z listą linkowanych artykułów
**Plans:**
- [ ] 02-01: Migrator + sidebar + widok StatLink
---
*Roadmap created: 2026-04-09*
*Last updated: 2026-04-09*

65
.paul/STATE.md Normal file
View File

@@ -0,0 +1,65 @@
# Project State
## Project Reference
See: .paul/PROJECT.md (updated 2026-04-09)
**Core value:** Zautomatyzowane tworzenie zaplecza SEO
**Current focus:** Phase 2 — Admin Panel Upgrade
## Current Position
Milestone: v0.1 Initial Release
Phase: 2 of 2 (Admin Panel Upgrade) — Planning
Plan: 02-01 created, awaiting approval
Status: PLAN created, ready for APPLY
Last activity: 2026-04-09 — Phase 1 UNIFY completed, bugfixes applied
Progress:
- Milestone: [████░░░░░░] 40%
- Phase 1: [██████████] 100% ✓
- Phase 2: [░░░░░░░░░░] 0%
## Loop Position
**Phase 1 (StatLink Auto-Linking):**
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Phase 1 complete]
```
**Phase 2 (Admin Panel Upgrade):**
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ○ ○ [Plan 02-01 created, awaiting approval]
```
## Accumulated Context
### Decisions
- StatLink.pl integration via Guzzle HTTP (cookie-based session)
- Login field name: "zaloguj" (not "loguj"), needs GET homepage first
- ilosc_dziennie: 0.02 (1 co 2 dni)
- Migrator: port z orderPRO z lock mechanism
- Sidebar: adaptacja orderPRO design do backPRO
- Anchor sanitization: Polish diacritics must be transliterated to ASCII for StatLink
- json_encode needs JSON_INVALID_UTF8_SUBSTITUTE when outputting scraped HTML
- OPcache reset required after FTP deploy for changes to take effect
- StatLink timeouts: connect_timeout=60s, timeout=120s, PHP set_time_limit=300s
### Deferred Issues
- StatLink: no max retry count for permanently failing links (could block queue)
- StatLink: cron not yet configured on server (only manual token URL trigger)
### Blockers/Concerns
None.
## Session Continuity
Last session: 2026-04-09
Stopped at: Phase 1 UNIFY complete, Phase 2 Plan 02-01 awaiting approval
Next action: Review and approve plan 02-01, then run /paul:apply
Resume file: .paul/phases/02-admin-panel-upgrade/02-01-PLAN.md
---
*STATE.md — Updated after every significant action*

View File

@@ -0,0 +1,19 @@
# 2026-04-09
## Co zrobiono
- [Phase 1, Plan 01] Zamknięcie fazy StatLink Auto-Linking
- Naprawiono sanityzację anchorów — polskie znaki diakrytyczne → ASCII (StatLink wymaga ASCII)
- Naprawiono puste odpowiedzi JSON — JSON_INVALID_UTF8_SUBSTITUTE dla scraperowanego HTML
- Naprawiono wykrywanie statlink_id — findLinkIdInHtml bezpośrednio z odpowiedzi formularza
- Zwiększono timeouty (120s request, 300s PHP execution) dla wolnego StatLink
- Dodano diagnostyki logowania we wszystkich ścieżkach błędu
- Zresetowano 37 failed linków do ponownej próby z poprawionymi anchorami
## Zmienione pliki
- `src/Services/StatLinkService.php`
- `src/Controllers/StatLinkController.php`
- `src/Core/Controller.php`
- `cron/statlink.php`
- `index.php` (tymczasowo debug, przywrócono oryginał)

4
.paul/docs/API.md Normal file
View File

@@ -0,0 +1,4 @@
# API
> Endpointy, kontrakty request/response, autentykacja.
> Uzupełnij gdy projekt będzie udostępniał API.

View File

@@ -0,0 +1,4 @@
# Architecture
> Struktura klas, modułów, przepływów i zależności w projekcie.
> Uzupełnij po pierwszej fazie implementacji.

4
.paul/docs/DB_SCHEMA.md Normal file
View File

@@ -0,0 +1,4 @@
# Database Schema
> Schemat bazy danych — tabele, kolumny, FK, indeksy.
> Uzupełnij gdy projekt będzie korzystał z bazy danych.

4
.paul/docs/DECISIONS.md Normal file
View File

@@ -0,0 +1,4 @@
# Architecture Decision Records
> Kluczowe decyzje techniczne i ich uzasadnienia.
> Dodawaj wpisy ręcznie gdy podejmujesz istotne decyzje architektoniczne.

4
.paul/docs/STACK.md Normal file
View File

@@ -0,0 +1,4 @@
# Stack
> Stack technologiczny, wersje, zależności, środowisko.
> Uzupełnij po inicjalizacji projektu.

View File

@@ -0,0 +1,4 @@
# Tech Changelog
> Chronologiczny log zmian technicznych — co i dlaczego.
> Aktualizowany automatycznie po każdej fazie PAUL.

4
.paul/docs/TODO.md Normal file
View File

@@ -0,0 +1,4 @@
# TODO
> Luźny parking pomysłów, rzeczy do sprawdzenia, rzeczy które mogą nigdy nie być wdrożone.
> Nie wymaga formalności — wrzucaj co chcesz.

View File

@@ -0,0 +1,129 @@
{"ts":"2026-04-08T22:12:17Z","tool":"Bash","cmd":"mkdir -p .paul/phases .paul/docs\",\"description\":\"Create PAUL directory structure\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isImage\":false,\"noOutputExpected\":","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:12:23Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\docs\\\\ARCHITECTURE.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:12:24Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\docs\\\\DB_SCHEMA.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:12:25Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\docs\\\\API.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:12:27Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\docs\\\\TECH_CHANGELOG.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:12:28Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\docs\\\\DECISIONS.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:12:29Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\docs\\\\STACK.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:12:31Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\docs\\\\TODO.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:13:53Z","tool":"Bash","cmd":"wc -l \"C:/visual studio code/projekty/backPRO/CLAUDE.md\" 2>/dev/null || echo \"NO_CLAUDE_MD\"\",\"description\":\"Check CLAUDE.md existence and size\"},\"tool_response\":{\"stdout\":\"NO_CLAUDE_MD\"","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:14:04Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:14:07Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:14:13Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:14:25Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\CLAUDE.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:22:44Z","tool":"Bash","cmd":"mkdir -p \"C:/visual studio code/projekty/backPRO/.paul/phases/01-statlink-autolinking\"\",\"description\":\"Create phase directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:23:46Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\phases\\\\01-statlink-autolinking\\\\01-01-PLAN.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:24:00Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:24:07Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:25:49Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\migrations\\\\013_statlink_tracking.sql","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:26:50Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:27:01Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\cron\\\\statlink.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:27:12Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Controllers\\\\StatLinkController.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:27:18Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\config\\\\routes.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:27:25Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/backPRO\" && php -l src/Services/StatLinkService.php && php -l src/Controllers/StatLinkController.php && php -l cron/statlink.php && php -l config/routes.php && ech","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:27:41Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:29:41Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\test_statlink_check.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:30:01Z","tool":"Bash","cmd":"rm \"C:/visual studio code/projekty/backPRO/test_statlink_check.php\"\",\"description\":\"Remove temporary test file\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isIma","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:31:42Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:31:47Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/backPRO\" && php -l src/Services/StatLinkService.php\",\"description\":\"Lint check updated StatLinkService\"},\"tool_response\":{\"stdout\":\"No syntax errors dete","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:33:51Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:33:57Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:34:02Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/backPRO\" && php -l src/Services/StatLinkService.php\",\"description\":\"Lint check StatLinkService\"},\"tool_response\":{\"stdout\":\"No syntax errors detected in ","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:36:11Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:36:18Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:36:27Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:36:39Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:36:45Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/backPRO\" && php -l src/Services/StatLinkService.php\",\"description\":\"Lint check\"},\"tool_response\":{\"stdout\":\"No syntax errors detected in src/Services/Sta","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:40:12Z","tool":"Bash","cmd":"mkdir -p \"C:/visual studio code/projekty/backPRO/.paul/phases/02-admin-panel-upgrade\"\",\"description\":\"Create phase 2 directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:41:12Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\phases\\\\02-admin-panel-upgrade\\\\02-01-PLAN.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:41:26Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:41:35Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:42:11Z","tool":"Bash","cmd":"mkdir -p \"C:/visual studio code/projekty/backPRO/src/Core/Database\"\",\"description\":\"Create Database directory for Migrator\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":f","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:42:31Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Core\\\\Database\\\\Migrator.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:42:37Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Controllers\\\\SettingsController.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:42:46Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Controllers\\\\SettingsController.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:43:01Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\templates\\\\settings\\\\database.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:43:12Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\config\\\\routes.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:43:39Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\templates\\\\layout\\\\sidebar.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:43:50Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\templates\\\\layout\\\\main.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:44:18Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\assets\\\\css\\\\app.css","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:44:32Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\assets\\\\js\\\\app.js","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:44:46Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Controllers\\\\StatLinkController.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:44:51Z","tool":"Bash","cmd":"mkdir -p \"C:/visual studio code/projekty/backPRO/templates/statlink\"\",\"description\":\"Create statlink templates directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":fal","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:45:16Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\templates\\\\statlink\\\\index.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:45:24Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/backPRO\" && php -l src/Core/Database/Migrator.php && php -l src/Controllers/SettingsController.php && php -l src/Controllers/StatLinkController.php && php -l templ","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:46:23Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\assets\\\\css\\\\app.css","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:48:40Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:48:56Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\cron\\\\statlink.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:49:08Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Controllers\\\\StatLinkController.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:52:43Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:55:08Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:55:15Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T22:55:20Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/backPRO\" && php -l src/Services/StatLinkService.php\",\"description\":\"Lint check\"},\"tool_response\":{\"stdout\":\"No syntax errors detected in src/Services/Sta","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T23:08:06Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T23:08:17Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Controllers\\\\StatLinkController.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T23:08:35Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T23:08:40Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T23:08:48Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-08T23:08:52Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T08:52:08Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T08:52:14Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T08:52:27Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T08:52:39Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T08:53:13Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T08:55:43Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T08:58:17Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Controllers\\\\StatLinkController.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T08:58:21Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\cron\\\\statlink.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T08:58:58Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && php -l src/Services/StatLinkService.php && php -l src/Controllers/StatLinkController.php\",\"description\":\"PHP syntax check on edited files\","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:01:53Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Controllers\\\\StatLinkController.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:03:51Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\public\\\\statlink-test.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:04:00Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && wc -c src/Controllers/StatLinkController.php src/Services/StatLinkService.php\",\"description\":\"Check local file sizes for comparison\"},\"t","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:05:02Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -v --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T src/Controllers/StatLinkController.php \"ftp://host7005","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:05:11Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T src/Services/StatLinkService.php \"ftp://host700513.hos","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:05:13Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T public/statlink-test.php \"ftp://host700513.hostido.net","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:06:23Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\index.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:06:33Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T index.php \"ftp://host700513.hostido.net.pl/public_html","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:06:58Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\statlink-test.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:07:06Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T statlink-test.php \"ftp://host700513.hostido.net.pl/pub","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:07:30Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\statlink-test.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:07:36Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T statlink-test.php \"ftp://host700513.hostido.net.pl/pub","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:08:25Z","tool":"Bash","cmd":"curl -s -D - \"https://backpro.projectpro.pl/statlink/token-run?token=***\" 2>&1\",\"timeout\":30000,\"description\":\"Raw HTTP response with headers\"},\"tool_response\":{\"stdout\":\"HTTP/1.1 200 OK","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:08:56Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Controllers\\\\StatLinkController.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:09:05Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T src/Controllers/StatLinkController.php \"ftp://host7005","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:09:30Z","tool":"Bash","cmd":"curl -s -D - \"https://backpro.projectpro.pl/statlink/token-run?token=***\" 2>&1\",\"timeout\":60000,\"description\":\"Test statlink endpoint after clean controller\"},\"tool_response\":{\"stdout\":\"","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:10:00Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Core\\\\Controller.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:10:17Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T src/Core/Controller.php \"ftp://host700513.hostido.net.","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:10:25Z","tool":"Bash","cmd":"curl -s \"https://backpro.projectpro.pl/statlink-test.php\" > /dev/null && curl -s \"https://backpro.projectpro.pl/statlink/token-run?token=***\" 2>&1\",\"timeout\":120000,\"description\":\"OPcache re","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:10:49Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:11:01Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T src/Services/StatLinkService.php \"ftp://host700513.hos","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:11:12Z","tool":"Bash","cmd":"curl -s \"https://backpro.projectpro.pl/statlink-test.php\" > /dev/null && curl -s \"https://backpro.projectpro.pl/statlink/token-run?token=***\" 2>&1\",\"timeout\":120000,\"description\":\"OPcache re","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:11:24Z","tool":"Bash","cmd":"curl -s \"https://backpro.projectpro.pl/statlink/token-run?token=***\" 2>&1\",\"timeout\":120000,\"description\":\"Run statlink again for next links\"},\"tool_response\":{\"stdout\":\"{\"success\":tru","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:11:37Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\index.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:11:44Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T index.php \"ftp://host700513.hostido.net.pl/public_html","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:11:47Z","tool":"Bash","cmd":"rm \"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\statlink-test.php\" \"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\public\\\\statlink-test.php\" 2>/dev/null; echo \"cleaned\"\",\"descrip","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:12:35Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\statlink-test.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:12:41Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T statlink-test.php \"ftp://host700513.hostido.net.pl/pub","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:12:47Z","tool":"Bash","cmd":"curl -s \"https://backpro.projectpro.pl/statlink-test.php\" 2>&1\",\"timeout\":30000,\"description\":\"Check DB for broken statlink IDs\"},\"tool_response\":{\"stdout\":\"{\\n \"broken_links\": [\\","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:13:10Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\statlink-test.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:13:19Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T statlink-test.php \"ftp://host700513.hostido.net.pl/pub","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:13:26Z","tool":"Bash","cmd":"curl -s \"https://backpro.projectpro.pl/statlink-test.php\" 2>&1\",\"timeout\":120000,\"description\":\"Run fix script for statlink_id -1\"},\"tool_response\":{\"stdout\":\"{\\n \"not_found\": [\\n","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:13:45Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\statlink-test.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:13:52Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T statlink-test.php \"ftp://host700513.hostido.net.pl/pub","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:14:39Z","tool":"Bash","cmd":"curl -s \"https://backpro.projectpro.pl/statlink-test.php\" 2>&1\",\"timeout\":120000,\"description\":\"Run reset for -1 statlink_id\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:14:44Z","tool":"Bash","cmd":"curl -s \"https://backpro.projectpro.pl/statlink-test.php\" 2>&1\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isImage\":false,\"noOutputExpected\":false},\"tool_use_id","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:14:51Z","tool":"Bash","cmd":"curl -s \"https://backpro.projectpro.pl/statlink-test.php\" 2>&1\",\"timeout\":120000},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isImage\":false,\"noOutputExpected\":f","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:14:57Z","tool":"Bash","cmd":"curl -v -s \"https://backpro.projectpro.pl/statlink-test.php\" 2>&1 | head -20\",\"timeout\":120000},\"tool_response\":{\"stdout\":\"* Host backpro.projectpro.pl:443 was resolved.\\r\\n* IPv6: (none)\","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:15:06Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\statlink-test.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:15:14Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T statlink-test.php \"ftp://host700513.hostido.net.pl/pub","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:15:24Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && curl -s --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -Q \"DELE /public_html/statlink-test.php\" \"ftp://host700513.hostido.net.pl","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:26:41Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:26:55Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\src\\\\Services\\\\StatLinkService.php","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:27:05Z","tool":"Bash","cmd":"cd \"C:\\\\visual studio code\\\\projekty\\\\backPRO\" && php -l src/Services/StatLinkService.php && curl -s --ftp-create-dirs --user \"www@backpro.projectpro.pl:WGnT4LEn6dLYKvDkXZdd\" -T src/Services","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:27:36Z","tool":"Bash","cmd":"curl -s \"https://backpro.projectpro.pl/statlink/token-run?token=***\" 2>&1\",\"timeout\":120000,\"description\":\"Test statlink with improved ID detection\"},\"tool_response\":{\"stdout\":\"{\"succes","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:42:18Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\phases\\\\01-statlink-autolinking\\\\01-01-SUMMARY.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:42:37Z","tool":"Bash","cmd":"mkdir -p \"C:/visual studio code/projekty/backPRO/.paul/changelog\"\",\"description\":\"Create changelog directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isImage","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:42:49Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\changelog\\\\2026-04-09.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:43:11Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:43:25Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:43:30Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/backPRO"}
{"ts":"2026-04-09T09:43:36Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\backPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/backPRO"}

View File

@@ -0,0 +1,249 @@
---
phase: 01-statlink-autolinking
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- migrations/013_statlink_tracking.sql
- src/Services/StatLinkService.php
- src/Controllers/SettingsController.php
- templates/settings/index.php
- .env
autonomous: true
delegation: off
---
<objective>
## Goal
Zbudować mechanizm automatycznego dodawania opublikowanych artykułów do StatLink.pl oraz ich automatycznego usuwania po 60 dniach.
## Purpose
Każdy opublikowany artykuł na stronach zapleczowych powinien automatycznie otrzymywać linkowanie w systemie StatLink.pl na okres 60 dni, co zwiększy efektywność pozycjonowania. Po 60 dniach link jest automatycznie usuwany, aby nie marnować punktów.
## Output
- `StatLinkService.php` — serwis PHP z logowaniem do StatLink via Guzzle (cookies), dodawaniem i usuwaniem linków
- Migracja SQL do śledzenia linków w StatLink (statlink_id, article_id, added_at, expires_at)
- Endpoint cron do automatycznego uruchamiania (dodaj nowe / usuń wygasłe)
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@src/Services/PublisherService.php
@src/Models/Article.php
@src/Controllers/SettingsController.php
@.env
## StatLink.pl Research (from browser exploration)
- Login: POST to https://statlink.pl/ with fields: email (textbox), haslo (textbox), submit ZALOGUJ
- Session: cookie-based (PHP session)
- Add link: POST to /148,twoje-linki#lista with fields:
- niepozwol: CSRF token (must be scraped from page)
- https: 1 (radio, 0=http, 1=https)
- link: URL without protocol (e.g. "example.com/article-slug")
- anchor: anchor text (article title or topic keyword)
- fraza_kluczowa1, fraza_kluczowa2, fraza_kluczowa3: (empty)
- wylacznosc: unchecked
- frazowy: unchecked
- tylko_https: unchecked
- min_ilosc_znakow: 0
- statrank_min: 0, statrank_max: 10
- semstorm_keywords_top_min: 0
- ilosc_dziennie: 0.14 (= 1 link co 2 dni)
- ilosc_max: 10
- ilosc_nofollow: 0
- max_ilosc_domena: (default 5)
- id_kategorie_multiple[]: all category values selected
- zapisz: DODAJ
- Delete link: POST to /148,twoje-linki#lista0 with fields:
- statlink_id: ID of the link
- usun: Usuń
- Category checkboxes: multiple id_kategorie_multiple[] values (all selected)
- NOWY LINK form is inside div#nowy_link2vis
- Each link row has Edytuj and Usuń buttons with statlink_id hidden field
</context>
<acceptance_criteria>
## AC-1: Login do StatLink
```gherkin
Given dane logowania w .env (statlink_url, statlink_login, statlink_password)
When StatLinkService wykonuje login via Guzzle z CookieJar
Then sesja jest utrzymana i kolejne requesty zwracają stronę zalogowanego użytkownika
```
## AC-2: Dodawanie linku do StatLink
```gherkin
Given opublikowany artykuł z wp_post_url i tytułem
When StatLinkService::addLink() jest wywołane
Then link zostaje dodany w StatLink.pl z prawidłowymi parametrami (anchor=tytuł/keyword, ilosc_dziennie=0.14, ilosc_max=10, wszystkie kategorie)
And statlink_id zostaje zapisany w tabeli statlink_links
```
## AC-3: Usuwanie wygasłych linków
```gherkin
Given link w tabeli statlink_links z expires_at < NOW()
When StatLinkService::removeExpiredLinks() jest wywołane
Then link zostaje usunięty ze StatLink.pl via POST z usun
And rekord w tabeli statlink_links zostaje oznaczony jako removed
```
## AC-4: Cron endpoint
```gherkin
Given endpoint /cron/statlink z tokenem autoryzacyjnym
When endpoint jest wywołany
Then nowe opublikowane artykuły (bez wpisu w statlink_links) dostają linki w StatLink
And wygasłe linki (expires_at < NOW()) są usuwane ze StatLink
```
## AC-5: Tracking w bazie danych
```gherkin
Given tabela statlink_links
When artykuł dostaje link w StatLink
Then zapisywany jest: article_id, site_id, statlink_id, anchor, added_at, expires_at (added_at + 60 dni), status
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migracja SQL + Model śledzenia StatLink</name>
<files>migrations/013_statlink_tracking.sql</files>
<action>
Utworzyć migrację tworzącą tabelę statlink_links:
- id INT AUTO_INCREMENT PRIMARY KEY
- article_id INT NOT NULL (FK do articles)
- site_id INT NOT NULL (FK do sites)
- statlink_id INT NULL (ID linku w systemie StatLink — parsowany z odpowiedzi)
- anchor VARCHAR(500) NOT NULL
- link_url VARCHAR(500) NOT NULL
- added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
- expires_at DATETIME NOT NULL (added_at + 60 dni)
- removed_at DATETIME NULL
- status ENUM('active', 'expired', 'removed', 'failed') DEFAULT 'active'
- error_message TEXT NULL
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
Indeksy: (article_id), (status, expires_at), (site_id)
</action>
<verify>SQL jest poprawny składniowo, tabela zawiera wszystkie kolumny</verify>
<done>AC-5 satisfied: tabela statlink_links gotowa do śledzenia linków</done>
</task>
<task type="auto">
<name>Task 2: StatLinkService — login, dodawanie, usuwanie linków</name>
<files>src/Services/StatLinkService.php</files>
<action>
Utworzyć StatLinkService z metodami:
1. **login()** — POST do statlink.pl z email+haslo, utrzymuj CookieJar w Guzzle
- Sprawdź odpowiedź czy zawiera "Zalogowano"
- Rzuć wyjątek jeśli login się nie powiedzie
2. **addLink(array $article, string $anchor)** — dodaje link do StatLink:
- Najpierw GET /148,twoje-linki aby pobrać token CSRF (pole "niepozwol" — regex z HTML)
- POST do /148,twoje-linki#lista z parametrami:
- niepozwol: token z GET
- https: 1 (jeśli URL artykułu jest HTTPS) lub 0
- link: URL artykułu bez protokołu (np. "domena.pl/slug")
- anchor: tytuł artykułu lub keyword tematu (naprzemiennie)
- fraza_kluczowa1/2/3: puste
- ilosc_dziennie: 0.14
- ilosc_max: 10
- ilosc_nofollow: 0
- statrank_min: 0, statrank_max: 10
- id_kategorie_multiple[]: wszystkie kategorie (pobrać listę z HTML)
- zapisz: DODAJ
- Parsuj statlink_id z odpowiedzi (szukaj nowego ID w tabeli linków)
- Return statlink_id lub null
3. **removeLink(int $statlinkId)** — usuwa link ze StatLink:
- POST do /148,twoje-linki#lista0 z statlink_id + usun=Usuń
- Sprawdź czy usunięcie się powiodło
4. **getExistingLinkIds()** — parsuje listę linków z /148,twoje-linki
- Zwraca tablicę statlink_id dla weryfikacji
5. **scrapeCategories()** — parsuje checkboxy kategorii z formularza
- Zwraca tablicę wartości id_kategorie_multiple[] do zaznaczenia
6. **processNewArticles()** — główna metoda:
- Pobierz opublikowane artykuły bez wpisu w statlink_links
- Zaloguj się do StatLink
- Dla każdego artykułu: addLink() + zapisz do statlink_links z expires_at = NOW + 60 dni
- Anchor naprzemiennie: tytuł artykułu / keyword tematu
7. **removeExpiredLinks()** — główna metoda usuwania:
- Pobierz linki z status='active' AND expires_at < NOW()
- Zaloguj się do StatLink
- Dla każdego: removeLink() + ustaw status='removed', removed_at=NOW()
Użyj GuzzleHttp\Client z CookieJar.
Loguj operacje przez Logger (kanał 'statlink').
Odporność: try-catch per link, nie przerywaj całego batch przy błędzie jednego.
Avoid: nie wysyłaj więcej niż 5 linków w jednym uruchomieniu crona (rate limiting).
</action>
<verify>Klasa kompiluje się bez błędów, metody mają prawidłowe sygnatury</verify>
<done>AC-1, AC-2, AC-3 satisfied: serwis loguje się, dodaje i usuwa linki</done>
</task>
<task type="auto">
<name>Task 3: Cron endpoint + integracja z routerem</name>
<files>src/Controllers/SettingsController.php, src/Core/Router.php</files>
<action>
Dodać endpoint /cron/statlink w routerze (wzorowany na istniejących cron endpointach):
- Walidacja tokenu (SEO_TRIGGER_TOKEN lub nowy STATLINK_TRIGGER_TOKEN)
- Wywołanie StatLinkService::processNewArticles() — dodaj nowe
- Wywołanie StatLinkService::removeExpiredLinks() — usuń wygasłe
- Zwróć JSON z podsumowaniem (added: N, removed: N, errors: N)
Sprawdź jak działają istniejące cron endpointy w projekcie i zastosuj ten sam wzorzec.
</action>
<verify>Endpoint /cron/statlink odpowiada JSON-em z podsumowaniem</verify>
<done>AC-4 satisfied: cron endpoint do automatycznego zarządzania linkami StatLink</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- src/Services/PublisherService.php (nie modyfikuj flow publikacji)
- src/Models/Article.php (nie zmieniaj istniejących metod)
- migrations/001-012 (istniejące migracje niemodyfikowalne)
- src/Services/InternalLinkService.php (osobny mechanizm linkowania)
## SCOPE LIMITS
- Ten plan NIE integruje StatLink z procesem publikacji (to osobny cron)
- Nie dodajemy UI do zarządzania StatLink w panelu backPRO (może w przyszłości)
- Nie modyfikujemy istniejących endpointów cron
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Migracja 013 tworzy tabelę statlink_links
- [ ] StatLinkService loguje się do statlink.pl (test ręczny)
- [ ] StatLinkService dodaje link (test ręczny z jednym artykułem)
- [ ] StatLinkService usuwa link (test ręczny)
- [ ] Endpoint /cron/statlink zwraca JSON
- [ ] Logger zapisuje operacje na kanale 'statlink'
- [ ] Nie więcej niż 5 linków dodanych per uruchomienie crona
</verification>
<success_criteria>
- Wszystkie taski wykonane
- StatLinkService działa end-to-end (login → add → track → remove po 60 dniach)
- Endpoint cron działa z tokenem
- Brak błędów w istniejącej funkcjonalności
</success_criteria>
<output>
After completion, create `.paul/phases/01-statlink-autolinking/01-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,164 @@
---
phase: 01-statlink-autolinking
plan: 01
subsystem: seo
tags: [statlink, guzzle, scraping, cron, seo-linkbuilding]
requires:
- phase: none
provides: published articles with wp_post_url
provides:
- StatLink.pl auto-linking service
- Cron endpoint for link management
- Link lifecycle tracking (add → expire → remove)
affects: [admin-panel, monitoring]
tech-stack:
added: [guzzle-cookiejar, html-scraping]
patterns: [service-class-per-integration, cron-token-auth, diagnostic-logging]
key-files:
created:
- src/Services/StatLinkService.php
- src/Controllers/StatLinkController.php
- cron/statlink.php
- migrations/013_statlink_tracking.sql
modified:
- config/routes.php
- src/Core/Controller.php
key-decisions:
- "Cookie-based Guzzle session for StatLink (no API available)"
- "Anchor sanitization: Polish diacritics → ASCII (StatLink restriction)"
- "MAX_LINKS_PER_RUN=1 to avoid rate limiting"
- "ilosc_dziennie=0.02, link lifetime 60 days"
- "json_encode with JSON_INVALID_UTF8_SUBSTITUTE for scraped HTML safety"
patterns-established:
- "Diagnostic array pattern for debugging external service integrations"
- "FTP deploy requires OPcache reset for changes to take effect"
duration: ~4h (initial build) + 2h (bugfix session 2026-04-09)
started: 2026-04-08
completed: 2026-04-09T11:15:00Z
---
# Phase 1 Plan 01: StatLink Auto-Linking Summary
**Automated StatLink.pl link management: login, add links for published articles, track lifecycle, remove after 60 days**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~6h total (build + bugfix) |
| Started | 2026-04-08 |
| Completed | 2026-04-09 |
| Tasks | 3 completed |
| Files created | 4 |
| Files modified | 2 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Login do StatLink | Pass | Guzzle CookieJar, GET homepage + POST login, verified on production |
| AC-2: Dodawanie linku | Pass | Form POST with CSRF, anchor sanitization, ID extraction from response |
| AC-3: Usuwanie wygasłych | Pass | POST with statlink_id + usun, status tracking |
| AC-4: Cron endpoint | Pass | /statlink/token-run with SEO_TRIGGER_TOKEN, also cron/statlink.php |
| AC-5: Tracking w bazie | Pass | statlink_links table with full lifecycle tracking |
## Accomplishments
- StatLink service logs in, adds links, removes expired links end-to-end
- Robust diagnostic logging — every step tracked, errors surfaced in JSON response
- Retry mechanism for failed links with error tracking in database
- Token-secured HTTP endpoint + standalone cron script
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Services/StatLinkService.php` | Created | Core service: login, addLink, removeLink, processNewArticles, retryFailedLinks, removeExpiredLinks |
| `src/Controllers/StatLinkController.php` | Created | HTTP endpoints: index (admin view), runByToken (cron trigger) |
| `cron/statlink.php` | Created | Standalone cron script with lock file |
| `migrations/013_statlink_tracking.sql` | Created | statlink_links table schema |
| `config/routes.php` | Modified | Added /statlink routes |
| `src/Core/Controller.php` | Modified | json_encode with JSON_INVALID_UTF8_SUBSTITUTE |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| ASCII-only anchors (transliteration) | StatLink rejects Polish diacritics in anchor field | All anchors auto-sanitized ą→a, ś→s etc. |
| MAX_LINKS_PER_RUN=1 | Avoid StatLink rate limiting | 1 link per cron run, predictable load |
| Timeout 120s per request | StatLink is slow | connect_timeout=60s, timeout=120s |
| set_time_limit(300) | PHP default 30s insufficient | Both controller and cron script |
| JSON_INVALID_UTF8_SUBSTITUTE | Scraped StatLink HTML contains broken UTF-8 | Prevents empty JSON responses |
| findLinkIdInHtml before search | Response HTML already contains new link | Reduces requests, more reliable ID detection |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 3 | Critical — without these fixes, no links were being added |
| Scope additions | 1 | Retry mechanism (not in original plan) |
| Deferred | 1 | No max retry limit |
### Auto-fixed Issues
**1. Anchor encoding — StatLink rejects Polish characters**
- **Found during:** Production testing
- **Issue:** StatLink form validation requires ASCII-only anchors (alphanumeric + `.,+-_?!&\:=` + space)
- **Fix:** Added `sanitizeAnchor()` with Polish→ASCII transliteration map
- **Files:** `src/Services/StatLinkService.php`
- **Verification:** Links now added successfully with sanitized anchors
**2. Empty JSON responses from scraped HTML**
- **Found during:** Production debugging
- **Issue:** `json_encode()` returns `false` (output: nothing) when data contains invalid UTF-8 from StatLink HTML
- **Fix:** Added `JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_UNICODE` flags
- **Files:** `src/Core/Controller.php`
- **Verification:** All endpoints return valid JSON
**3. StatLink ID not detected after successful add**
- **Found during:** Production testing
- **Issue:** `findLinkIdBySearch` made separate request, URL matching was too narrow (no protocol variants, small region)
- **Fix:** New `findLinkIdInHtml()` extracts ID directly from form response HTML with wider region and URL variants
- **Files:** `src/Services/StatLinkService.php`
- **Verification:** `statlink_id=2673465` correctly detected
### Deferred Items
- No max retry count for permanently failing links (could block queue)
- StatLink cron not integrated into main publish cron — needs separate cron job setup on server
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| OPcache serving stale files after FTP upload | Manual opcache_reset() via test script; documented in patterns |
| PHP max_execution_time killing script | Added set_time_limit(300) in controller and cron |
| Login diagnostics missing on failure | Added loginDiagnostic in all error paths (empty credentials, exceptions) |
## Next Phase Readiness
**Ready:**
- StatLink service fully operational, links being added and tracked
- 37 failed links queued for retry (will auto-process via cron)
- Admin panel view exists at /statlink
**Concerns:**
- No max retry limit — a permanently failing link blocks the queue
- Cron not yet configured on server (only manual token URL trigger)
**Blockers:**
- None
---
*Phase: 01-statlink-autolinking, Plan: 01*
*Completed: 2026-04-09*

View File

@@ -0,0 +1,253 @@
---
phase: 02-admin-panel-upgrade
plan: 01
type: execute
wave: 1
depends_on: ["01-01"]
files_modified:
- src/Core/Database/Migrator.php
- src/Controllers/SettingsController.php
- src/Controllers/StatLinkController.php
- templates/layout/sidebar.php
- templates/layout/main.php
- templates/settings/index.php
- templates/settings/database.php
- templates/statlink/index.php
- config/routes.php
- assets/css/app.css
autonomous: true
delegation: off
---
<objective>
## Goal
Rozbudowa panelu administracyjnego backPRO o trzy funkcje:
1. Panel migracji bazy danych (wzorowany na orderPRO Migrator)
2. Nowoczesne lewe menu sidebar (wzorowane na orderPRO)
3. Lista publikacji StatLink — przegląd linkowanych artykułów
## Purpose
Usprawnienie zarządzania projektem — migracje bez ręcznego SSH, lepszy UX nawigacji, widoczność statusu linkowania StatLink.
## Output
- `Migrator.php` — engine migracji (port z orderPRO, dostosowany do backPRO)
- Nowy sidebar z grupowaniem, ikonami SVG, collapse/expand
- Widok `/statlink` z listą aktywnych/wygasłych linków StatLink
- Endpoint migracji `/settings/database/migrate`
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files — backPRO
@templates/layout/sidebar.php
@templates/layout/main.php
@assets/css/app.css
@src/Controllers/SettingsController.php
@config/routes.php
## Reference — orderPRO (wzorce do adaptacji)
- Migrator: C:\visual studio code\projekty\orderPRO\src\Core\Database\Migrator.php
- Sidebar: C:\visual studio code\projekty\orderPRO\resources\views\layouts\app.php (lines 18-116)
- Database view: C:\visual studio code\projekty\orderPRO\resources\views\settings\database.php
- CSS sidebar: C:\visual studio code\projekty\orderPRO\public\assets\css\app.css (sidebar section)
</context>
<acceptance_criteria>
## AC-1: Panel migracji bazy danych
```gherkin
Given użytkownik zalogowany w panelu backPRO
When przechodzi do /settings/database
Then widzi statystyki migracji (total, applied, pending)
And widzi listę oczekujących plików migracji
And może kliknąć "Uruchom migracje" aby wykonać pending migracje
And po wykonaniu widzi logi z wynikami (ok/error per plik)
```
## AC-2: Nowy sidebar
```gherkin
Given użytkownik zalogowany w panelu
When widzi lewe menu
Then menu ma nowoczesny wygląd (ciemne tło, ikony SVG, grupy rozwijane)
And sekcje: Główne (Dashboard, Strony, SEO Stats), Treści (Tematy, Artykuły, StatLink), Administracja (Instalator, Ustawienia z sub-menu, Logi)
And aktywna strona jest podświetlona
And menu można zwinąć/rozwinąć
```
## AC-3: Lista publikacji StatLink
```gherkin
Given użytkownik przechodzi do /statlink
Then widzi tabelę z linkami StatLink (artykuł, URL, anchor, data dodania, wygasa, status)
And linki aktywne są zielone, wygasłe szare, failed czerwone
And widzi podsumowanie: X aktywnych, Y wygasłych, Z błędów
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migrator engine + controller + widok migracji</name>
<files>src/Core/Database/Migrator.php, src/Controllers/SettingsController.php, templates/settings/database.php, config/routes.php</files>
<action>
1. Skopiować i dostosować Migrator.php z orderPRO:
- Namespace: App\Core\Database
- Zmienić lock name na 'backpro_migrations_lock'
- Używa istniejącego Database::getInstance() do PDO
- Ścieżka migracji: basePath/migrations/
- Bez zmian w logice (ensureMigrationsTable, runPending, status)
2. Dodać metody do SettingsController:
- database(): GET /settings/database — wyświetla status migracji
- Tworzy Migrator z Database::getInstance() i ścieżką migrations
- Przekazuje $status = $migrator->status() do widoku
- migrate(): POST /settings/database/migrate — uruchamia migracje
- Wywołuje $migrator->runPending()
- Flash success/error + redirect do /settings/database
3. Dodać routes:
- $router->get('/settings/database', 'SettingsController', 'database');
- $router->post('/settings/database/migrate', 'SettingsController', 'migrate');
4. Utworzyć templates/settings/database.php:
- Statystyki: total, applied, pending (3 karty)
- Alert warning jeśli pending > 0 + przycisk "Uruchom migracje"
- Tabela z pending files
- Logi po wykonaniu (jeśli dostępne)
- Styl: użyj istniejących klas Bootstrap z backPRO
Avoid: nie zmieniaj istniejącego install.php — Migrator to nowy, równoległy mechanizm.
Avoid: nie usuwaj istniejącej metody index() ani update() z SettingsController.
</action>
<verify>PHP lint na wszystkich zmienionych plikach</verify>
<done>AC-1 satisfied: panel migracji z Migratorem dostępny pod /settings/database</done>
</task>
<task type="auto">
<name>Task 2: Nowy sidebar wzorowany na orderPRO</name>
<files>templates/layout/sidebar.php, templates/layout/main.php, assets/css/app.css</files>
<action>
1. Przepisać sidebar.php na nowy format z orderPRO:
- Struktura: aside.sidebar > div.sidebar__brand + nav.sidebar__nav
- Grupy rozwijane: details.sidebar__group > summary.sidebar__group-toggle + div.sidebar__group-links
- Ikony SVG inline (nie Bootstrap Icons CDN)
- Collapse button w brand area
Sekcje menu:
a) Grupa "Główne":
- Dashboard (/) — ikona: dashboard/home
- Strony WP (/sites) — ikona: globe
- Statystyki SEO (/seo/stats) — ikona: chart
b) Grupa "Treści":
- Biblioteka Tematów (/global-topics) — ikona: book
- Artykuły (/articles) — ikona: document
- StatLink (/statlink) — ikona: link/chain
c) Grupa "Administracja":
- Instalator WP (/installer) — ikona: download
- Ustawienia (/settings) — sub-links:
- Ustawienia API (/settings)
- Baza danych (/settings/database)
- Dziennik zdarzeń (/logs) — ikona: list
d) Footer: użytkownik + wyloguj (jak obecny)
2. Active state: użyj zmiennej $activeMenu przekazywanej z kontrolera
- Grupy auto-open gdy aktywna strona jest w grupie
3. Dodać CSS sidebar do app.css (skopiować sekcję sidebar z orderPRO app.css):
- .sidebar: dark bg (#0f172a), 260px width, fixed left
- .sidebar.is-collapsed: 52px width, hide labels
- .sidebar__brand, __nav, __link, __group, __sublink
- Hover, active states, transitions
- Responsive: mobile overlay z backdrop
4. Zaktualizować main.php:
- Dodać .app-shell wrapper
- Dodać sidebar-backdrop div
- Dodać JS do collapse/expand
Avoid: nie usuwaj istniejących stylów z app.css — dodaj nowe sekcje sidebar na końcu.
Avoid: zachowaj aktualną zmienną $activeMenu i Router::isCurrent().
</action>
<verify>PHP lint + wizualna weryfikacja (sidebar renderuje się poprawnie)</verify>
<done>AC-2 satisfied: nowy sidebar z grupami, ikonami SVG, collapse</done>
</task>
<task type="auto">
<name>Task 3: Widok listy StatLink + route</name>
<files>src/Controllers/StatLinkController.php, templates/statlink/index.php, config/routes.php</files>
<action>
1. Dodać metodę index() do StatLinkController:
- Auth::requireLogin()
- Pobrać z bazy statlink_links z JOIN na articles i sites
- Podsumowanie: count active, expired, removed, failed
- Paginacja (20 per page)
- Przekazać do widoku
2. Dodać route: $router->get('/statlink', 'StatLinkController', 'index');
3. Utworzyć templates/statlink/index.php:
- Nagłówek z 4 kartami statystyk (active, expired, removed, failed) — kolorowe
- Tabela:
- Artykuł (tytuł + link do /articles/{id})
- Strona (nazwa site)
- URL linkowany
- Anchor
- Data dodania
- Wygasa
- Status (badge: zielony=active, szary=expired/removed, czerwony=failed)
- StatLink ID
- Sortowanie domyślne: added_at DESC
- Paginacja na dole
Avoid: nie modyfikuj istniejącej metody runByToken() w StatLinkController.
</action>
<verify>PHP lint + endpoint /statlink zwraca stronę z tabelą</verify>
<done>AC-3 satisfied: lista StatLink z tabelą i statystykami</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- src/Services/StatLinkService.php (dopiero co utworzony)
- src/Services/PublisherService.php
- src/Models/Article.php
- migrations/* (istniejące migracje)
- install.php (istniejący mechanizm)
## SCOPE LIMITS
- Nie dodajemy CRUD dla statlink_links (tylko podgląd)
- Nie przenosimy istniejących ustawień do nowego formatu
- Sidebar: adaptacja orderPRO designu, nie kopia 1:1
</boundaries>
<verification>
Before declaring plan complete:
- [ ] php -l na wszystkich zmienionych/nowych plikach PHP
- [ ] /settings/database pokazuje status migracji
- [ ] POST /settings/database/migrate wykonuje pending migracje
- [ ] Sidebar renderuje się z nowymi grupami i ikonami
- [ ] /statlink pokazuje listę linków z tabeli statlink_links
- [ ] Aktywne linki w menu są podświetlone
</verification>
<success_criteria>
- 3 taski wykonane
- Panel migracji działa end-to-end
- Sidebar wygląda jak w orderPRO (adaptacja do backPRO)
- Lista StatLink pokazuje dane z tabeli
- Brak regresji w istniejącej funkcjonalności
</success_criteria>
<output>
After completion, create `.paul/phases/02-admin-panel-upgrade/02-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,10 @@
[ 306ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ https://statlink.pl/:0
[ 28033ms] TypeError: Cannot read properties of null (reading 'style')
at ukryj_grupy (https://statlink.pl/javascript.js:453:42)
at https://statlink.pl/148,twoje-linki:256:72620
[ 259987ms] TypeError: Cannot read properties of null (reading 'style')
at ukryj_grupy (https://statlink.pl/javascript.js:453:42)
at https://statlink.pl/148,twoje-linki:256:72620
[ 262751ms] TypeError: Cannot read properties of null (reading 'style')
at ukryj_grupy (https://statlink.pl/javascript.js:453:42)
at https://statlink.pl/148,twoje-linki:256:72620

View File

@@ -0,0 +1,3 @@
[ 141ms] TypeError: Cannot read properties of null (reading 'style')
at ukryj_grupy (https://statlink.pl/javascript.js:453:42)
at https://statlink.pl/148,twoje-linki:256:71974

View File

@@ -0,0 +1,9 @@
[ 19376ms] TypeError: Cannot read properties of null (reading 'style')
at ukryj_grupy (https://statlink.pl/javascript.js:453:42)
at https://statlink.pl/148,twoje-linki:256:73342
[ 82797ms] TypeError: Cannot read properties of null (reading 'style')
at ukryj_grupy (https://statlink.pl/javascript.js:453:42)
at https://statlink.pl/148,twoje-linki:256:73342
[ 96213ms] TypeError: Cannot read properties of null (reading 'style')
at ukryj_grupy (https://statlink.pl/javascript.js:453:42)
at https://statlink.pl/148,twoje-linki:256:73342

View File

@@ -0,0 +1,3 @@
[ 156ms] TypeError: Cannot read properties of null (reading 'style')
at ukryj_grupy (https://statlink.pl/javascript.js:453:42)
at https://statlink.pl/148,twoje-linki:256:73342

View File

@@ -0,0 +1,3 @@
[ 105ms] TypeError: Cannot read properties of null (reading 'style')
at ukryj_grupy (https://statlink.pl/javascript.js:453:42)
at https://statlink.pl/148,twoje-linki:256:72620

View File

@@ -0,0 +1 @@
[ 874ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://backpro.projectpro.pl/favicon.ico:0

View File

@@ -0,0 +1 @@
[ 252ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ https://backpro.projectpro.pl/login:0

View File

@@ -0,0 +1,2 @@
[ 28ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://backpro.projectpro.pl/statlink-test.php:0
[ 40508ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://backpro.projectpro.pl/statlink-test.php:0

View File

@@ -0,0 +1,2 @@
[ 36ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://backpro.projectpro.pl/statlink-test.php:0
[ 7001ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://backpro.projectpro.pl/statlink-test.php:0

View File

@@ -0,0 +1,191 @@
- table [ref=e2]:
- rowgroup [ref=e3]:
- row [ref=e4]:
- cell [ref=e5]
- cell [ref=e7]:
- table [ref=e8]:
- rowgroup [ref=e9]:
- 'row "pamiętaj: ZALOGUJ Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603 Start Aktualności O systemie Regulamin Rejestracja Zaloguj Forum Kontakt FAQ" [ref=e10]':
- 'cell "pamiętaj: ZALOGUJ Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603 Start Aktualności O systemie Regulamin Rejestracja Zaloguj Forum Kontakt FAQ" [ref=e11]':
- generic [ref=e12]:
- table [ref=e13]:
- rowgroup [ref=e14]:
- 'row "pamiętaj: ZALOGUJ Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603" [ref=e15]':
- cell [ref=e16]:
- img [ref=e17]
- cell [ref=e18]:
- link:
- /url: http://statlink.pl
- img [ref=e19] [cursor=pointer]
- 'cell "pamiętaj: ZALOGUJ Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603" [ref=e20]':
- table [ref=e22]:
- rowgroup [ref=e23]:
- 'row "pamiętaj: ZALOGUJ" [ref=e24]':
- 'cell "pamiętaj: ZALOGUJ" [ref=e25]':
- table [ref=e26]:
- rowgroup [ref=e27]:
- 'row "pamiętaj: ZALOGUJ" [ref=e28]':
- cell "pamiętaj:" [ref=e29]:
- text: "pamiętaj:"
- checkbox [ref=e30]
- cell [ref=e31]:
- textbox [ref=e32]
- cell [ref=e33]:
- textbox [ref=e34]
- cell "ZALOGUJ" [ref=e35]:
- button "ZALOGUJ" [ref=e36]
- generic [ref=e37]: "Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603"
- table [ref=e40]:
- rowgroup [ref=e41]:
- row "Start Aktualności O systemie Regulamin Rejestracja Zaloguj Forum Kontakt FAQ" [ref=e42]:
- cell "Start" [ref=e43]:
- link "Start" [ref=e45] [cursor=pointer]:
- /url: http://www.statlink.pl
- cell [ref=e46]:
- img [ref=e47]
- cell "Aktualności" [ref=e48]:
- link "Aktualności" [ref=e50] [cursor=pointer]:
- /url: /61,aktualnosci
- cell [ref=e51]:
- img [ref=e52]
- cell "O systemie" [ref=e53]:
- link "O systemie" [ref=e55] [cursor=pointer]:
- /url: /73,o-systemie
- cell [ref=e56]:
- img [ref=e57]
- cell "Regulamin" [ref=e58]:
- link "Regulamin" [ref=e60] [cursor=pointer]:
- /url: /28,regulamin
- cell [ref=e61]:
- img [ref=e62]
- cell "Rejestracja" [ref=e63]:
- link "Rejestracja" [ref=e65] [cursor=pointer]:
- /url: /87,rejestracja
- cell [ref=e66]:
- img [ref=e67]
- cell "Zaloguj" [ref=e68]:
- link "Zaloguj" [ref=e70] [cursor=pointer]:
- /url: /20,zaloguj
- cell [ref=e71]:
- img [ref=e72]
- cell "Forum" [ref=e73]:
- link "Forum" [ref=e75] [cursor=pointer]:
- /url: /forum
- cell [ref=e76]:
- img [ref=e77]
- cell "Kontakt" [ref=e78]:
- link "Kontakt" [ref=e80] [cursor=pointer]:
- /url: /5,kontakt
- img [ref=e81]
- text: Kontakt
- cell [ref=e82]:
- img [ref=e83]
- cell "FAQ" [ref=e84]:
- link "FAQ" [ref=e86] [cursor=pointer]:
- /url: /344,faq
- row [ref=e88]:
- cell [ref=e89]:
- table [ref=e91]:
- rowgroup [ref=e92]:
- row [ref=e93]:
- cell [ref=e94]:
- img [ref=e95]
- cell
- table [ref=e96]:
- rowgroup [ref=e97]:
- row [ref=e98]:
- cell [ref=e99]:
- generic:
- table
- cell [ref=e100]:
- generic [ref=e101]:
- heading "System reklamy i promocji w internecie Statlink.pl" [level=1] [ref=e103]
- generic [ref=e105]:
- table [ref=e106]:
- rowgroup [ref=e107]:
- row "Witamy w Statlink.pl" [ref=e108]:
- cell "Witamy w Statlink.pl" [ref=e109]:
- heading "Witamy w Statlink.pl" [level=2] [ref=e110]
- paragraph [ref=e111]: System statlink ma na celu zwiększenie odwiedzalności Twojej strony poprzez umieszczenie reklamy linkowej lub kontekstowej na serwisach partnerskich z Twojej branży. Dzięki temu Twój biznes zyska wiele unikalnych wejść a tym samym zwiększy możliwość sprzedaży produktów lub usług.
- generic [ref=e112]:
- table [ref=e113]:
- rowgroup [ref=e114]:
- row "Warto spróbować !" [ref=e115]:
- cell "Warto spróbować !" [ref=e116]:
- heading "Warto spróbować !" [level=2] [ref=e117]
- paragraph [ref=e118]: Wszyscy wiemy jak istotna jest reklama stron internetowych w wyszukiwarkach. Nasz system jest narzędziem wspierającym promocję w sieci. Dzięki systemowi możesz pozyskać miejsca reklamowe i linki na serwisach branżowych tematycznie powiązanych z Twoim serwisem.
- paragraph [ref=e119]:
- strong [ref=e120]: Statlink.pl jest pierwszym systemem w Polsce który zdecydowanie odrzuca SPAM.
- text: Do systemu nie są przyjmowane strony powszechnie uważane za spam lub serwisy o małej zawartości unikalnych treści. Przez system nie są akceptowane generatory treści, katalogi stron, tzn ,,precle” , autoblogi oparte o czytnik rss, wikipedie, serwisy pornograficzne, wspierające promocję farmaceutyków, strony łamiące prawo i itp. Podstrony serwisów dodanych do Statlink poddawane są szczegółowej weryfikacji i selekcji między innymi pod kątem zaindeksowania w wyszukiwarkach, ilości zawartości treści, poprawności działania. Podstrony nie spełniające wymogów są ignorowane przez wciąż rozwijany algorytm systemu.
- paragraph [ref=e121]:
- text: Dzięki tak ostrym kryteriom, nowatorskiemu algorytmowi oraz pozostałym cechom, o których można przeczytać więcej w dziale
- link "O systemie" [ref=e122] [cursor=pointer]:
- /url: /73,o-systemie
- text: ", każdy Użytkownik ma zagwarantowaną emisję swoich Linków i treści reklamowych na wartościowych podstronach serwisów."
- paragraph [ref=e123]: My postawiliśmy na jakość a Ty? Zarejestruj się już dziś, zapraszamy!
- generic [ref=e125]:
- text: "|"
- link "Przejdź do góry" [ref=e126] [cursor=pointer]:
- /url: "#srodek"
- text: "|"
- cell "AKTUALNOŚCI Aktualizacja Algorytmu ( 2025-08-06 ) Informujemy, że wprowadziliśmy poprawki do algorytmu zwiększające skuteczność weryfikacji indeksu wyszukiwarki. W związku z wprowadzonhmi zmianami w przeciągu... Czytaj więcej Wznowienie pracy systemu oraz czyszczenie z fraz niedozwolonych ( 2025-03-01 ) Informujemy że prace serwisowe dobiegły końca, wróciliśmy do pracy produkcyjnej, w najbliższych dniach mogą wystąpić jeszcze wahania stabilności związane z... Czytaj więcej Przerwa w dostępności usług ( 2025-02-26 ) Informujemy że z uwagi na konieczność wykonania pilnych prac serwisowych system Statlink nie będzie dostępny od godziny 21:00 w... Czytaj więcej" [ref=e127]:
- generic [ref=e128]: AKTUALNOŚCI
- generic [ref=e129]:
- generic [ref=e130]: Aktualizacja Algorytmu
- generic [ref=e131]: ( 2025-08-06 )
- generic [ref=e132]: Informujemy, że wprowadziliśmy poprawki do algorytmu zwiększające skuteczność weryfikacji indeksu wyszukiwarki. W związku z wprowadzonhmi zmianami w przeciągu...
- link "Czytaj więcej" [ref=e134] [cursor=pointer]:
- /url: /61,aktualnosci#pozycja1537
- separator [ref=e135]
- generic [ref=e136]: Wznowienie pracy systemu oraz czyszczenie z fraz niedozwolonych
- generic [ref=e137]: ( 2025-03-01 )
- generic [ref=e138]: Informujemy że prace serwisowe dobiegły końca, wróciliśmy do pracy produkcyjnej, w najbliższych dniach mogą wystąpić jeszcze wahania stabilności związane z...
- link "Czytaj więcej" [ref=e140] [cursor=pointer]:
- /url: /61,aktualnosci#pozycja1536
- separator [ref=e141]
- generic [ref=e142]: Przerwa w dostępności usług
- generic [ref=e143]: ( 2025-02-26 )
- generic [ref=e144]: Informujemy że z uwagi na konieczność wykonania pilnych prac serwisowych system Statlink nie będzie dostępny od godziny 21:00 w...
- link "Czytaj więcej" [ref=e146] [cursor=pointer]:
- /url: /61,aktualnosci#pozycja1535
- separator [ref=e147]
- table [ref=e149]:
- rowgroup [ref=e150]:
- row "Aktualności O systemie Regulamin Kontakt FAQ" [ref=e151]:
- cell "Aktualności O systemie Regulamin Kontakt FAQ" [ref=e152]:
- table [ref=e156]:
- rowgroup [ref=e157]:
- row "Aktualności O systemie Regulamin Kontakt FAQ" [ref=e158]:
- cell "Aktualności" [ref=e159]:
- link "Aktualności" [ref=e161] [cursor=pointer]:
- /url: /61,aktualnosci
- cell [ref=e162]:
- img [ref=e163]
- cell "O systemie" [ref=e164]:
- link "O systemie" [ref=e166] [cursor=pointer]:
- /url: /73,o-systemie
- cell [ref=e167]:
- img [ref=e168]
- cell "Regulamin" [ref=e169]:
- link "Regulamin" [ref=e171] [cursor=pointer]:
- /url: /28,regulamin
- cell [ref=e172]:
- img [ref=e173]
- cell "Kontakt" [ref=e174]:
- link "Kontakt" [ref=e176] [cursor=pointer]:
- /url: /5,kontakt
- img [ref=e177]
- text: Kontakt
- cell [ref=e178]:
- img [ref=e179]
- cell "FAQ" [ref=e180]:
- link "FAQ" [ref=e182] [cursor=pointer]:
- /url: /344,faq
- row "...Copyrights © 2008 Statlink.pl. Wszelkie prawa zastrzeżone..." [ref=e184]:
- cell "...Copyrights © 2008 Statlink.pl. Wszelkie prawa zastrzeżone..." [ref=e185]:
- generic [ref=e187]:
- text: ...Copyrights © 2008
- link "Statlink.pl" [ref=e188] [cursor=pointer]:
- /url: http://www.statlink.pl
- text: . Wszelkie prawa zastrzeżone...
- cell [ref=e189]

View File

@@ -0,0 +1,156 @@
- table [ref=e2]:
- rowgroup [ref=e3]:
- 'row "Zalogowano: Vampirius ID: 2758 (profil zaufany) Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603 Start Wskazówki Reklamodawca Wskazówki Wydawca Panel Użytkownika ! Aktualności O systemie Regulamin Wyloguj Forum Kontakt FAQ Zaloguj Zostałeś zalogowany Data ostatniego logowania: 2026-04-08 23:54:24 Adres IP: 89.69.31.86 Panel Użytkownika | Przejdź do góry | Aktualności O systemie Regulamin Kontakt FAQ ...Copyrights © 2008 Statlink.pl. Wszelkie prawa zastrzeżone..." [ref=e4]':
- cell [ref=e5]
- 'cell "Zalogowano: Vampirius ID: 2758 (profil zaufany) Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603 Start Wskazówki Reklamodawca Wskazówki Wydawca Panel Użytkownika ! Aktualności O systemie Regulamin Wyloguj Forum Kontakt FAQ Zaloguj Zostałeś zalogowany Data ostatniego logowania: 2026-04-08 23:54:24 Adres IP: 89.69.31.86 Panel Użytkownika | Przejdź do góry | Aktualności O systemie Regulamin Kontakt FAQ ...Copyrights © 2008 Statlink.pl. Wszelkie prawa zastrzeżone..." [ref=e7]':
- table [ref=e8]:
- rowgroup [ref=e9]:
- 'row "Zalogowano: Vampirius ID: 2758 (profil zaufany) Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603 Start Wskazówki Reklamodawca Wskazówki Wydawca Panel Użytkownika ! Aktualności O systemie Regulamin Wyloguj Forum Kontakt FAQ" [ref=e10]':
- 'cell "Zalogowano: Vampirius ID: 2758 (profil zaufany) Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603 Start Wskazówki Reklamodawca Wskazówki Wydawca Panel Użytkownika ! Aktualności O systemie Regulamin Wyloguj Forum Kontakt FAQ" [ref=e11]':
- generic [ref=e12]:
- table [ref=e13]:
- rowgroup [ref=e14]:
- 'row "Zalogowano: Vampirius ID: 2758 (profil zaufany) Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603" [ref=e15]':
- cell [ref=e16]:
- img [ref=e17]
- cell [ref=e18]:
- link:
- /url: http://statlink.pl
- img [ref=e19] [cursor=pointer]
- 'cell "Zalogowano: Vampirius ID: 2758 (profil zaufany) Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603" [ref=e20]':
- generic [ref=e21]: "Zalogowano: Vampirius ID: 2758 (profil zaufany)"
- generic [ref=e22]: "Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603"
- generic [ref=e24]:
- table [ref=e25]:
- rowgroup [ref=e26]:
- row "Start Wskazówki Reklamodawca Wskazówki Wydawca Panel Użytkownika ! Aktualności O systemie Regulamin" [ref=e27]:
- cell "Start" [ref=e28]:
- link "Start" [ref=e30] [cursor=pointer]:
- /url: http://www.statlink.pl
- cell [ref=e31]:
- img [ref=e32]
- cell "Wskazówki Reklamodawca" [ref=e33]:
- link "Wskazówki Reklamodawca" [ref=e35] [cursor=pointer]:
- /url: /1465,wskazowki-reklamodawca
- cell [ref=e36]:
- img [ref=e37]
- cell "Wskazówki Wydawca" [ref=e38]:
- link "Wskazówki Wydawca" [ref=e40] [cursor=pointer]:
- /url: /1468,wskazowki-wydawca
- cell [ref=e41]:
- img [ref=e42]
- cell "Panel Użytkownika" [ref=e43]:
- link "Panel Użytkownika" [ref=e45] [cursor=pointer]:
- /url: /147,panel-uzytkownika
- cell [ref=e46]:
- img [ref=e47]
- cell "! Aktualności" [ref=e48]:
- link "! Aktualności" [ref=e50] [cursor=pointer]:
- /url: /61,aktualnosci
- cell [ref=e51]:
- img [ref=e52]
- cell "O systemie" [ref=e53]:
- link "O systemie" [ref=e55] [cursor=pointer]:
- /url: /73,o-systemie
- cell [ref=e56]:
- img [ref=e57]
- cell "Regulamin" [ref=e58]:
- link "Regulamin" [ref=e60] [cursor=pointer]:
- /url: /28,regulamin
- table [ref=e61]:
- rowgroup [ref=e62]:
- row "Wyloguj Forum Kontakt FAQ" [ref=e63]:
- cell "Wyloguj" [ref=e64]:
- link "Wyloguj" [ref=e66] [cursor=pointer]:
- /url: /23,wyloguj
- cell [ref=e67]:
- img [ref=e68]
- cell "Forum" [ref=e69]:
- link "Forum" [ref=e71] [cursor=pointer]:
- /url: /forum
- cell [ref=e72]:
- img [ref=e73]
- cell "Kontakt" [ref=e74]:
- link "Kontakt" [ref=e76] [cursor=pointer]:
- /url: /5,kontakt
- img [ref=e77]
- text: Kontakt
- cell [ref=e78]:
- img [ref=e79]
- cell "FAQ" [ref=e80]:
- link "FAQ" [ref=e82] [cursor=pointer]:
- /url: /344,faq
- row [ref=e84]:
- cell [ref=e85]:
- table [ref=e87]:
- rowgroup [ref=e88]:
- row [ref=e89]:
- cell [ref=e90]:
- img [ref=e91]
- cell
- table [ref=e92]:
- rowgroup [ref=e93]:
- 'row "Zaloguj Zostałeś zalogowany Data ostatniego logowania: 2026-04-08 23:54:24 Adres IP: 89.69.31.86 Panel Użytkownika | Przejdź do góry |" [ref=e94]':
- cell [ref=e95]:
- generic:
- table
- 'cell "Zaloguj Zostałeś zalogowany Data ostatniego logowania: 2026-04-08 23:54:24 Adres IP: 89.69.31.86 Panel Użytkownika | Przejdź do góry |" [ref=e96]':
- generic [ref=e97]:
- heading "Zaloguj" [level=1] [ref=e99]
- generic [ref=e104]:
- separator [ref=e105]
- table [ref=e106]:
- rowgroup [ref=e107]:
- 'row "Zostałeś zalogowany Data ostatniego logowania: 2026-04-08 23:54:24 Adres IP: 89.69.31.86 Panel Użytkownika" [ref=e108]':
- 'cell "Zostałeś zalogowany Data ostatniego logowania: 2026-04-08 23:54:24 Adres IP: 89.69.31.86 Panel Użytkownika" [ref=e109]':
- generic [ref=e110]:
- text: Zostałeś zalogowany
- text: "Data ostatniego logowania: 2026-04-08 23:54:24 Adres IP: 89.69.31.86"
- link "Panel Użytkownika" [ref=e111] [cursor=pointer]:
- /url: /147,panel-uzytkownika
- separator [ref=e112]
- generic [ref=e114]:
- text: "|"
- link "Przejdź do góry" [ref=e115] [cursor=pointer]:
- /url: "#srodek"
- text: "|"
- table [ref=e116]:
- rowgroup [ref=e117]:
- row "Aktualności O systemie Regulamin Kontakt FAQ" [ref=e118]:
- cell "Aktualności O systemie Regulamin Kontakt FAQ" [ref=e119]:
- table [ref=e123]:
- rowgroup [ref=e124]:
- row "Aktualności O systemie Regulamin Kontakt FAQ" [ref=e125]:
- cell "Aktualności" [ref=e126]:
- link "Aktualności" [ref=e128] [cursor=pointer]:
- /url: /61,aktualnosci
- cell [ref=e129]:
- img [ref=e130]
- cell "O systemie" [ref=e131]:
- link "O systemie" [ref=e133] [cursor=pointer]:
- /url: /73,o-systemie
- cell [ref=e134]:
- img [ref=e135]
- cell "Regulamin" [ref=e136]:
- link "Regulamin" [ref=e138] [cursor=pointer]:
- /url: /28,regulamin
- cell [ref=e139]:
- img [ref=e140]
- cell "Kontakt" [ref=e141]:
- link "Kontakt" [ref=e143] [cursor=pointer]:
- /url: /5,kontakt
- img [ref=e144]
- text: Kontakt
- cell [ref=e145]:
- img [ref=e146]
- cell "FAQ" [ref=e147]:
- link "FAQ" [ref=e149] [cursor=pointer]:
- /url: /344,faq
- row "...Copyrights © 2008 Statlink.pl. Wszelkie prawa zastrzeżone..." [ref=e151]:
- cell "...Copyrights © 2008 Statlink.pl. Wszelkie prawa zastrzeżone..." [ref=e152]:
- generic [ref=e154]:
- text: ...Copyrights © 2008
- link "Statlink.pl" [ref=e155] [cursor=pointer]:
- /url: http://www.statlink.pl
- text: . Wszelkie prawa zastrzeżone...
- cell [ref=e156]

View File

@@ -0,0 +1,272 @@
- table [ref=e2]:
- rowgroup [ref=e3]:
- row [ref=e4]:
- cell [ref=e5]
- cell [ref=e7]:
- table [ref=e8]:
- rowgroup [ref=e9]:
- 'row "Zalogowano: Vampirius ID: 2758 (profil zaufany) Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603 Start Wskazówki Reklamodawca Wskazówki Wydawca Panel Użytkownika Aktualności O systemie Regulamin Wyloguj Forum Kontakt FAQ" [ref=e10]':
- 'cell "Zalogowano: Vampirius ID: 2758 (profil zaufany) Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603 Start Wskazówki Reklamodawca Wskazówki Wydawca Panel Użytkownika Aktualności O systemie Regulamin Wyloguj Forum Kontakt FAQ" [ref=e11]':
- generic [ref=e12]:
- table [ref=e13]:
- rowgroup [ref=e14]:
- 'row "Zalogowano: Vampirius ID: 2758 (profil zaufany) Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603" [ref=e15]':
- cell [ref=e16]:
- img [ref=e17]
- cell [ref=e18]:
- link:
- /url: http://statlink.pl
- img [ref=e19] [cursor=pointer]
- 'cell "Zalogowano: Vampirius ID: 2758 (profil zaufany) Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603" [ref=e20]':
- generic [ref=e21]: "Zalogowano: Vampirius ID: 2758 (profil zaufany)"
- generic [ref=e22]: "Ilość domen Standard: 5162, Ilość domen GOLD: 463, Ilość adresów IP: 1603"
- generic [ref=e24]:
- table [ref=e25]:
- rowgroup [ref=e26]:
- row "Start Wskazówki Reklamodawca Wskazówki Wydawca Panel Użytkownika Aktualności O systemie Regulamin" [ref=e27]:
- cell "Start" [ref=e28]:
- link "Start" [ref=e30] [cursor=pointer]:
- /url: http://www.statlink.pl
- cell [ref=e31]:
- img [ref=e32]
- cell "Wskazówki Reklamodawca" [ref=e33]:
- link "Wskazówki Reklamodawca" [ref=e35] [cursor=pointer]:
- /url: /1465,wskazowki-reklamodawca
- cell [ref=e36]:
- img [ref=e37]
- cell "Wskazówki Wydawca" [ref=e38]:
- link "Wskazówki Wydawca" [ref=e40] [cursor=pointer]:
- /url: /1468,wskazowki-wydawca
- cell [ref=e41]:
- img [ref=e42]
- cell "Panel Użytkownika" [ref=e43]:
- link "Panel Użytkownika" [ref=e45] [cursor=pointer]:
- /url: /147,panel-uzytkownika
- cell [ref=e46]:
- img [ref=e47]
- cell "Aktualności" [ref=e48]:
- link "Aktualności" [ref=e50] [cursor=pointer]:
- /url: /61,aktualnosci
- cell [ref=e51]:
- img [ref=e52]
- cell "O systemie" [ref=e53]:
- link "O systemie" [ref=e55] [cursor=pointer]:
- /url: /73,o-systemie
- cell [ref=e56]:
- img [ref=e57]
- cell "Regulamin" [ref=e58]:
- link "Regulamin" [ref=e60] [cursor=pointer]:
- /url: /28,regulamin
- table [ref=e61]:
- rowgroup [ref=e62]:
- row "Wyloguj Forum Kontakt FAQ" [ref=e63]:
- cell "Wyloguj" [ref=e64]:
- link "Wyloguj" [ref=e66] [cursor=pointer]:
- /url: /23,wyloguj
- cell [ref=e67]:
- img [ref=e68]
- cell "Forum" [ref=e69]:
- link "Forum" [ref=e71] [cursor=pointer]:
- /url: /forum
- cell [ref=e72]:
- img [ref=e73]
- cell "Kontakt" [ref=e74]:
- link "Kontakt" [ref=e76] [cursor=pointer]:
- /url: /5,kontakt
- img [ref=e77]
- text: Kontakt
- cell [ref=e78]:
- img [ref=e79]
- cell "FAQ" [ref=e80]:
- link "FAQ" [ref=e82] [cursor=pointer]:
- /url: /344,faq
- table [ref=e84]:
- rowgroup [ref=e85]:
- row [ref=e86]:
- cell [ref=e87]:
- generic:
- table
- cell [ref=e88]:
- heading "Panel Użytkownika" [level=1] [ref=e91]
- generic [ref=e94]:
- text: "MENU: |"
- link "Panel Użytkownika" [ref=e95] [cursor=pointer]:
- /url: /147,panel-uzytkownika#srodek
- text: "|"
- link "Twoje Linki" [ref=e96] [cursor=pointer]:
- /url: /148,twoje-linki#srodek
- text: "|"
- link "Lista Wykluczeń" [ref=e97] [cursor=pointer]:
- /url: /1381,lista-wykluczen#srodek
- text: "|"
- link "Twoje Strony" [ref=e98] [cursor=pointer]:
- /url: /153,twoje-strony#srodek
- text: "|"
- link "Zapotrzebowanie Frazy Kluczowe" [ref=e99] [cursor=pointer]:
- /url: /1120,zapotrzebowanie-frazy-kluczowe#srodek
- text: "|"
- link "Twoje Transfery" [ref=e100] [cursor=pointer]:
- /url: /158,twoje-transfery#srodek
- text: "|"
- link "Twoje Konto" [ref=e101] [cursor=pointer]:
- /url: /159,twoje-konto#srodek
- text: "|"
- link "Instalator" [ref=e102] [cursor=pointer]:
- /url: /160,instalator#srodek
- text: "|"
- generic [ref=e103]:
- generic [ref=e104]:
- text: "Zalogowano: Vampirius, ID Użytkownika:"
- strong [ref=e105]: "2758"
- text: ", Ostrzeżenia:"
- img [ref=e106]
- text: "-"
- link "OCHRONA ZASOBÓW (URLOP) WYŁĄCZONA" [ref=e107] [cursor=pointer]:
- /url: /159,twoje-dane#urlop
- link "historia logowania" [ref=e109] [cursor=pointer]:
- /url: javascript:void(0)
- separator [ref=e111]
- table [ref=e113]:
- rowgroup [ref=e114]:
- row "Ostatnie News'y" [ref=e115]:
- cell "Ostatnie News'y" [ref=e116]:
- heading "Ostatnie News'y" [level=2] [ref=e117]
- generic [ref=e118]:
- generic [ref=e119]:
- heading "### Aktualizacja Algorytmu" [level=2] [ref=e120]
- text: ( 2025-08-06 )
- generic [ref=e122]: Informujemy, że wprowadziliśmy poprawki do algorytmu zwiększające skuteczność weryfikacji indeksu wyszukiwarki. W związku z wprowadzonhmi zmianami w przeciągu najbliższych 10 dni można spodziewać się większych przetasowań w lokalizacjach referencyjnych a w efekcie końcowym zwiekszenia efektywności i poprawy wyników promocji.
- generic [ref=e124]:
- heading "### Wznowienie pracy systemu oraz czyszczenie z fraz niedozwolonych" [level=2] [ref=e125]
- text: ( 2025-03-01 )
- generic [ref=e127]:
- text: Informujemy że prace serwisowe dobiegły końca, wróciliśmy do pracy produkcyjnej, w najbliższych dniach mogą wystąpić jeszcze wahania stabilności związane z optymalizacją.
- generic [ref=e129]: Jednocześnie informujemy, że przerwę wykorzystaliśmy na czyszczenie systemu z fraz niedozwolonych regulaminem typu erotyczne, medykamenty itp. Przypominamy że takie linkowanie jest niedopuszczalne w systemie Statlink i będziemy kontynuować eliminację takich pozycji.
- generic [ref=e130]:
- heading "### Przerwa w dostępności usług" [level=2] [ref=e131]
- text: ( 2025-02-26 )
- generic [ref=e133]: Informujemy że z uwagi na konieczność wykonania pilnych prac serwisowych system Statlink nie będzie dostępny od godziny 21:00 w dniu dzisiejszym do dnia 29.02.2025. Transfery punktowe wygasające w trakcie przerwy zostaną utrzymane. Za utrudnienia przepraszamy.
- generic [ref=e134]:
- heading "### Zmiany Funkcjonalności" [level=2] [ref=e135]
- text: ( 2024-10-09 )
- generic [ref=e137]:
- generic [ref=e138]: W odpowiedzi na potrzeby naszych użytkowników wprowadzamy zmiany w funkcjonalności Statlink. Celem tych zmian jest lepsze dopasowanie przydzielanych Linków na serwisach, które są dostępne w naszym systemie. Zmniejszamy znaczenie kategorii tematycznych a skupiamy się bardziej na przydzielaniu Linków na podstronach tematycznych analizując wyniki serp oraz treść występującą na danej podstronie w oparciu o frazy kluczowe.
- text: "Pierwsze zmiany już zostały wprowadzone i obejmują:"
- list [ref=e141]:
- listitem [ref=e142]: Dodaliśmy nowy parametr "Przydzielaj tylko Frazowe" w definicji Linka. Po zaznaczeniu tej opcji Linki będą przydzielane tylko wtedy, gdy treści wydawcze będą zgodne z określonymi Frazami Kluczowymi (serpy, treść). Zaktualizowaliśmy również opis dotyczący definiowania Fraz Kluczowych, który jest dostępny w chmurce tooltip w Panelu edycji Linka. Zachęcamy do zapoznania się z tym opisem.
- listitem [ref=e143]:
- generic [ref=e144]: "Dla Wydawców: W panelu Zapotrzebowanie Fraz Kluczowych poprawiliśmy sposób mierzenia zapotrzebowania na artykuły tematyczne. Teraz wskaźnik zapotrzebowania jest bardziej realistyczny, ponieważ odnosi się do rzeczywistej liczby żądań artykułów tematycznych opartych na Frazach Kluczowych."
- link "Czytaj więcej aktualności" [ref=e146] [cursor=pointer]:
- /url: /61,aktualnosci
- generic [ref=e147]:
- separator [ref=e148]
- strong [ref=e149]:
- heading "Statystyka Twoich zasobów" [level=2] [ref=e150]: Statystyka Twoich zasobów
- table [ref=e152]:
- rowgroup [ref=e153]:
- 'row "STANDARD Ilość Stron: 0 szt. Uzyskane Punkty za dodane Strony: 0 pkt. w tym Punkty odliczone za linki wychodzące ze Stron: -0 pkt. w tym Punkty doliczone za staż Stron w systemie Statlink: +0 pkt. w tym Punkty doliczone za unikalne adresy IP Stron: +0 pkt. Ilość Linkow: 82 szt. Ilość Przydzieleń Linkow: 600 szt. Punkty odliczone za przydzielone Linki Statlink: 8747 pkt. Bilans Punktów Transferowych: +20000 pkt. Bilans Punktów do wykorzystania: 11253 pkt. Średni koszt punktów za 1 Link Standard: 14.06 pkt. Maksymalna ilość Punktów uzyskana w systemie za 1 Stronę: 55825 pkt." [ref=e154]':
- 'cell "STANDARD Ilość Stron: 0 szt. Uzyskane Punkty za dodane Strony: 0 pkt. w tym Punkty odliczone za linki wychodzące ze Stron: -0 pkt. w tym Punkty doliczone za staż Stron w systemie Statlink: +0 pkt. w tym Punkty doliczone za unikalne adresy IP Stron: +0 pkt. Ilość Linkow: 82 szt. Ilość Przydzieleń Linkow: 600 szt. Punkty odliczone za przydzielone Linki Statlink: 8747 pkt. Bilans Punktów Transferowych: +20000 pkt. Bilans Punktów do wykorzystania: 11253 pkt. Średni koszt punktów za 1 Link Standard: 14.06 pkt. Maksymalna ilość Punktów uzyskana w systemie za 1 Stronę: 55825 pkt." [ref=e155]':
- strong [ref=e156]: STANDARD
- text: "Ilość Stron:"
- strong [ref=e157]: "0"
- text: szt.
- text: "Uzyskane Punkty za dodane Strony:"
- strong [ref=e158]: "0"
- text: pkt.
- text: "w tym Punkty odliczone za linki wychodzące ze Stron:"
- strong [ref=e159]: "-0"
- text: pkt.
- text: "w tym Punkty doliczone za staż Stron w systemie Statlink:"
- strong [ref=e160]: "+0"
- text: pkt.
- text: "w tym Punkty doliczone za unikalne adresy IP Stron:"
- strong [ref=e161]: "+0"
- text: pkt.
- text: "Ilość Linkow:"
- strong [ref=e162]: "82"
- text: szt.
- text: "Ilość Przydzieleń Linkow:"
- strong [ref=e163]: "600"
- text: szt.
- text: "Punkty odliczone za przydzielone Linki Statlink:"
- strong [ref=e164]: "8747"
- text: pkt.
- text: "Bilans Punktów Transferowych:"
- strong [ref=e165]: "+20000"
- text: pkt.
- text: "Bilans Punktów do wykorzystania:"
- strong [ref=e166]: "11253"
- text: pkt.
- generic [ref=e167]:
- text: "Średni koszt punktów za 1 Link Standard:"
- strong [ref=e168]: "14.06"
- text: pkt.
- generic [ref=e169]:
- text: "Maksymalna ilość Punktów uzyskana w systemie za 1 Stronę:"
- strong [ref=e170]: "55825"
- text: pkt.
- cell [ref=e171]
- separator [ref=e173]
- table [ref=e175]:
- rowgroup [ref=e176]:
- 'row "SPRAWDŹ UNIKALNOŚĆ IP Adresy IP (max 100szt rozdzielone spacją, przecinkiem lub nowym wierszem): SPRAWDŹ SPRAWDŹ STATRANK Uwaga!!! Pamiętaj że ocena domen StatRank bazuje na frazach zaindeksowanych w wyszukiwarce. Jeżeli sprawdzasz domenę wygasłą która została częściowo lub w całości wyindeksowana z serpów wyszukiwarki wynik podany w sprawdzarce będzie zerowy niezależnie od realnego potencjału domeny. W takich przypadkach zalecamy sugerować się parametrami seo domeny które po wznowieniu domeny powinny przełożyć się na adekwatny do parametrów współczynnik StatRank. Sprawdzarka działa na zasadzie czasochłonnej analizy, sugerujemy ponownie sprawdzić te same domeny po upływie 24 godzin od pierwszego sprawdzenia. Domeny (max 50szt rozdzielone spacją, przecinkiem lub nowym wierszem): SPRAWDŹ" [ref=e177]':
- 'cell "SPRAWDŹ UNIKALNOŚĆ IP Adresy IP (max 100szt rozdzielone spacją, przecinkiem lub nowym wierszem): SPRAWDŹ" [ref=e178]':
- generic [ref=e179]:
- text: SPRAWDŹ UNIKALNOŚĆ IP
- text: "Adresy IP (max 100szt rozdzielone spacją, przecinkiem lub nowym wierszem):"
- textbox [ref=e180]
- button "SPRAWDŹ" [ref=e181] [cursor=pointer]
- 'cell "SPRAWDŹ STATRANK Uwaga!!! Pamiętaj że ocena domen StatRank bazuje na frazach zaindeksowanych w wyszukiwarce. Jeżeli sprawdzasz domenę wygasłą która została częściowo lub w całości wyindeksowana z serpów wyszukiwarki wynik podany w sprawdzarce będzie zerowy niezależnie od realnego potencjału domeny. W takich przypadkach zalecamy sugerować się parametrami seo domeny które po wznowieniu domeny powinny przełożyć się na adekwatny do parametrów współczynnik StatRank. Sprawdzarka działa na zasadzie czasochłonnej analizy, sugerujemy ponownie sprawdzić te same domeny po upływie 24 godzin od pierwszego sprawdzenia. Domeny (max 50szt rozdzielone spacją, przecinkiem lub nowym wierszem): SPRAWDŹ" [ref=e182]':
- generic [ref=e183]:
- text: SPRAWDŹ STATRANK
- generic [ref=e184]:
- text: Uwaga!!! Pamiętaj że ocena domen StatRank bazuje na frazach zaindeksowanych w wyszukiwarce. Jeżeli sprawdzasz domenę wygasłą która została częściowo lub w całości wyindeksowana z serpów wyszukiwarki wynik podany w sprawdzarce będzie zerowy niezależnie od realnego potencjału domeny. W takich przypadkach zalecamy sugerować się parametrami seo domeny które po wznowieniu domeny powinny przełożyć się na adekwatny do parametrów współczynnik StatRank.
- text: Sprawdzarka działa na zasadzie czasochłonnej analizy, sugerujemy ponownie sprawdzić te same domeny po upływie 24 godzin od pierwszego sprawdzenia.
- text: "Domeny (max 50szt rozdzielone spacją, przecinkiem lub nowym wierszem):"
- textbox [ref=e185]
- button "SPRAWDŹ" [ref=e186] [cursor=pointer]
- generic [ref=e188]:
- text: "|"
- link "Przejdź do góry" [ref=e189] [cursor=pointer]:
- /url: "#srodek"
- text: "|"
- table [ref=e190]:
- rowgroup [ref=e191]:
- row "Aktualności O systemie Regulamin Kontakt FAQ" [ref=e192]:
- cell "Aktualności O systemie Regulamin Kontakt FAQ" [ref=e193]:
- table [ref=e197]:
- rowgroup [ref=e198]:
- row "Aktualności O systemie Regulamin Kontakt FAQ" [ref=e199]:
- cell "Aktualności" [ref=e200]:
- link "Aktualności" [ref=e202] [cursor=pointer]:
- /url: /61,aktualnosci
- cell [ref=e203]:
- img [ref=e204]
- cell "O systemie" [ref=e205]:
- link "O systemie" [ref=e207] [cursor=pointer]:
- /url: /73,o-systemie
- cell [ref=e208]:
- img [ref=e209]
- cell "Regulamin" [ref=e210]:
- link "Regulamin" [ref=e212] [cursor=pointer]:
- /url: /28,regulamin
- cell [ref=e213]:
- img [ref=e214]
- cell "Kontakt" [ref=e215]:
- link "Kontakt" [ref=e217] [cursor=pointer]:
- /url: /5,kontakt
- img [ref=e218]
- text: Kontakt
- cell [ref=e219]:
- img [ref=e220]
- cell "FAQ" [ref=e221]:
- link "FAQ" [ref=e223] [cursor=pointer]:
- /url: /344,faq
- row "...Copyrights © 2008 Statlink.pl. Wszelkie prawa zastrzeżone..." [ref=e225]:
- cell "...Copyrights © 2008 Statlink.pl. Wszelkie prawa zastrzeżone..." [ref=e226]:
- generic [ref=e228]:
- text: ...Copyrights © 2008
- link "Statlink.pl" [ref=e229] [cursor=pointer]:
- /url: http://www.statlink.pl
- text: . Wszelkie prawa zastrzeżone...
- cell [ref=e230]

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
- generic [ref=e4]:
- generic [ref=e5]:
- heading " BackPRO" [level=3] [ref=e6]:
- generic [ref=e7]:
- text: BackPRO
- paragraph [ref=e8]: Zarządzanie Zapleczem SEO
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Login
- textbox "Login" [active] [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Hasło
- textbox "Hasło" [ref=e15]
- button "Zaloguj się" [ref=e16] [cursor=pointer]

View File

@@ -0,0 +1 @@
- heading "404 - Strona nie znaleziona" [level=1] [ref=e2]

View File

@@ -0,0 +1 @@
- heading "404 - Strona nie znaleziona" [level=1] [ref=e2]

View File

@@ -0,0 +1 @@
- generic [active] [ref=e1]: "{\"alive\":true,\"php\":\"8.5.4\",\"time\":\"11:07:12\"}"

View File

@@ -0,0 +1 @@
- generic [active] [ref=e1]: "{ \"alive\": true, \"php\": \"8.5.4\", \"time\": \"11:07:42\", \"opcache_enabled\": true, \"opcache_reset\": \"done\", \"controller_size\": 4973, \"controller_mtime\": \"2026-04-09 11:05:03\", \"service_size\": 29216, \"service_mtime\": \"2026-04-09 11:05:11\", \"index_size\": 725, \"index_mtime\": \"2026-04-09 11:06:34\", \"controller_first_100\": \""

View File

@@ -0,0 +1 @@
- generic [active] [ref=e1]: "{ \"alive\": true, \"php\": \"8.5.4\", \"time\": \"11:09:10\", \"opcache_enabled\": true, \"opcache_reset\": \"done\", \"controller_size\": 3806, \"controller_mtime\": \"2026-04-09 11:09:06\", \"service_size\": 29216, \"service_mtime\": \"2026-04-09 11:05:11\", \"index_size\": 725, \"index_mtime\": \"2026-04-09 11:06:34\", \"controller_first_100\": \""

2
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/cache
/project.local.yml

152
.serena/project.yml Normal file
View File

@@ -0,0 +1,152 @@
# the name by which the project can be referenced within Serena
project_name: "backPRO"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- php
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []

View File

@@ -1,6 +1,12 @@
{ {
"ftp://host700513.hostido.net.pl:21@www@backpro.projectpro.pl": { "ftp://host700513.hostido.net.pl:21@www@backpro.projectpro.pl": {
"public_html": { "public_html": {
"AGENTS.md": {
"type": "-",
"size": 3258,
"lmtime": 1772626517305,
"modified": false
},
"assets": { "assets": {
"css": { "css": {
"app.css": { "app.css": {
@@ -133,6 +139,12 @@
"size": 1874, "size": 1874,
"lmtime": 1771620180848, "lmtime": 1771620180848,
"modified": false "modified": false
},
"enable-indexing.php": {
"type": "-",
"size": 1606,
"lmtime": 1772626695405,
"modified": false
} }
}, },
"docs": { "docs": {
@@ -163,8 +175,8 @@
}, },
".env": { ".env": {
"type": "-", "type": "-",
"size": 389, "size": 447,
"lmtime": 1771626264475, "lmtime": 1772626863102,
"modified": false "modified": false
}, },
".env.example": { ".env.example": {
@@ -173,6 +185,12 @@
"lmtime": 1771669059812, "lmtime": 1771669059812,
"modified": false "modified": false
}, },
"GEMINI.md": {
"type": "-",
"size": 4249,
"lmtime": 0,
"modified": false
},
".htaccess": { ".htaccess": {
"type": "-", "type": "-",
"size": 701, "size": 701,
@@ -445,8 +463,8 @@
}, },
"InstallerService.php": { "InstallerService.php": {
"type": "-", "type": "-",
"size": 33181, "size": 33173,
"lmtime": 1771375416096, "lmtime": 1772626668989,
"modified": false "modified": false
}, },
"OpenAIService.php": { "OpenAIService.php": {
@@ -481,8 +499,8 @@
}, },
"WordPressService.php": { "WordPressService.php": {
"type": "-", "type": "-",
"size": 48742, "size": 50666,
"lmtime": 1771375416099, "lmtime": 1772626684669,
"modified": false "modified": false
} }
} }
@@ -501,12 +519,96 @@
"lmtime": 1771375416101, "lmtime": 1771375416101,
"modified": false "modified": false
}, },
"installer_2026-03-04.log": {
"type": "-",
"size": 1038,
"lmtime": 1772627085110,
"modified": false
},
"openai_2026-02-17.log": { "openai_2026-02-17.log": {
"type": "-", "type": "-",
"size": 118, "size": 118,
"lmtime": 1771375416102, "lmtime": 1771375416102,
"modified": false "modified": false
}, },
"openai_2026-02-18.log": {
"type": "-",
"size": 215,
"lmtime": 0,
"modified": false
},
"openai_2026-02-19.log": {
"type": "-",
"size": 454,
"lmtime": 0,
"modified": false
},
"openai_2026-02-20.log": {
"type": "-",
"size": 417,
"lmtime": 0,
"modified": false
},
"openai_2026-02-23.log": {
"type": "-",
"size": 367,
"lmtime": 0,
"modified": false
},
"openai_2026-02-24.log": {
"type": "-",
"size": 219,
"lmtime": 0,
"modified": false
},
"openai_2026-02-25.log": {
"type": "-",
"size": 131,
"lmtime": 0,
"modified": false
},
"openai_2026-02-26.log": {
"type": "-",
"size": 122,
"lmtime": 0,
"modified": false
},
"openai_2026-02-27.log": {
"type": "-",
"size": 345,
"lmtime": 0,
"modified": false
},
"openai_2026-02-28.log": {
"type": "-",
"size": 142,
"lmtime": 0,
"modified": false
},
"openai_2026-03-01.log": {
"type": "-",
"size": 113,
"lmtime": 0,
"modified": false
},
"openai_2026-03-02.log": {
"type": "-",
"size": 345,
"lmtime": 0,
"modified": false
},
"openai_2026-03-03.log": {
"type": "-",
"size": 131,
"lmtime": 0,
"modified": false
},
"openai_2026-03-04.log": {
"type": "-",
"size": 107,
"lmtime": 0,
"modified": false
},
"publish_2026-02-17.log": { "publish_2026-02-17.log": {
"type": "-", "type": "-",
"size": 2539, "size": 2539,
@@ -515,7 +617,37 @@
}, },
"publish_2026-02-18.log": { "publish_2026-02-18.log": {
"type": "-", "type": "-",
"size": 702, "size": 2565,
"lmtime": 0,
"modified": true
},
"publish_2026-02-19.log": {
"type": "-",
"size": 4815,
"lmtime": 0,
"modified": false
},
"publish_2026-02-20.log": {
"type": "-",
"size": 4982,
"lmtime": 0,
"modified": false
},
"semstorm_2026-02-20.log": {
"type": "-",
"size": 4412,
"lmtime": 0,
"modified": false
},
"semstorm_2026-02-21.log": {
"type": "-",
"size": 861,
"lmtime": 0,
"modified": false
},
"semstorm_2026-03-01.log": {
"type": "-",
"size": 818,
"lmtime": 0, "lmtime": 0,
"modified": false "modified": false
}, },
@@ -524,6 +656,12 @@
"size": 1471, "size": 1471,
"lmtime": 1771375416103, "lmtime": 1771375416103,
"modified": false "modified": false
},
"wordpress_2026-03-04.log": {
"type": "-",
"size": 1948,
"lmtime": 1772627089177,
"modified": false
} }
} }
}, },

36
CLAUDE.md Normal file
View File

@@ -0,0 +1,36 @@
# Projektowe zasady dla backPRO
## Stack
- **Język:** PHP 8.1+
- **Framework:** Custom (bez frameworka — PSR-4 autoload)
- **HTTP Client:** Guzzle 7
- **Config:** phpdotenv
- **Baza danych:** SQLite (migracje SQL)
- **Frontend:** PHP templates (vanilla)
## Zasady kodu
- Stosuj PSR-12 dla formatowania kodu PHP
- Nazewnictwo: PascalCase dla klas, camelCase dla metod, snake_case dla zmiennych DB
- Unikaj zagnieżdżeń > 3 poziomy — wydzielaj do metod
- Komentarze tylko gdy wyjaśniają "dlaczego", nie "co"
## Baza danych
- Schemat dokumentowany w `.paul/docs/DB_SCHEMA.md`
- Każda zmiana schematu wymaga migracji w `migrations/`
- Nie modyfikuj istniejących migracji — twórz nowe
- Konwencja nazw: `NNN_opis.sql` (np. `013_nowa_tabela.sql`)
## Testy
- Uzupełnij jak uruchamiać testy
## Dokumentacja
- Dokumentacja techniczna w `.paul/docs/`
- Przy każdej zmianie aktualizuj odpowiednie pliki docs
- `TECH_CHANGELOG.md` aktualizowany po każdej fazie
## Wdrażanie
- (Uzupełnij procedurę deploy)
## UI/Frontend
- PHP templates w `templates/`
- (Uzupełnij konwencje frontendowe — komponenty, style)

View File

@@ -31,36 +31,266 @@ a:hover {
color: var(--primary-hover); color: var(--primary-hover);
} }
/* Sidebar - Compact */ /* App Shell */
.app-shell {
display: flex;
min-height: 100vh;
}
.app-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
margin-left: 260px;
transition: margin-left 0.25s ease;
}
.app-shell.is-collapsed .app-main {
margin-left: 52px;
}
/* Sidebar */
.sidebar { .sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 260px;
background-color: var(--sidebar-bg); background-color: var(--sidebar-bg);
border-right: 1px solid rgba(255, 255, 255, 0.05); border-right: 1px solid rgba(255, 255, 255, 0.05);
z-index: 1000; z-index: 1000;
display: flex;
flex-direction: column;
transition: width 0.25s ease;
overflow: hidden;
} }
.sidebar-header { .app-shell.is-collapsed .sidebar {
padding: 1rem 1.25rem; /* Zmniejszony padding */ width: 52px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.sidebar .nav-link { .sidebar__brand {
color: var(--sidebar-link);
padding: 0.5rem 1rem; /* Zmniejszony padding */
margin: 0.15rem 0.75rem; /* Mniejsze marginesy */
border-radius: 0.375rem;
font-weight: 500;
font-size: 0.85rem; /* Mniejsza czcionka */
display: flex; display: flex;
align-items: center; align-items: center;
transition: all 0.2s ease; justify-content: space-between;
padding: 0.875rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
} }
.sidebar .nav-link i { .sidebar__brand-text {
font-size: 1rem; color: #e2e8f0;
margin-right: 0.75rem; font-size: 1.1rem;
font-weight: 400;
letter-spacing: -0.01em;
white-space: nowrap;
}
.sidebar__brand-text strong {
font-weight: 700;
color: #fff;
}
.sidebar__collapse-btn {
background: none;
border: none;
color: #64748b;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar__collapse-btn:hover {
color: #e2e8f0;
background: rgba(255,255,255,0.06);
}
.app-shell.is-collapsed .sidebar__collapse-icon {
transform: rotate(180deg);
}
.app-shell.is-collapsed .sidebar__brand-text,
.app-shell.is-collapsed .sidebar__label,
.app-shell.is-collapsed .sidebar__toggle-arrow,
.app-shell.is-collapsed .sidebar__user-info,
.app-shell.is-collapsed .sidebar__group-links {
display: none;
}
/* Sidebar Nav */
.sidebar__nav {
flex: 1;
overflow-y: auto;
padding: 8px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar__group {
margin: 0;
}
.sidebar__group > summary {
list-style: none;
}
.sidebar__group > summary::-webkit-details-marker {
display: none;
}
.sidebar__group-toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
color: #94a3b8;
font-size: 0.835rem;
font-weight: 500;
cursor: pointer;
border-radius: 6px;
margin: 0 8px;
transition: color 0.15s, background 0.15s;
user-select: none;
}
.sidebar__group-toggle:hover {
color: #e2e8f0;
background: #1b2a3f;
}
.sidebar__group.is-active > .sidebar__group-toggle {
color: #e2e8f0;
}
.sidebar__toggle-arrow {
margin-left: auto;
transition: transform 0.2s;
opacity: 0.5;
}
.sidebar__group[open] > .sidebar__group-toggle .sidebar__toggle-arrow {
transform: rotate(180deg);
}
.sidebar__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
opacity: 0.7; opacity: 0.7;
} }
.sidebar__label {
white-space: nowrap;
}
.sidebar__group-links {
padding: 2px 0 4px 0;
}
.sidebar__sublink {
display: block;
padding: 6px 14px 6px 52px;
color: #94a3b8;
font-size: 0.8rem;
font-weight: 400;
text-decoration: none;
border-radius: 6px;
margin: 1px 8px;
transition: color 0.15s, background 0.15s;
}
.sidebar__sublink:hover {
color: #e2e8f0;
background: #1b2a3f;
}
.sidebar__sublink.is-active {
color: #fff;
background: #2e4f93;
font-weight: 500;
}
/* Sidebar Footer */
.sidebar__footer {
padding: 12px 14px;
border-top: 1px solid rgba(255,255,255,0.06);
}
.sidebar__user {
display: flex;
align-items: center;
gap: 10px;
}
.sidebar__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #334155;
color: #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: 600;
flex-shrink: 0;
}
.sidebar__user-name {
color: #e2e8f0;
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__user-logout {
color: #64748b;
font-size: 0.75rem;
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
transition: color 0.15s;
}
.sidebar__user-logout:hover {
color: #e2e8f0;
}
/* Sidebar Backdrop (mobile) */
.sidebar-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 999;
}
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
}
.app-main {
margin-left: 0;
}
.app-shell.sidebar-open .sidebar {
transform: translateX(0);
}
.app-shell.sidebar-open .sidebar-backdrop {
display: block;
}
}
/* Header - Slim */ /* Header - Slim */
header { header {
background-color: rgba(255, 255, 255, 0.8) !important; background-color: rgba(255, 255, 255, 0.8) !important;

View File

@@ -285,10 +285,32 @@
return queueConfirm(message, options); return queueConfirm(message, options);
}; };
function initSidebarCollapse() {
var collapseBtn = document.getElementById('js-sidebar-collapse');
var appShell = document.getElementById('js-app-shell');
var backdrop = document.getElementById('js-sidebar-backdrop');
if (!collapseBtn || !appShell) return;
var saved = localStorage.getItem('backpro_sidebar_collapsed');
if (saved === '1') appShell.classList.add('is-collapsed');
collapseBtn.addEventListener('click', function () {
appShell.classList.toggle('is-collapsed');
localStorage.setItem('backpro_sidebar_collapsed', appShell.classList.contains('is-collapsed') ? '1' : '0');
});
if (backdrop) {
backdrop.addEventListener('click', function () {
appShell.classList.remove('sidebar-open');
});
}
}
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
installConfirmForForms(); installConfirmForForms();
initTestConnectionButtons(); initTestConnectionButtons();
initTopicEditButtons(); initTopicEditButtons();
highlightActiveSidebarLink(); highlightActiveSidebarLink();
initSidebarCollapse();
}); });
})(); })();

View File

@@ -72,6 +72,13 @@ $router->get('/installer', 'InstallerController', 'index');
$router->post('/installer', 'InstallerController', 'install'); $router->post('/installer', 'InstallerController', 'install');
$router->get('/installer/status/{id}', 'InstallerController', 'status'); $router->get('/installer/status/{id}', 'InstallerController', 'status');
// StatLink
$router->get('/statlink', 'StatLinkController', 'index');
$router->get('/statlink/token-run', 'StatLinkController', 'runByToken');
$router->post('/statlink/token-run', 'StatLinkController', 'runByToken');
// Settings // Settings
$router->get('/settings', 'SettingsController', 'index'); $router->get('/settings', 'SettingsController', 'index');
$router->post('/settings', 'SettingsController', 'update'); $router->post('/settings', 'SettingsController', 'update');
$router->get('/settings/database', 'SettingsController', 'database');
$router->post('/settings/database/migrate', 'SettingsController', 'migrate');

50
cron/statlink.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
// Lock file to prevent concurrent execution
$basePath = dirname(__DIR__);
$lockFile = $basePath . '/storage/logs/statlink.lock';
if (file_exists($lockFile)) {
$lockTime = filemtime($lockFile);
// If lock is older than 30 minutes, assume stale and remove
if (time() - $lockTime < 1800) {
echo "Another statlink process is running. Exiting.\n";
exit(0);
}
unlink($lockFile);
}
file_put_contents($lockFile, date('Y-m-d H:i:s'));
set_time_limit(300);
try {
require_once $basePath . '/vendor/autoload.php';
\App\Core\Config::load($basePath);
\App\Helpers\Logger::setBasePath($basePath);
$service = new \App\Services\StatLinkService();
// One action per run: prioritize removing expired, then add new
$removeResult = $service->removeExpiredLinks();
if ($removeResult['removed'] > 0 || $removeResult['errors'] > 0) {
echo sprintf("StatLink cron: removed=%d, errors=%d\n", $removeResult['removed'], $removeResult['errors']);
} else {
$addResult = $service->processNewArticles();
echo sprintf("StatLink cron: added=%d, skipped=%d, errors=%d\n", $addResult['added'], $addResult['skipped'], $addResult['errors']);
}
} catch (\Throwable $e) {
$message = "StatLink CRON Error: " . $e->getMessage();
echo $message . "\n";
if (class_exists(\App\Helpers\Logger::class)) {
\App\Helpers\Logger::error($message, 'statlink');
}
} finally {
if (file_exists($lockFile)) {
unlink($lockFile);
}
}

View File

@@ -0,0 +1,15 @@
-- Upgrade default article generation quality settings (safe for existing custom prompts)
UPDATE settings
SET value = 'Jestes doswiadczonym redaktorem SEO i ghostwriterem. Pisz po polsku naturalnie, konkretnie i bez AI-owych klisz. Artykul ma miec od {min_words} do {max_words} slow. Formatuj tresc tylko jako HTML (bez <html>, <body>, <head>) i zachowaj strukture: lead, sekcje H2/H3, listy praktyczne, sekcja FAQ. Uzywaj realnych przykladow i jezyka, ktory brzmi jak tekst napisany przez czlowieka z doswiadczeniem. Nie naduzywaj pytan retorycznych i unikaj fraz typu: "w dzisiejszych czasach", "podsumowujac", "warto zauwazyc". Zwracaj odpowiedz WYLACZNIE jako JSON: {"title":"...","content":"..."}'
WHERE `key` = 'article_generation_prompt'
AND value LIKE '%copywriterem SEO%'
AND value LIKE '%{min_words}%'
AND value LIKE '%{max_words}%';
UPDATE settings
SET value = '900'
WHERE `key` = 'article_min_words' AND value = '800';
UPDATE settings
SET value = '1400'
WHERE `key` = 'article_max_words' AND value = '1200';

View File

@@ -0,0 +1,10 @@
-- Internal linking smart matching: article URL + settings
ALTER TABLE articles
ADD COLUMN wp_post_url VARCHAR(1000) NULL AFTER wp_post_id;
INSERT INTO settings (`key`, value) VALUES
('internal_linking_enabled', '1'),
('internal_link_min_count', '2'),
('internal_link_max_count', '4'),
('openai_embedding_model', 'text-embedding-3-small')
ON DUPLICATE KEY UPDATE value = value;

View File

@@ -0,0 +1,18 @@
-- BackPRO StatLink.pl integration - link lifecycle tracking
CREATE TABLE IF NOT EXISTS statlink_links (
id INT AUTO_INCREMENT PRIMARY KEY,
article_id INT NOT NULL,
site_id INT NOT NULL,
statlink_id INT NULL COMMENT 'ID linku w systemie StatLink.pl',
anchor VARCHAR(500) NOT NULL,
link_url VARCHAR(500) NOT NULL,
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
removed_at DATETIME NULL,
status ENUM('active', 'expired', 'removed', 'failed') NOT NULL DEFAULT 'active',
error_message TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_article_id (article_id),
INDEX idx_status_expires (status, expires_at),
INDEX idx_site_id (site_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -137,6 +137,7 @@ class ArticleController extends Controller
} }
$content = (string) ($post['content']['rendered'] ?? ''); $content = (string) ($post['content']['rendered'] ?? '');
$postUrl = trim((string) ($post['link'] ?? ''));
$postDate = (string) ($post['date'] ?? ''); $postDate = (string) ($post['date'] ?? '');
$publishedAt = $postDate !== '' ? date('Y-m-d H:i:s', strtotime($postDate)) : date('Y-m-d H:i:s'); $publishedAt = $postDate !== '' ? date('Y-m-d H:i:s', strtotime($postDate)) : date('Y-m-d H:i:s');
if ($publishedAt === '1970-01-01 00:00:00') { if ($publishedAt === '1970-01-01 00:00:00') {
@@ -150,6 +151,7 @@ class ArticleController extends Controller
'title' => $title, 'title' => $title,
'content' => $content, 'content' => $content,
'wp_post_id' => $wpPostId, 'wp_post_id' => $wpPostId,
'wp_post_url' => $postUrl !== '' ? $postUrl : null,
'status' => 'published', 'status' => 'published',
'published_at' => $publishedAt, 'published_at' => $publishedAt,
]); ]);

View File

@@ -5,6 +5,8 @@ namespace App\Controllers;
use App\Core\Auth; use App\Core\Auth;
use App\Core\Config; use App\Core\Config;
use App\Core\Controller; use App\Core\Controller;
use App\Core\Database;
use App\Core\Database\Migrator;
use App\Services\ImageService; use App\Services\ImageService;
use App\Services\OpenAIService; use App\Services\OpenAIService;
@@ -20,6 +22,10 @@ class SettingsController extends Controller
'article_min_words', 'article_min_words',
'article_max_words', 'article_max_words',
'article_generation_prompt', 'article_generation_prompt',
'internal_linking_enabled',
'internal_link_min_count',
'internal_link_max_count',
'openai_embedding_model',
'image_generation_prompt', 'image_generation_prompt',
'semstorm_login', 'semstorm_login',
'semstorm_password', 'semstorm_password',
@@ -36,9 +42,13 @@ class SettingsController extends Controller
private array $settingDefaults = [ private array $settingDefaults = [
'openai_model' => 'gpt-4o', 'openai_model' => 'gpt-4o',
'image_provider' => 'freepik', 'image_provider' => 'freepik',
'article_min_words' => '800', 'article_min_words' => '900',
'article_max_words' => '1200', 'article_max_words' => '1400',
'article_generation_prompt' => OpenAIService::DEFAULT_ARTICLE_PROMPT_TEMPLATE, 'article_generation_prompt' => OpenAIService::DEFAULT_ARTICLE_PROMPT_TEMPLATE,
'internal_linking_enabled' => '1',
'internal_link_min_count' => '2',
'internal_link_max_count' => '4',
'openai_embedding_model' => 'text-embedding-3-small',
'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',
@@ -76,4 +86,48 @@ class SettingsController extends Controller
$this->flash('success', 'Ustawienia zostały zapisane.'); $this->flash('success', 'Ustawienia zostały zapisane.');
$this->redirect('/settings'); $this->redirect('/settings');
} }
public function database(): void
{
Auth::requireLogin();
$migrator = new Migrator(
Database::getInstance(),
dirname(__DIR__, 2) . '/migrations'
);
$status = $migrator->status();
$runLogs = $_SESSION['migration_logs'] ?? null;
unset($_SESSION['migration_logs']);
$this->view('settings/database', [
'status' => $status,
'runLogs' => $runLogs,
]);
}
public function migrate(): void
{
Auth::requireLogin();
$migrator = new Migrator(
Database::getInstance(),
dirname(__DIR__, 2) . '/migrations'
);
try {
$result = $migrator->runPending();
$_SESSION['migration_logs'] = $result['logs'];
if ($result['executed'] > 0) {
$this->flash('success', "Wykonano {$result['executed']} migracji.");
} else {
$this->flash('info', 'Brak oczekujacych migracji.');
}
} catch (\Throwable $e) {
$this->flash('danger', 'Blad migracji: ' . $e->getMessage());
}
$this->redirect('/settings/database');
}
} }

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Config;
use App\Core\Controller;
use App\Core\Database;
use App\Helpers\Logger;
use App\Services\StatLinkService;
class StatLinkController extends Controller
{
public function index(): void
{
Auth::requireLogin();
$db = Database::getInstance();
$page = max(1, (int) $this->input('page', 1));
$perPage = 20;
$offset = ($page - 1) * $perPage;
// Stats
$stats = [];
$statsStmt = $db->query(
"SELECT status, COUNT(*) as cnt FROM statlink_links GROUP BY status"
);
foreach ($statsStmt->fetchAll() as $row) {
$stats[$row['status']] = (int) $row['cnt'];
}
$totalLinks = array_sum($stats);
$totalPages = max(1, (int) ceil($totalLinks / $perPage));
// Links with article and site info
$stmt = $db->prepare(
"SELECT sl.*, a.title as article_title, s.name as site_name
FROM statlink_links sl
LEFT JOIN articles a ON sl.article_id = a.id
LEFT JOIN sites s ON sl.site_id = s.id
ORDER BY sl.added_at DESC
LIMIT :limit OFFSET :offset"
);
$stmt->bindValue('limit', $perPage, \PDO::PARAM_INT);
$stmt->bindValue('offset', $offset, \PDO::PARAM_INT);
$stmt->execute();
$links = $stmt->fetchAll();
$this->view('statlink/index', [
'links' => $links,
'stats' => $stats,
'page' => $page,
'totalPages' => $totalPages,
'totalLinks' => $totalLinks,
]);
}
public function runByToken(): void
{
$configuredToken = (string) Config::get('SEO_TRIGGER_TOKEN', '');
$providedToken = (string) $this->input('token', '');
if ($providedToken === '') {
$providedToken = (string) ($_SERVER['HTTP_X_STATLINK_TOKEN'] ?? '');
}
if ($configuredToken === '') {
$this->json(['success' => false, 'message' => 'Token trigger is disabled.'], 503);
return;
}
if ($providedToken === '' || !hash_equals($configuredToken, $providedToken)) {
$this->json(['success' => false, 'message' => 'Forbidden'], 403);
return;
}
@set_time_limit(300);
$service = new StatLinkService();
// One action per run: prioritize removing expired, then retry failed, then add new
$removeResult = $service->removeExpiredLinks();
if ($removeResult['removed'] > 0 || $removeResult['errors'] > 0) {
$this->json([
'success' => true,
'action' => 'remove',
'removed' => $removeResult['removed'],
'remove_errors' => $removeResult['errors'],
]);
return;
}
// Retry failed links before adding new ones
$retryResult = $service->retryFailedLinks();
if ($retryResult['retried'] > 0 || $retryResult['errors'] > 0) {
$this->json([
'success' => true,
'action' => 'retry',
'retried' => $retryResult['retried'],
'still_failed' => $retryResult['still_failed'],
'retry_errors' => $retryResult['errors'],
'diagnostics' => $service->getLastDiagnostics(),
]);
return;
}
$addResult = $service->processNewArticles();
$this->json([
'success' => true,
'action' => 'add',
'added' => $addResult['added'],
'skipped' => $addResult['skipped'],
'add_errors' => $addResult['errors'],
'diagnostics' => $service->getLastDiagnostics(),
]);
}
}

View File

@@ -19,7 +19,7 @@ abstract class Controller
{ {
http_response_code($statusCode); http_response_code($statusCode);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode($data); echo json_encode($data, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_UNICODE);
exit; exit;
} }

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Core\Database;
use PDO;
use RuntimeException;
use Throwable;
final class Migrator
{
public function __construct(
private readonly PDO $pdo,
private readonly string $migrationsPath
) {
}
/**
* @return array{total:int, applied:int, pending:int, pending_files:array<int, string>}
*/
public function status(): array
{
$this->ensureMigrationsTable();
$allFiles = $this->migrationFiles();
$appliedFiles = $this->appliedFilenames();
$pendingFiles = array_values(array_diff($allFiles, $appliedFiles));
return [
'total' => count($allFiles),
'applied' => count($appliedFiles),
'pending' => count($pendingFiles),
'pending_files' => $pendingFiles,
];
}
/**
* @return array{executed:int, skipped:int, logs:array<int, string>}
*/
public function runPending(): array
{
$this->acquireLock();
try {
$this->ensureMigrationsTable();
$allFiles = $this->migrationFiles();
$appliedFiles = $this->appliedFilenames();
$pendingFiles = array_values(array_diff($allFiles, $appliedFiles));
$insert = $this->pdo->prepare(
'INSERT INTO migrations (filename, executed_at) VALUES (:filename, :executed_at)'
);
$executed = 0;
$skipped = 0;
$logs = [];
foreach ($pendingFiles as $filename) {
$fullPath = rtrim($this->migrationsPath, '/\\') . DIRECTORY_SEPARATOR . $filename;
$sql = file_get_contents($fullPath);
if ($sql === false || trim($sql) === '') {
$skipped++;
$logs[] = '[skip-empty] ' . $filename;
continue;
}
try {
$this->pdo->beginTransaction();
$this->pdo->exec($sql);
$insert->execute([
'filename' => $filename,
'executed_at' => date('Y-m-d H:i:s'),
]);
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
$executed++;
$logs[] = '[ok] ' . $filename;
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$logs[] = '[error] ' . $filename . ' - ' . $exception->getMessage();
throw $exception;
}
}
return [
'executed' => $executed,
'skipped' => $skipped,
'logs' => $logs,
];
} finally {
$this->releaseLock();
}
}
private function ensureMigrationsTable(): void
{
$this->pdo->exec(
'CREATE TABLE IF NOT EXISTS migrations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(190) NOT NULL,
executed_at DATETIME NOT NULL,
UNIQUE KEY migrations_filename_unique (filename)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
}
private function acquireLock(): void
{
$statement = $this->pdo->query("SELECT GET_LOCK('backpro_migrations_lock', 10)");
$value = $statement !== false ? $statement->fetchColumn() : false;
if ((string) $value !== '1') {
throw new RuntimeException('Nie mozna uzyskac blokady migracji. Sprobuj ponownie za chwile.');
}
}
private function releaseLock(): void
{
$this->pdo->query("DO RELEASE_LOCK('backpro_migrations_lock')");
}
/**
* @return array<int, string>
*/
private function migrationFiles(): array
{
$files = glob(rtrim($this->migrationsPath, '/\\') . DIRECTORY_SEPARATOR . '*.sql');
if (!is_array($files)) {
return [];
}
$filenames = array_map(static fn(string $path): string => basename($path), $files);
sort($filenames);
return $filenames;
}
/**
* @return array<int, string>
*/
private function appliedFilenames(): array
{
$statement = $this->pdo->query('SELECT filename FROM migrations ORDER BY filename');
$rows = $statement->fetchAll(PDO::FETCH_COLUMN);
if (!is_array($rows)) {
return [];
}
return array_values(array_map(static fn(mixed $value): string => (string) $value, $rows));
}
}

View File

@@ -90,6 +90,22 @@ class Article extends Model
return $stmt->fetchAll(\PDO::FETCH_COLUMN); return $stmt->fetchAll(\PDO::FETCH_COLUMN);
} }
public static function findPublishedLinkCandidatesBySite(int $siteId, int $limit = 60): array
{
$stmt = self::db()->prepare(
"SELECT a.id, a.title, a.content, a.wp_post_id, a.wp_post_url, a.published_at, a.created_at
FROM articles a
WHERE a.site_id = :site_id
AND a.status = 'published'
AND a.wp_post_id IS NOT NULL
AND a.content <> ''
ORDER BY COALESCE(a.published_at, a.created_at) DESC, a.id DESC
LIMIT {$limit}"
);
$stmt->execute(['site_id' => $siteId]);
return $stmt->fetchAll();
}
public static function existsBySiteAndWpPostId(int $siteId, int $wpPostId): bool public static function existsBySiteAndWpPostId(int $siteId, int $wpPostId): bool
{ {
$stmt = self::db()->prepare( $stmt = self::db()->prepare(

View File

@@ -667,8 +667,10 @@ PHP;
); );
if ($wpPostId) { if ($wpPostId) {
$wpPostUrl = $wp->getPostLink($site, (int) $wpPostId);
Article::update((int) $article['id'], [ Article::update((int) $article['id'], [
'wp_post_id' => (int) $wpPostId, 'wp_post_id' => (int) $wpPostId,
'wp_post_url' => $wpPostUrl,
'status' => 'published', 'status' => 'published',
'error_message' => null, 'error_message' => null,
]); ]);

View File

@@ -0,0 +1,479 @@
<?php
namespace App\Services;
use App\Core\Config;
use App\Helpers\Logger;
use App\Models\Article;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
class InternalLinkService
{
private const DEFAULT_MIN_LINKS = 2;
private const DEFAULT_MAX_LINKS = 4;
private const DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small';
private const EMBEDDING_INPUT_MAX_CHARS = 1800;
private const MIN_PARAGRAPH_LENGTH = 120;
private Client $client;
public function __construct()
{
$this->client = new Client([
'base_uri' => 'https://api.openai.com/v1/',
'timeout' => 45,
]);
}
public function enrichContentWithInternalLinks(array $site, string $title, string $htmlContent): array
{
if (!$this->isEnabled()) {
return [
'content' => $htmlContent,
'links_added' => 0,
'targets' => [],
'mode' => 'disabled',
];
}
$siteId = (int) ($site['id'] ?? 0);
if ($siteId <= 0 || trim($htmlContent) === '') {
return [
'content' => $htmlContent,
'links_added' => 0,
'targets' => [],
'mode' => 'invalid_input',
];
}
$maxLinks = $this->sanitizeLimit(Config::getDbSetting('internal_link_max_count', (string) self::DEFAULT_MAX_LINKS), self::DEFAULT_MAX_LINKS, 1, 8);
$minLinks = $this->sanitizeLimit(Config::getDbSetting('internal_link_min_count', (string) self::DEFAULT_MIN_LINKS), self::DEFAULT_MIN_LINKS, 0, $maxLinks);
$candidates = Article::findPublishedLinkCandidatesBySite($siteId, 80);
if (count($candidates) === 0) {
return [
'content' => $htmlContent,
'links_added' => 0,
'targets' => [],
'mode' => 'no_candidates',
];
}
$ranked = $this->rankCandidates($site, $title, $htmlContent, $candidates);
if (count($ranked) === 0) {
return [
'content' => $htmlContent,
'links_added' => 0,
'targets' => [],
'mode' => 'ranking_empty',
];
}
$selected = array_slice($ranked, 0, $maxLinks);
if (count($selected) < $minLinks && count($ranked) >= $minLinks) {
$selected = array_slice($ranked, 0, $minLinks);
}
$injected = $this->injectLinksIntoParagraphs($htmlContent, $selected);
return [
'content' => $injected['content'],
'links_added' => $injected['links_added'],
'targets' => $injected['targets'],
'mode' => $injected['mode'],
];
}
private function isEnabled(): bool
{
$raw = (string) Config::getDbSetting('internal_linking_enabled', '1');
return $raw !== '0';
}
private function rankCandidates(array $site, string $title, string $htmlContent, array $candidates): array
{
$sourceText = $this->buildEmbeddingText($title, $htmlContent);
$candidatePayload = [];
foreach ($candidates as $candidate) {
$candidateId = (int) ($candidate['id'] ?? 0);
$candidateWpPostId = (int) ($candidate['wp_post_id'] ?? 0);
$candidateTitle = trim((string) ($candidate['title'] ?? ''));
if ($candidateId <= 0 || $candidateWpPostId <= 0 || $candidateTitle === '') {
continue;
}
$url = $this->resolveCandidateUrl($site, $candidate);
if ($url === null) {
continue;
}
$candidatePayload[] = [
'id' => $candidateId,
'title' => $candidateTitle,
'url' => $url,
'text' => $this->buildEmbeddingText($candidateTitle, (string) ($candidate['content'] ?? '')),
];
}
if (count($candidatePayload) === 0) {
return [];
}
$semanticScores = $this->calculateSemanticScores($sourceText, $candidatePayload);
if ($semanticScores === null) {
Logger::warning('Internal linking fallback to lexical ranking (embeddings unavailable).', 'publish');
return $this->calculateLexicalRanking($sourceText, $candidatePayload);
}
usort($semanticScores, static fn (array $a, array $b) => $b['score'] <=> $a['score']);
return array_values(array_filter($semanticScores, static fn (array $row) => $row['score'] > 0.15));
}
private function calculateSemanticScores(string $sourceText, array $candidates): ?array
{
$apiKey = Config::getDbSetting('openai_api_key', Config::get('OPENAI_API_KEY'));
if (!is_string($apiKey) || trim($apiKey) === '') {
return null;
}
$model = (string) Config::getDbSetting('openai_embedding_model', self::DEFAULT_EMBEDDING_MODEL);
$inputs = [$sourceText];
foreach ($candidates as $candidate) {
$inputs[] = $candidate['text'];
}
try {
$response = $this->client->post('embeddings', [
'headers' => [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
],
'json' => [
'model' => $model,
'input' => $inputs,
],
]);
} catch (GuzzleException $e) {
Logger::warning('Embeddings API error for internal linking: ' . $e->getMessage(), 'publish');
return null;
}
$data = json_decode($response->getBody()->getContents(), true);
if (!is_array($data) || !isset($data['data']) || !is_array($data['data']) || count($data['data']) !== count($inputs)) {
Logger::warning('Embeddings response invalid for internal linking.', 'publish');
return null;
}
$vectors = [];
foreach ($data['data'] as $item) {
$embedding = $item['embedding'] ?? null;
if (!is_array($embedding)) {
return null;
}
$vectors[] = array_map('floatval', $embedding);
}
$sourceVector = $vectors[0];
$ranked = [];
foreach ($candidates as $index => $candidate) {
$candidateVector = $vectors[$index + 1] ?? null;
if (!is_array($candidateVector)) {
continue;
}
$ranked[] = [
'id' => $candidate['id'],
'title' => $candidate['title'],
'url' => $candidate['url'],
'score' => $this->cosineSimilarity($sourceVector, $candidateVector),
];
}
return $ranked;
}
private function calculateLexicalRanking(string $sourceText, array $candidates): array
{
$sourceTokens = $this->tokenize($sourceText);
$sourceSet = array_fill_keys($sourceTokens, true);
$ranked = [];
foreach ($candidates as $candidate) {
$targetTokens = $this->tokenize($candidate['text']);
if (count($targetTokens) === 0) {
continue;
}
$shared = 0;
foreach ($targetTokens as $token) {
if (isset($sourceSet[$token])) {
$shared++;
}
}
$score = $shared / max(1, count(array_unique($targetTokens)));
$ranked[] = [
'id' => $candidate['id'],
'title' => $candidate['title'],
'url' => $candidate['url'],
'score' => $score,
];
}
usort($ranked, static fn (array $a, array $b) => $b['score'] <=> $a['score']);
return $ranked;
}
private function injectLinksIntoParagraphs(string $html, array $selected): array
{
if (!class_exists(\DOMDocument::class) || !class_exists(\DOMXPath::class)) {
return [
'content' => $html,
'links_added' => 0,
'targets' => [],
'mode' => 'dom_extension_missing',
];
}
$doc = new \DOMDocument('1.0', 'UTF-8');
$wrappedHtml = '<div id="backpro-root">' . $html . '</div>';
libxml_use_internal_errors(true);
$loaded = $doc->loadHTML('<?xml encoding="utf-8" ?>' . $wrappedHtml, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
if (!$loaded) {
return [
'content' => $html,
'links_added' => 0,
'targets' => [],
'mode' => 'dom_parse_failed',
];
}
$xpath = new \DOMXPath($doc);
$paragraphNodes = $xpath->query('//div[@id="backpro-root"]//p');
if ($paragraphNodes === false || $paragraphNodes->length === 0) {
return [
'content' => $html,
'links_added' => 0,
'targets' => [],
'mode' => 'no_paragraphs',
];
}
$availableParagraphIndexes = [];
for ($i = 0; $i < $paragraphNodes->length; $i++) {
$paragraphText = trim((string) $paragraphNodes->item($i)?->textContent);
if (mb_strlen($paragraphText) >= self::MIN_PARAGRAPH_LENGTH) {
$availableParagraphIndexes[] = $i;
}
}
if (count($availableParagraphIndexes) === 0) {
return [
'content' => $html,
'links_added' => 0,
'targets' => [],
'mode' => 'paragraphs_too_short',
];
}
$usedParagraphs = [];
$targets = [];
$linksAdded = 0;
foreach ($selected as $candidate) {
$index = $this->pickBestParagraphIndex($paragraphNodes, $availableParagraphIndexes, $usedParagraphs, (string) $candidate['title']);
if ($index === null) {
continue;
}
$paragraph = $paragraphNodes->item($index);
if (!$paragraph instanceof \DOMElement) {
continue;
}
$paragraph->appendChild($doc->createTextNode(' Sprawdz tez: '));
$anchor = $doc->createElement('a', (string) $candidate['title']);
$anchor->setAttribute('href', (string) $candidate['url']);
$anchor->setAttribute('title', (string) $candidate['title']);
$paragraph->appendChild($anchor);
$paragraph->appendChild($doc->createTextNode('.'));
$usedParagraphs[$index] = true;
$targets[] = [
'title' => (string) $candidate['title'],
'url' => (string) $candidate['url'],
'score' => (float) ($candidate['score'] ?? 0.0),
];
$linksAdded++;
}
$root = $xpath->query('//div[@id="backpro-root"]')->item(0);
if (!$root instanceof \DOMElement) {
return [
'content' => $html,
'links_added' => 0,
'targets' => [],
'mode' => 'root_missing',
];
}
$newHtml = '';
foreach ($root->childNodes as $child) {
$newHtml .= $doc->saveHTML($child);
}
return [
'content' => $newHtml !== '' ? $newHtml : $html,
'links_added' => $linksAdded,
'targets' => $targets,
'mode' => 'ok',
];
}
private function pickBestParagraphIndex(\DOMNodeList $paragraphNodes, array $candidateIndexes, array $usedParagraphs, string $title): ?int
{
$titleTokens = array_values(array_unique($this->tokenize($title)));
$bestIndex = null;
$bestScore = -1;
foreach ($candidateIndexes as $index) {
if (isset($usedParagraphs[$index])) {
continue;
}
$paragraphNode = $paragraphNodes->item($index);
if (!$paragraphNode instanceof \DOMElement) {
continue;
}
$paragraphText = mb_strtolower(trim((string) $paragraphNode->textContent));
if ($paragraphText === '') {
continue;
}
$score = 0;
foreach ($titleTokens as $token) {
if (mb_strlen($token) < 4) {
continue;
}
if (str_contains($paragraphText, $token)) {
$score++;
}
}
if ($score > $bestScore) {
$bestScore = $score;
$bestIndex = $index;
}
}
return $bestIndex;
}
private function buildEmbeddingText(string $title, string $htmlContent): string
{
$cleanTitle = trim($title);
$plain = trim((string) preg_replace('/\s+/u', ' ', strip_tags($htmlContent)));
$text = $cleanTitle;
if ($plain !== '') {
$text .= "\n\n" . mb_substr($plain, 0, self::EMBEDDING_INPUT_MAX_CHARS);
}
return trim($text);
}
private function resolveCandidateUrl(array $site, array $candidate): ?string
{
$storedUrl = trim((string) ($candidate['wp_post_url'] ?? ''));
if ($storedUrl !== '') {
return $storedUrl;
}
$siteUrl = rtrim((string) ($site['url'] ?? ''), '/');
$wpPostId = (int) ($candidate['wp_post_id'] ?? 0);
if ($siteUrl === '' || $wpPostId <= 0) {
return null;
}
return $siteUrl . '/?p=' . $wpPostId;
}
private function cosineSimilarity(array $a, array $b): float
{
$count = min(count($a), count($b));
if ($count === 0) {
return 0.0;
}
$dot = 0.0;
$normA = 0.0;
$normB = 0.0;
for ($i = 0; $i < $count; $i++) {
$dot += $a[$i] * $b[$i];
$normA += $a[$i] * $a[$i];
$normB += $b[$i] * $b[$i];
}
if ($normA <= 0.0 || $normB <= 0.0) {
return 0.0;
}
return $dot / (sqrt($normA) * sqrt($normB));
}
private function tokenize(string $text): array
{
$text = mb_strtolower($text);
$text = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $text) ?? $text;
$parts = preg_split('/\s+/u', $text);
if (!is_array($parts)) {
return [];
}
$stopWords = [
'oraz', 'ktory', 'ktora', 'ktore', 'ktorych', 'przez', 'takze', 'bardzo', 'mozna', 'mozesz',
'czyli', 'jest', 'sa', 'ten', 'ta', 'to', 'dla', 'pod', 'nad', 'sie', 'jako', 'aby', 'albo', 'lub',
'or', 'and', 'the', 'with', 'from', 'that', 'this', 'tego', 'tych', 'tym', 'juz', 'wiecej', 'mniej',
];
$stopSet = array_fill_keys($stopWords, true);
$tokens = [];
foreach ($parts as $part) {
$token = trim($part);
if ($token === '' || mb_strlen($token) < 3) {
continue;
}
if (isset($stopSet[$token])) {
continue;
}
$tokens[] = $token;
}
return $tokens;
}
private function sanitizeLimit(mixed $value, int $default, int $min, int $max): int
{
$intValue = (int) $value;
if ($intValue === 0) {
$intValue = $default;
}
if ($intValue < $min) {
return $min;
}
if ($intValue > $max) {
return $max;
}
return $intValue;
}
}

View File

@@ -2,14 +2,16 @@
namespace App\Services; namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use App\Core\Config; use App\Core\Config;
use App\Helpers\Logger; use App\Helpers\Logger;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
class OpenAIService class OpenAIService
{ {
public const DEFAULT_ARTICLE_PROMPT_TEMPLATE = 'Jesteś doświadczonym copywriterem SEO. Pisz artykuły w języku polskim, optymalizowane pod SEO. Artykuł powinien mieć {min_words}-{max_words} słów, zawierać nagłówki H2 i H3, być angażujący i merytoryczny. Formatuj treść w HTML (bez tagów <html>, <body>, <head>). Zwróć odpowiedź WYŁĄCZNIE w formacie JSON: {"title": "tytuł artykułu", "content": "treść HTML artykułu"}'; public const DEFAULT_ARTICLE_PROMPT_TEMPLATE = 'Jestes doswiadczonym redaktorem SEO i ghostwriterem. Pisz po polsku naturalnie, konkretnie i bez AI-owych klisz. Artykul ma miec od {min_words} do {max_words} slow. Formatuj tresc tylko jako HTML (bez <html>, <body>, <head>) i zachowaj strukture: lead, sekcje H2/H3, listy praktyczne, sekcja FAQ. Uzywaj realnych przykladow i jezyka, ktory brzmi jak tekst napisany przez czlowieka z doswiadczeniem. Nie naduzywaj pytan retorycznych i unikaj fraz typu: "w dzisiejszych czasach", "podsumowujac", "warto zauwazyc". Zwracaj odpowiedz WYLACZNIE jako JSON: {"title":"...","content":"..."}';
private const MIN_ACCEPTABLE_H2 = 3;
private Client $client; private Client $client;
@@ -25,8 +27,13 @@ class OpenAIService
{ {
$apiKey = Config::getDbSetting('openai_api_key', Config::get('OPENAI_API_KEY')); $apiKey = Config::getDbSetting('openai_api_key', Config::get('OPENAI_API_KEY'));
$model = Config::getDbSetting('openai_model', Config::get('OPENAI_MODEL', 'gpt-4o')); $model = Config::getDbSetting('openai_model', Config::get('OPENAI_MODEL', 'gpt-4o'));
$minWords = Config::getDbSetting('article_min_words', '800'); $minWords = $this->sanitizeWordLimit(Config::getDbSetting('article_min_words', '900'), 900);
$maxWords = Config::getDbSetting('article_max_words', '1200'); $maxWords = $this->sanitizeWordLimit(Config::getDbSetting('article_max_words', '1400'), 1400);
if ($maxWords < $minWords) {
$maxWords = $minWords;
}
$systemPromptTemplate = Config::getDbSetting('article_generation_prompt', self::DEFAULT_ARTICLE_PROMPT_TEMPLATE); $systemPromptTemplate = Config::getDbSetting('article_generation_prompt', self::DEFAULT_ARTICLE_PROMPT_TEMPLATE);
if (!is_string($systemPromptTemplate) || trim($systemPromptTemplate) === '') { if (!is_string($systemPromptTemplate) || trim($systemPromptTemplate) === '') {
@@ -40,22 +47,70 @@ class OpenAIService
$existingList = !empty($existingTitles) $existingList = !empty($existingTitles)
? implode("\n- ", $existingTitles) ? implode("\n- ", $existingTitles)
: '(brak - to pierwszy artykuł z tego tematu)'; : '(brak - to pierwszy artykul z tego tematu)';
$systemPrompt = strtr($systemPromptTemplate, [ $systemPrompt = strtr($systemPromptTemplate, [
'{min_words}' => (string) $minWords, '{min_words}' => (string) $minWords,
'{max_words}' => (string) $maxWords, '{max_words}' => (string) $maxWords,
]); ]);
$userPrompt = "Napisz artykuł na temat: {$topicName}\n"; $qualityFeedback = '';
$userPrompt .= "Tytul ma byc samodzielny i nie moze zaczynac sie od nazwy tematu ani kategorii.\n"; $lastPrompt = '';
if (!empty($topicDescription)) {
$userPrompt .= "Wytyczne: {$topicDescription}\n"; for ($attempt = 1; $attempt <= 2; $attempt++) {
$userPrompt = $this->buildUserPrompt(
$topicName,
$topicDescription,
$existingList,
$qualityFeedback,
$minWords,
$maxWords
);
$fullPrompt = $systemPrompt . "\n\n" . $userPrompt;
$lastPrompt = $fullPrompt;
$content = $this->requestStructuredArticle($apiKey, (string) $model, $systemPrompt, $userPrompt);
if ($content === null) {
continue;
}
$article = json_decode($content, true);
if (!isset($article['title']) || !isset($article['content'])) {
Logger::error('Invalid JSON structure from OpenAI: ' . $content, 'openai');
$qualityFeedback = 'Zwroc poprawny JSON z polami "title" i "content".';
continue;
}
$title = trim((string) ($article['title'] ?? ''));
$htmlContent = trim((string) ($article['content'] ?? ''));
$quality = $this->validateQuality($title, $htmlContent, $minWords);
if (!$quality['ok']) {
$qualityFeedback = 'Poprzednia wersja nie spelnila wymagan: ' . implode('; ', $quality['issues']) . '. Napisz nowa, lepsza wersje.';
Logger::warning(
'Generated article quality retry for topic "' . $topicName . '" (attempt ' . $attempt . '): ' . implode('; ', $quality['issues']),
'openai'
);
continue;
}
Logger::info('Generated article: ' . $title, 'openai');
return [
'title' => $title,
'content' => $htmlContent,
'model' => (string) $model,
'prompt' => $fullPrompt,
];
} }
$userPrompt .= "\nWAŻNE - NIE pisz o następujących tematach, bo artykuły o nich już istnieją na stronie:\n- {$existingList}";
$fullPrompt = $systemPrompt . "\n\n" . $userPrompt; Logger::error('OpenAI generation failed after quality retries', 'openai');
return null;
}
private function requestStructuredArticle(string $apiKey, string $model, string $systemPrompt, string $userPrompt): ?string
{
try { try {
$response = $this->client->post('chat/completions', [ $response = $this->client->post('chat/completions', [
'headers' => [ 'headers' => [
@@ -68,38 +123,125 @@ class OpenAIService
['role' => 'system', 'content' => $systemPrompt], ['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt], ['role' => 'user', 'content' => $userPrompt],
], ],
'temperature' => 0.8, 'temperature' => 0.9,
'max_tokens' => 4000, 'max_tokens' => 5000,
'response_format' => ['type' => 'json_object'], 'response_format' => ['type' => 'json_object'],
], ],
]); ]);
$data = json_decode($response->getBody()->getContents(), true);
$content = $data['choices'][0]['message']['content'] ?? null;
if (!$content) {
Logger::error('Empty response from OpenAI', 'openai');
return null;
}
$article = json_decode($content, true);
if (!isset($article['title']) || !isset($article['content'])) {
Logger::error('Invalid JSON structure from OpenAI: ' . $content, 'openai');
return null;
}
Logger::info("Generated article: {$article['title']}", 'openai');
return [
'title' => $article['title'],
'content' => $article['content'],
'model' => $model,
'prompt' => $fullPrompt,
];
} catch (GuzzleException $e) { } catch (GuzzleException $e) {
Logger::error('OpenAI API error: ' . $e->getMessage(), 'openai'); Logger::error('OpenAI API error: ' . $e->getMessage(), 'openai');
return null; return null;
} }
$data = json_decode($response->getBody()->getContents(), true);
$content = $data['choices'][0]['message']['content'] ?? null;
if (!is_string($content) || trim($content) === '') {
Logger::error('Empty response from OpenAI', 'openai');
return null;
}
return $content;
}
private function buildUserPrompt(
string $topicName,
string $topicDescription,
string $existingList,
string $qualityFeedback,
int $minWords,
int $maxWords
): string {
$prompt = "Napisz artykul na temat: {$topicName}\n";
$prompt .= "Docelowa dlugosc: {$minWords}-{$maxWords} slow.\n";
$prompt .= "Tytul ma byc samodzielny i nie moze zaczynac sie od nazwy tematu ani kategorii.\n";
$prompt .= "Tresc ma byc konkretna, praktyczna i naturalna. Bez ogolnikow.\n";
$prompt .= "Wstep: 2-3 krotkie akapity i jasna obietnica, czego czytelnik sie dowie.\n";
$prompt .= "Srodek: minimum 3 sekcje H2, w kazdej przynajmniej jeden konkret (przyklad, liczba, scenariusz, checklista).\n";
$prompt .= "Wstaw jedna sekcje H2 o nazwie \"Najczestsze bledy\" i jedna H2 \"FAQ\" z 3 pytaniami i odpowiedziami.\n";
$prompt .= "Zakonczenie ma byc praktyczne: \"Co warto zapamietac\" jako lista punktowana.\n";
$prompt .= "Uzywaj tylko HTML: <p>, <h2>, <h3>, <ul>, <ol>, <li>, <strong>, <em>, <blockquote>, <table>, <tr>, <th>, <td>.\n";
if ($topicDescription !== '') {
$prompt .= "Wytyczne redakcyjne od klienta: {$topicDescription}\n";
}
$prompt .= "\nNie powielaj tematow, bo te tytuly juz istnieja:\n- {$existingList}\n";
if ($qualityFeedback !== '') {
$prompt .= "\nKrytyczne poprawki do wdrozenia: {$qualityFeedback}\n";
}
return $prompt;
}
private function validateQuality(string $title, string $content, int $minWords): array
{
$issues = [];
$wordCount = $this->countWords($content);
if ($title === '') {
$issues[] = 'brak tytulu';
}
if ($content === '') {
$issues[] = 'brak tresci';
}
if ($wordCount < $minWords) {
$issues[] = 'za malo slow (' . $wordCount . ')';
}
$h2Count = preg_match_all('/<h2\b[^>]*>/i', $content);
if ($h2Count < self::MIN_ACCEPTABLE_H2) {
$issues[] = 'za malo naglowkow H2';
}
if (preg_match('/<h2\b[^>]*>\s*faq\s*<\/h2>/iu', $content) !== 1) {
$issues[] = 'brak sekcji FAQ';
}
if (!str_contains(mb_strtolower($content), 'co warto zapamietac')) {
$issues[] = 'brak sekcji koncowej z konkretami';
}
return [
'ok' => empty($issues),
'issues' => $issues,
];
}
private function countWords(string $html): int
{
$plain = trim(strip_tags($html));
if ($plain === '') {
return 0;
}
$parts = preg_split('/\s+/u', $plain);
if (!is_array($parts)) {
return 0;
}
return count(array_filter($parts, static fn ($item) => $item !== ''));
}
private function sanitizeWordLimit(mixed $value, int $default): int
{
$intValue = (int) $value;
if ($intValue === 0) {
return $default;
}
if ($intValue < 400) {
return 400;
}
if ($intValue > 4000) {
return 4000;
}
return $intValue;
} }
} }

View File

@@ -11,6 +11,7 @@ class PublisherService
{ {
private TopicBalancer $topicBalancer; private TopicBalancer $topicBalancer;
private OpenAIService $openAI; private OpenAIService $openAI;
private InternalLinkService $internalLinkService;
private ImageService $imageService; private ImageService $imageService;
private WordPressService $wordpress; private WordPressService $wordpress;
@@ -18,6 +19,7 @@ class PublisherService
{ {
$this->topicBalancer = new TopicBalancer(); $this->topicBalancer = new TopicBalancer();
$this->openAI = new OpenAIService(); $this->openAI = new OpenAIService();
$this->internalLinkService = new InternalLinkService();
$this->imageService = new ImageService(); $this->imageService = new ImageService();
$this->wordpress = new WordPressService(); $this->wordpress = new WordPressService();
} }
@@ -103,6 +105,18 @@ class PublisherService
private function publishPreparedArticle(array $site, array $topic, array $article, ?int $existingArticleId = null): array private function publishPreparedArticle(array $site, array $topic, array $article, ?int $existingArticleId = null): array
{ {
$linkingResult = $this->internalLinkService->enrichContentWithInternalLinks(
$site,
(string) ($article['title'] ?? ''),
(string) ($article['content'] ?? '')
);
$article['content'] = (string) ($linkingResult['content'] ?? (string) ($article['content'] ?? ''));
Logger::info(
'Internal linking mode=' . (string) ($linkingResult['mode'] ?? 'unknown')
. ', links_added=' . (int) ($linkingResult['links_added'] ?? 0),
'publish'
);
$imageUrl = null; $imageUrl = null;
$mediaId = null; $mediaId = null;
$image = $this->imageService->generate((string) $article['title'], (string) $topic['name']); $image = $this->imageService->generate((string) $article['title'], (string) $topic['name']);
@@ -122,7 +136,8 @@ class PublisherService
(string) $article['title'], (string) $article['title'],
(string) $article['content'], (string) $article['content'],
$topic['wp_category_id'], $topic['wp_category_id'],
$mediaId $mediaId,
$this->buildExcerpt((string) $article['content'])
); );
if (!$wpPostId) { if (!$wpPostId) {
@@ -137,12 +152,14 @@ class PublisherService
} }
Logger::info("Opublikowano post: wp_post_id={$wpPostId}", 'publish'); Logger::info("Opublikowano post: wp_post_id={$wpPostId}", 'publish');
$wpPostUrl = $this->wordpress->getPostLink($site, (int) $wpPostId);
if ($existingArticleId !== null) { if ($existingArticleId !== null) {
Article::update($existingArticleId, [ Article::update($existingArticleId, [
'title' => (string) $article['title'], 'title' => (string) $article['title'],
'content' => (string) $article['content'], 'content' => (string) $article['content'],
'wp_post_id' => $wpPostId, 'wp_post_id' => $wpPostId,
'wp_post_url' => $wpPostUrl,
'image_url' => $imageUrl, 'image_url' => $imageUrl,
'status' => 'published', 'status' => 'published',
'ai_model' => $article['model'] ?? null, 'ai_model' => $article['model'] ?? null,
@@ -157,6 +174,7 @@ class PublisherService
'title' => (string) $article['title'], 'title' => (string) $article['title'],
'content' => (string) $article['content'], 'content' => (string) $article['content'],
'wp_post_id' => $wpPostId, 'wp_post_id' => $wpPostId,
'wp_post_url' => $wpPostUrl,
'image_url' => $imageUrl, 'image_url' => $imageUrl,
'status' => 'published', 'status' => 'published',
'ai_model' => $article['model'] ?? null, 'ai_model' => $article['model'] ?? null,
@@ -281,4 +299,27 @@ class PublisherService
return ['success' => true, 'message' => $message]; return ['success' => true, 'message' => $message];
} }
private function buildExcerpt(string $htmlContent): string
{
$plain = trim((string) preg_replace('/\s+/u', ' ', strip_tags($htmlContent)));
if ($plain === '') {
return '';
}
$maxLength = 155;
if (mb_strlen($plain) <= $maxLength) {
return $plain;
}
$cut = mb_substr($plain, 0, $maxLength + 1);
$lastSpace = mb_strrpos($cut, ' ');
if ($lastSpace !== false && $lastSpace > 80) {
$cut = mb_substr($cut, 0, $lastSpace);
} else {
$cut = mb_substr($cut, 0, $maxLength);
}
return rtrim($cut, " \t\n\r\0\x0B.,;:!?") . '.';
}
} }

View File

@@ -0,0 +1,720 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Config;
use App\Core\Database;
use App\Helpers\Logger;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
class StatLinkService
{
private Client $http;
private CookieJar $cookies;
private bool $loggedIn = false;
private string $baseUrl;
private string $login;
private string $password;
private const MAX_LINKS_PER_RUN = 1;
private const LINK_LIFETIME_DAYS = 60;
public function __construct()
{
$this->baseUrl = rtrim((string) Config::get('statlink_url', 'https://statlink.pl'), '/');
$this->login = (string) Config::get('statlink_login', '');
$this->password = (string) Config::get('statlink_password', '');
$this->cookies = new CookieJar();
$this->http = new Client([
'cookies' => $this->cookies,
'connect_timeout' => 60,
'timeout' => 120,
'verify' => false,
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language' => 'pl-PL,pl;q=0.9,en;q=0.8',
],
]);
}
public function login(): bool
{
if ($this->loggedIn) {
return true;
}
if ($this->login === '' || $this->password === '') {
$this->loginDiagnostic = 'FAIL: brak danych logowania w .env';
Logger::error('StatLink: brak danych logowania w .env', 'statlink');
return false;
}
try {
// First GET the homepage to pick up session cookies and hidden fields
$homePage = $this->http->get($this->baseUrl . '/');
$homeBody = (string) $homePage->getBody();
$postujForm = '';
if (preg_match('/name="postuj_form"\s+value="([^"]*)"/', $homeBody, $pfm)) {
$postujForm = $pfm[1];
}
$response = $this->http->post($this->baseUrl . '/20,zaloguj', [
'form_params' => [
'email' => $this->login,
'haslo' => $this->password,
'zaloguj' => 'ZALOGUJ',
'postuj_form' => $postujForm,
],
'allow_redirects' => true,
]);
$body = (string) $response->getBody();
if (str_contains($body, 'Zalogowano') || str_contains($body, 'zalogowany') || str_contains($body, 'Vampirius')) {
$this->loggedIn = true;
$this->loginDiagnostic = 'OK: zalogowano';
Logger::info('StatLink: zalogowano pomyslnie', 'statlink');
return true;
}
$this->loginDiagnostic = 'FAIL: brak potwierdzenia logowania. Fragment: ' . mb_substr(strip_tags($body), 0, 300);
Logger::error('StatLink: login nie powiodl sie — brak potwierdzenia zalogowania', 'statlink');
return false;
} catch (\Throwable $e) {
$this->loginDiagnostic = 'EXCEPTION: ' . $e->getMessage();
Logger::error('StatLink: blad logowania — ' . $e->getMessage(), 'statlink');
return false;
}
}
/** @var array Diagnostic log for the last operation */
private array $lastDiagnostics = [];
private ?string $loginDiagnostic = null;
public function getLastDiagnostics(): array
{
$diag = [];
if ($this->loginDiagnostic !== null) {
$diag[] = 'LOGIN: ' . $this->loginDiagnostic;
}
return array_merge($diag, $this->lastDiagnostics);
}
private function sanitizeAnchor(string $anchor): string
{
// Transliterate Polish diacritics to ASCII equivalents
$map = [
'ą' => 'a', 'ć' => 'c', 'ę' => 'e', 'ł' => 'l', 'ń' => 'n',
'ó' => 'o', 'ś' => 's', 'ź' => 'z', 'ż' => 'z',
'Ą' => 'A', 'Ć' => 'C', 'Ę' => 'E', 'Ł' => 'L', 'Ń' => 'N',
'Ó' => 'O', 'Ś' => 'S', 'Ź' => 'Z', 'Ż' => 'Z',
];
$anchor = strtr($anchor, $map);
// Remove any remaining non-ASCII characters, keep allowed: alphanumeric .,+-_?!&\:= and space
$anchor = preg_replace('/[^a-zA-Z0-9 .,+\-_?!&\\\\:=]/', '', $anchor);
return trim($anchor);
}
public function addLink(string $url, string $anchor): ?int
{
$this->lastDiagnostics = [];
// Sanitize anchor for StatLink's character restrictions
$anchor = $this->sanitizeAnchor($anchor);
if (mb_strlen($anchor) < 2) {
$this->lastDiagnostics[] = 'FAIL: anchor za krotki po sanityzacji: "' . $anchor . '"';
return null;
}
if (!$this->loggedIn && !$this->login()) {
$this->lastDiagnostics[] = 'FAIL: login nie powiodl sie';
return null;
}
$this->lastDiagnostics[] = 'OK: zalogowano';
try {
// GET the links page to scrape CSRF token and categories
$pageResponse = $this->http->get($this->baseUrl . '/148,twoje-linki');
$pageHtml = (string) $pageResponse->getBody();
$this->lastDiagnostics[] = 'OK: pobrano strone linkow (HTTP ' . $pageResponse->getStatusCode() . ', ' . strlen($pageHtml) . ' bytes)';
$csrfToken = $this->scrapeCsrfToken($pageHtml);
if ($csrfToken === null) {
$this->lastDiagnostics[] = 'FAIL: brak tokenu CSRF w HTML (szukam name="niepozwol")';
// Save a snippet of the page for debugging
$this->lastDiagnostics[] = 'DEBUG: fragment HTML (500 znakow): ' . substr(strip_tags($pageHtml), 0, 500);
Logger::error('StatLink: nie udalo sie pobrac tokenu CSRF', 'statlink');
return null;
}
$this->lastDiagnostics[] = 'OK: CSRF token pobrany (' . strlen($csrfToken) . ' znakow)';
$categories = $this->scrapeCategories($pageHtml);
$this->lastDiagnostics[] = 'OK: kategorie: ' . (count($categories) > 0 ? implode(',', $categories) : 'brak');
// Determine protocol
$isHttps = str_starts_with($url, 'https://');
$cleanUrl = preg_replace('#^https?://#', '', $url);
$cleanUrl = rtrim($cleanUrl, '/');
$this->lastDiagnostics[] = "INFO: wysylam link={$cleanUrl}, anchor={$anchor}, https=" . ($isHttps ? '1' : '0');
// Build form data
$formData = [
'editadd' => '',
'niepozwol' => $csrfToken,
'https' => $isHttps ? '1' : '0',
'link' => $cleanUrl,
'anchor' => $anchor,
'fraza_kluczowa1' => '',
'fraza_kluczowa2' => '',
'fraza_kluczowa3' => '',
'wylacznosc' => '',
'frazowy' => '',
'tylko_https' => '',
'min_ilosc_znakow' => '0',
'statrank_min' => '0',
'reaguj_statrank_min' => '',
'statrank_max' => '10',
'semstorm_keywords_top_min' => '0',
'ilosc_dziennie' => '0.02',
'ilosc_max' => '10',
'ilosc_max_powiadom' => '',
'ilosc_nofollow' => '0',
'max_ilosc_domena' => '5',
'skanuj_pos' => '',
'pos_limit' => '',
'pos_limit_powiadom' => '',
'wstrzymaj' => '',
'id_grupy' => '0',
'nowa_grupa' => '',
'pomin_wlasne' => '',
'zapisz' => 'DODAJ',
];
// Build multipart-like form with categories
$params = $formData;
// Categories need special handling (multiple values for same key)
// Guzzle form_params doesn't support duplicate keys, so use body directly
$bodyParts = [];
foreach ($params as $key => $value) {
$bodyParts[] = urlencode($key) . '=' . urlencode((string) $value);
}
foreach ($categories as $catId) {
$bodyParts[] = 'id_kategorie_multiple%5B%5D=' . urlencode((string) $catId);
}
$bodyString = implode('&', $bodyParts);
$response = $this->http->post($this->baseUrl . '/148,twoje-linki#lista', [
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => $bodyString,
'allow_redirects' => true,
]);
$responseHtml = (string) $response->getBody();
$this->lastDiagnostics[] = 'OK: formularz wyslany (HTTP ' . $response->getStatusCode() . ', ' . strlen($responseHtml) . ' bytes)';
// Check for common error/warning messages in the response
$errorPatterns = ['błąd', 'Błąd', 'error', 'Error', 'nieprawidłow', 'Nieprawidłow', 'nie można', 'Nie można', 'istnieje', 'Istnieje', 'Limit', 'limit', 'za dużo', 'Za dużo'];
$foundMessages = [];
$strippedHtml = strip_tags($responseHtml);
foreach ($errorPatterns as $pat) {
$pos = mb_stripos($strippedHtml, $pat);
if ($pos !== false) {
$start = max(0, $pos - 40);
$foundMessages[] = mb_substr($strippedHtml, $start, 100);
}
}
if (!empty($foundMessages)) {
$this->lastDiagnostics[] = 'WARN: znalezione komunikaty w odpowiedzi: ' . implode(' | ', array_unique($foundMessages));
}
// Check for "Dodano Link" success message (case-insensitive)
if (stripos($responseHtml, 'Dodano Link') !== false || stripos($responseHtml, 'Dodano link') !== false || stripos($responseHtml, 'dodano link') !== false) {
// Try to extract ID directly from the response HTML first
$statlinkId = $this->findLinkIdInHtml($responseHtml, $cleanUrl);
if ($statlinkId === null) {
// Fallback: search via separate request
$statlinkId = $this->findLinkIdBySearch($cleanUrl);
}
$this->lastDiagnostics[] = 'OK: potwierdzenie "Dodano Link" znalezione, statlink_id=' . ($statlinkId ?? 'nieznany');
Logger::info("StatLink: dodano link" . ($statlinkId ? " ID {$statlinkId}" : '') . " dla {$cleanUrl}", 'statlink');
return $statlinkId ?: -1; // -1 means added but ID unknown
}
$this->lastDiagnostics[] = 'WARN: brak "Dodano Link" w odpowiedzi';
// Extract meaningful content from the response — skip navigation/header junk
$strippedFull = strip_tags($responseHtml);
// Look for form-area messages: find "Twoje Linki" heading and capture text after it
$formAreaPos = mb_strpos($strippedFull, 'Twoje Linki');
if ($formAreaPos !== false) {
$formArea = mb_substr($strippedFull, $formAreaPos, 2000);
// Collapse whitespace for readability
$formArea = trim(preg_replace('/\s+/', ' ', $formArea));
$this->lastDiagnostics[] = 'DEBUG: sekcja formularza: ' . mb_substr($formArea, 0, 1500);
} else {
$this->lastDiagnostics[] = 'DEBUG: fragment odpowiedzi (1500 znakow): ' . mb_substr(preg_replace('/\s+/', ' ', $strippedFull), 0, 1500);
}
// Also check for alert/message divs in raw HTML
if (preg_match_all('/<div[^>]*class="[^"]*(?:alert|message|info|error|warning|komunikat)[^"]*"[^>]*>(.*?)<\/div>/si', $responseHtml, $alertMatches)) {
$alerts = array_map(function($m) { return trim(strip_tags($m)); }, $alertMatches[1]);
$alerts = array_filter($alerts);
if (!empty($alerts)) {
$this->lastDiagnostics[] = 'ALERT_DIVS: ' . implode(' | ', $alerts);
}
}
// No "Dodano" confirmation — check if link already exists in StatLink
$existingId = $this->findLinkIdBySearch($cleanUrl);
if ($existingId !== null) {
$this->lastDiagnostics[] = 'OK: link juz istnieje w StatLink (ID ' . $existingId . ')';
Logger::info("StatLink: link {$cleanUrl} juz istnieje w systemie (ID {$existingId})", 'statlink');
return $existingId; // treat as success — link is there
}
$this->lastDiagnostics[] = 'FAIL: link nie znaleziony w StatLink po wysylce';
Logger::warning("StatLink: wyslano link {$cleanUrl}, ale brak potwierdzenia dodania", 'statlink');
return null;
} catch (\Throwable $e) {
$this->lastDiagnostics[] = 'EXCEPTION: ' . $e->getMessage();
Logger::error("StatLink: blad dodawania linku {$url}" . $e->getMessage(), 'statlink');
return null;
}
}
public function removeLink(int $statlinkId): bool
{
if (!$this->loggedIn && !$this->login()) {
return false;
}
try {
$response = $this->http->post($this->baseUrl . '/148,twoje-linki#lista0', [
'form_params' => [
'statlink_id' => (string) $statlinkId,
'usun' => 'Usuń',
],
'allow_redirects' => true,
]);
$body = (string) $response->getBody();
// Verify link was removed by checking it no longer appears
$remainingIds = $this->scrapeExistingLinkIds($body);
if (!in_array($statlinkId, $remainingIds)) {
Logger::info("StatLink: usunieto link ID {$statlinkId}", 'statlink');
return true;
}
Logger::warning("StatLink: link ID {$statlinkId} nadal widoczny po probie usuniecia", 'statlink');
return false;
} catch (\Throwable $e) {
Logger::error("StatLink: blad usuwania linku ID {$statlinkId}" . $e->getMessage(), 'statlink');
return false;
}
}
public function processNewArticles(): array
{
$db = Database::getInstance();
$stats = ['added' => 0, 'skipped' => 0, 'errors' => 0];
// Find published articles with wp_post_id but without an active statlink entry
$stmt = $db->prepare(
"SELECT a.id, a.title, a.wp_post_id, a.wp_post_url, a.site_id, a.topic_id,
t.name as topic_name, s.url as site_url,
s.api_user as site_api_user, s.api_token as site_api_token
FROM articles a
JOIN topics t ON a.topic_id = t.id
JOIN sites s ON a.site_id = s.id
WHERE a.status = 'published'
AND a.wp_post_id IS NOT NULL
AND a.wp_post_id > 0
AND a.id NOT IN (
SELECT sl.article_id FROM statlink_links sl
)
ORDER BY a.published_at ASC
LIMIT :limit"
);
$stmt->bindValue('limit', self::MAX_LINKS_PER_RUN, \PDO::PARAM_INT);
$stmt->execute();
$articles = $stmt->fetchAll();
if (empty($articles)) {
Logger::info('StatLink: brak nowych artykulow do dodania', 'statlink');
return $stats;
}
if (!$this->login()) {
Logger::error('StatLink: nie mozna zalogowac — przerywam processNewArticles', 'statlink');
$stats['errors'] = count($articles);
return $stats;
}
$wordpress = new WordPressService();
$counter = 0;
foreach ($articles as $article) {
// Alternate anchor: even = title, odd = topic keyword
$anchor = ($counter % 2 === 0)
? (string) $article['title']
: (string) $article['topic_name'];
// Resolve article URL: use wp_post_url if available, otherwise fetch from WP API
$url = (string) ($article['wp_post_url'] ?? '');
if ($url === '') {
$site = [
'id' => $article['site_id'],
'url' => $article['site_url'],
'api_user' => $article['site_api_user'],
'api_token' => $article['site_api_token'],
];
$url = (string) ($wordpress->getPostLink($site, (int) $article['wp_post_id']) ?? '');
// Save resolved URL back to article for future use
if ($url !== '') {
$db->prepare("UPDATE articles SET wp_post_url = :url WHERE id = :id")
->execute(['url' => $url, 'id' => $article['id']]);
Logger::info("StatLink: uzupelniono wp_post_url dla artykulu ID {$article['id']}: {$url}", 'statlink');
}
}
if ($url === '') {
Logger::warning("StatLink: brak URL dla artykulu ID {$article['id']} — pomijam", 'statlink');
$stats['skipped']++;
$counter++;
continue;
}
try {
$statlinkId = $this->addLink($url, $anchor);
$now = date('Y-m-d H:i:s');
$expiresAt = date('Y-m-d H:i:s', strtotime('+' . self::LINK_LIFETIME_DAYS . ' days'));
if ($statlinkId !== null) {
$db->prepare(
"INSERT INTO statlink_links (article_id, site_id, statlink_id, anchor, link_url, added_at, expires_at, status)
VALUES (:article_id, :site_id, :statlink_id, :anchor, :link_url, :added_at, :expires_at, 'active')"
)->execute([
'article_id' => $article['id'],
'site_id' => $article['site_id'],
'statlink_id' => $statlinkId,
'anchor' => $anchor,
'link_url' => $url,
'added_at' => $now,
'expires_at' => $expiresAt,
]);
$stats['added']++;
Logger::info("StatLink: dodano artykul ID {$article['id']} jako link {$statlinkId}", 'statlink');
} else {
// Save as failed so we don't retry endlessly
$db->prepare(
"INSERT INTO statlink_links (article_id, site_id, anchor, link_url, added_at, expires_at, status, error_message)
VALUES (:article_id, :site_id, :anchor, :link_url, :added_at, :expires_at, 'failed', 'StatLink nie zaakceptowal linku')"
)->execute([
'article_id' => $article['id'],
'site_id' => $article['site_id'],
'anchor' => $anchor,
'link_url' => $url,
'added_at' => $now,
'expires_at' => $expiresAt,
]);
$stats['skipped']++;
Logger::warning("StatLink: link {$url} nie zostal dodany — oznaczono jako failed", 'statlink');
}
} catch (\Throwable $e) {
$stats['errors']++;
Logger::error("StatLink: blad przetwarzania artykulu ID {$article['id']}" . $e->getMessage(), 'statlink');
// Save failed attempt
$now = date('Y-m-d H:i:s');
$expiresAt = date('Y-m-d H:i:s', strtotime('+' . self::LINK_LIFETIME_DAYS . ' days'));
$db->prepare(
"INSERT INTO statlink_links (article_id, site_id, anchor, link_url, added_at, expires_at, status, error_message)
VALUES (:article_id, :site_id, :anchor, :link_url, :added_at, :expires_at, 'failed', :error)"
)->execute([
'article_id' => $article['id'],
'site_id' => $article['site_id'],
'anchor' => $anchor,
'link_url' => $url,
'added_at' => $now,
'expires_at' => $expiresAt,
'error' => $e->getMessage(),
]);
}
$counter++;
// Small delay between requests to avoid rate limiting
if ($counter < count($articles)) {
sleep(2);
}
}
Logger::info("StatLink processNewArticles: added={$stats['added']}, skipped={$stats['skipped']}, errors={$stats['errors']}", 'statlink');
return $stats;
}
public function retryFailedLinks(): array
{
$db = Database::getInstance();
$stats = ['retried' => 0, 'still_failed' => 0, 'errors' => 0];
// Find failed links to retry (max 3 retries tracked by retry_count or re-attempts)
$stmt = $db->prepare(
"SELECT sl.id, sl.article_id, sl.site_id, sl.link_url, sl.anchor
FROM statlink_links sl
WHERE sl.status = 'failed'
ORDER BY sl.added_at ASC
LIMIT :limit"
);
$stmt->bindValue('limit', self::MAX_LINKS_PER_RUN, \PDO::PARAM_INT);
$stmt->execute();
$failedLinks = $stmt->fetchAll();
if (empty($failedLinks)) {
return $stats;
}
if (!$this->login()) {
Logger::error('StatLink: nie mozna zalogowac — przerywam retryFailedLinks', 'statlink');
$stats['errors'] = count($failedLinks);
// Update error_message on failed links so we see the login diagnostic
$loginDiag = $this->loginDiagnostic ?? 'LOGIN FAIL: nieznany powod';
foreach ($failedLinks as $link) {
$db->prepare("UPDATE statlink_links SET error_message = :error WHERE id = :id")
->execute(['error' => $loginDiag, 'id' => $link['id']]);
}
return $stats;
}
foreach ($failedLinks as $link) {
$url = (string) $link['link_url'];
$anchor = (string) $link['anchor'];
if ($url === '') {
// No URL — delete the failed record so processNewArticles can re-pick it
$db->prepare("DELETE FROM statlink_links WHERE id = :id")
->execute(['id' => $link['id']]);
$stats['still_failed']++;
continue;
}
try {
$statlinkId = $this->addLink($url, $anchor);
if ($statlinkId !== null) {
$now = date('Y-m-d H:i:s');
$expiresAt = date('Y-m-d H:i:s', strtotime('+' . self::LINK_LIFETIME_DAYS . ' days'));
$db->prepare(
"UPDATE statlink_links
SET statlink_id = :statlink_id, status = 'active',
added_at = :added_at, expires_at = :expires_at, error_message = NULL
WHERE id = :id"
)->execute([
'statlink_id' => $statlinkId,
'added_at' => $now,
'expires_at' => $expiresAt,
'id' => $link['id'],
]);
$stats['retried']++;
Logger::info("StatLink: retry OK — artykul ID {$link['article_id']} dodany jako link {$statlinkId}", 'statlink');
} else {
$stats['still_failed']++;
$diag = implode(' | ', $this->lastDiagnostics);
$db->prepare(
"UPDATE statlink_links SET error_message = :error WHERE id = :id"
)->execute([
'error' => 'Retry failed: ' . mb_substr($diag, 0, 500),
'id' => $link['id'],
]);
Logger::warning("StatLink: retry FAIL — artykul ID {$link['article_id']}, link {$url}", 'statlink');
}
} catch (\Throwable $e) {
$stats['errors']++;
$db->prepare(
"UPDATE statlink_links SET error_message = :error WHERE id = :id"
)->execute(['error' => 'Retry exception: ' . $e->getMessage(), 'id' => $link['id']]);
Logger::error("StatLink: retry blad — artykul ID {$link['article_id']}" . $e->getMessage(), 'statlink');
}
}
Logger::info("StatLink retryFailedLinks: retried={$stats['retried']}, still_failed={$stats['still_failed']}, errors={$stats['errors']}", 'statlink');
return $stats;
}
public function removeExpiredLinks(): array
{
$db = Database::getInstance();
$stats = ['removed' => 0, 'errors' => 0];
$stmt = $db->prepare(
"SELECT id, statlink_id, link_url, article_id
FROM statlink_links
WHERE status = 'active'
AND expires_at < NOW()
ORDER BY expires_at ASC
LIMIT :limit"
);
$stmt->bindValue('limit', self::MAX_LINKS_PER_RUN, \PDO::PARAM_INT);
$stmt->execute();
$expiredLinks = $stmt->fetchAll();
if (empty($expiredLinks)) {
Logger::info('StatLink: brak wygaslych linkow do usuniecia', 'statlink');
return $stats;
}
if (!$this->login()) {
Logger::error('StatLink: nie mozna zalogowac — przerywam removeExpiredLinks', 'statlink');
$stats['errors'] = count($expiredLinks);
return $stats;
}
foreach ($expiredLinks as $i => $link) {
$statlinkId = (int) $link['statlink_id'];
if ($statlinkId <= 0) {
// No statlink_id — just mark as removed
$db->prepare(
"UPDATE statlink_links SET status = 'removed', removed_at = NOW() WHERE id = :id"
)->execute(['id' => $link['id']]);
$stats['removed']++;
continue;
}
try {
$removed = $this->removeLink($statlinkId);
if ($removed) {
$db->prepare(
"UPDATE statlink_links SET status = 'removed', removed_at = NOW() WHERE id = :id"
)->execute(['id' => $link['id']]);
$stats['removed']++;
Logger::info("StatLink: usunieto wygasly link ID {$statlinkId} (artykul {$link['article_id']})", 'statlink');
} else {
$db->prepare(
"UPDATE statlink_links SET status = 'expired', error_message = 'Nie udalo sie usunac ze StatLink' WHERE id = :id"
)->execute(['id' => $link['id']]);
$stats['errors']++;
}
} catch (\Throwable $e) {
$stats['errors']++;
$db->prepare(
"UPDATE statlink_links SET status = 'expired', error_message = :error WHERE id = :id"
)->execute(['id' => $link['id'], 'error' => $e->getMessage()]);
Logger::error("StatLink: blad usuwania linku ID {$statlinkId}" . $e->getMessage(), 'statlink');
}
// Small delay between requests
if ($i < count($expiredLinks) - 1) {
sleep(2);
}
}
Logger::info("StatLink removeExpiredLinks: removed={$stats['removed']}, errors={$stats['errors']}", 'statlink');
return $stats;
}
/**
* Extract statlink_id from HTML that already contains the link (e.g. form response).
*/
private function findLinkIdInHtml(string $html, string $cleanUrl): ?int
{
// Try multiple URL variants: with/without trailing slash, with/without protocol
$urlVariants = [
$cleanUrl,
rtrim($cleanUrl, '/'),
'https://' . $cleanUrl,
'https://' . rtrim($cleanUrl, '/'),
'http://' . $cleanUrl,
'http://' . rtrim($cleanUrl, '/'),
];
if (preg_match_all('/name="statlink_id"\s+value="(\d+)"/', $html, $idMatches)) {
foreach ($idMatches[1] as $candidateId) {
$idPos = strpos($html, 'value="' . $candidateId . '"');
if ($idPos === false) {
continue;
}
// Check a wide region around the ID for our URL
$regionStart = max(0, $idPos - 3000);
$region = substr($html, $regionStart, 6000);
foreach ($urlVariants as $variant) {
if (stripos($region, $variant) !== false) {
return (int) $candidateId;
}
}
}
}
return null;
}
private function findLinkIdBySearch(string $cleanUrl): ?int
{
try {
// Extract domain for search query
$searchTerm = explode('/', $cleanUrl)[0];
$response = $this->http->post($this->baseUrl . '/148,twoje-linki#lista0', [
'form_params' => [
'statlink_szukaj' => $searchTerm,
'ilosc_na_stronie_linki' => '100',
'statlink_szukaj_go' => '1',
],
'allow_redirects' => true,
]);
$body = (string) $response->getBody();
return $this->findLinkIdInHtml($body, $cleanUrl);
} catch (\Throwable $e) {
Logger::warning("StatLink: blad wyszukiwania ID linku — " . $e->getMessage(), 'statlink');
return null;
}
}
private function scrapeCsrfToken(string $html): ?string
{
if (preg_match('/name="niepozwol"\s+value="([^"]+)"/', $html, $matches)) {
return $matches[1];
}
if (preg_match('/value="([^"]+)"\s+.*?name="niepozwol"/', $html, $matches)) {
return $matches[1];
}
return null;
}
private function scrapeCategories(string $html): array
{
$categories = [];
if (preg_match_all('/name="id_kategorie_multiple\[\]"\s+value="(\d+)"/', $html, $matches)) {
$categories = array_map('intval', $matches[1]);
}
return $categories;
}
private function scrapeExistingLinkIds(string $html): array
{
$ids = [];
// Links table has forms with hidden statlink_id fields
if (preg_match_all('/name="statlink_id"\s+value="(\d+)"/', $html, $matches)) {
$ids = array_map('intval', array_unique($matches[1]));
}
return $ids;
}
}

View File

@@ -132,7 +132,8 @@ class WordPressService
string $title, string $title,
string $content, string $content,
?int $categoryId = null, ?int $categoryId = null,
?int $mediaId = null ?int $mediaId = null,
?string $excerpt = null
): ?int { ): ?int {
$auth = $this->requireAuthOption($site, 'createPost'); $auth = $this->requireAuthOption($site, 'createPost');
if ($auth === null) { if ($auth === null) {
@@ -155,6 +156,10 @@ class WordPressService
$postData['featured_media'] = $mediaId; $postData['featured_media'] = $mediaId;
} }
if (is_string($excerpt) && trim($excerpt) !== '') {
$postData['excerpt'] = trim($excerpt);
}
$response = $this->requestWp($site, 'POST', 'wp/v2/posts', [ $response = $this->requestWp($site, 'POST', 'wp/v2/posts', [
'auth' => $auth, 'auth' => $auth,
'json' => $postData, 'json' => $postData,
@@ -171,7 +176,7 @@ class WordPressService
} }
// Fall back to XML-RPC. // Fall back to XML-RPC.
return $this->createPostXmlRpc($site, $auth, $title, $content, $categoryId, $mediaId); return $this->createPostXmlRpc($site, $auth, $title, $content, $categoryId, $mediaId, $excerpt);
} }
public function getPublishedPosts(array $site, int $perPage = 100): array|false public function getPublishedPosts(array $site, int $perPage = 100): array|false
@@ -188,7 +193,7 @@ class WordPressService
'status' => 'publish', 'status' => 'publish',
'per_page' => $perPage, 'per_page' => $perPage,
'page' => $page, 'page' => $page,
'_fields' => 'id,title,content,date,categories', '_fields' => 'id,title,content,date,categories,link',
]; ];
$options = ['query' => $query]; $options = ['query' => $query];
if ($auth !== null) { if ($auth !== null) {
@@ -434,6 +439,29 @@ class WordPressService
return ['success' => false, 'message' => (string) ($retry['message'] ?? 'Blad zmiany permalink.')]; return ['success' => false, 'message' => (string) ($retry['message'] ?? 'Blad zmiany permalink.')];
} }
public function getPostLink(array $site, int $wpPostId): ?string
{
if ($wpPostId <= 0) {
return null;
}
$auth = $this->buildAuthOption($site);
$options = ['query' => ['_fields' => 'link']];
if ($auth !== null) {
$options['auth'] = $auth;
}
try {
$response = $this->requestWp($site, 'GET', 'wp/v2/posts/' . $wpPostId, $options);
$data = json_decode($response->getBody()->getContents(), true);
$link = trim((string) ($data['link'] ?? ''));
return $link !== '' ? $link : null;
} catch (GuzzleException $e) {
Logger::warning("WP getPostLink failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
return null;
}
}
public function enableSearchEngineIndexing(array $site): array public function enableSearchEngineIndexing(array $site): array
{ {
$result = $this->callRemoteService($site, 'set_blog_public', ['blog_public' => '1']); $result = $this->callRemoteService($site, 'set_blog_public', ['blog_public' => '1']);
@@ -619,12 +647,24 @@ class WordPressService
// ── XML-RPC fallback methods ────────────────────────────────────── // ── XML-RPC fallback methods ──────────────────────────────────────
private function createPostXmlRpc(array $site, array $auth, string $title, string $content, ?int $categoryId, ?int $mediaId): ?int private function createPostXmlRpc(
array $site,
array $auth,
string $title,
string $content,
?int $categoryId,
?int $mediaId,
?string $excerpt
): ?int
{ {
$fields = '<member><name>post_title</name><value><string>' . $this->xmlEsc($title) . '</string></value></member>' $fields = '<member><name>post_title</name><value><string>' . $this->xmlEsc($title) . '</string></value></member>'
. '<member><name>post_content</name><value><string>' . $this->xmlEsc($content) . '</string></value></member>' . '<member><name>post_content</name><value><string>' . $this->xmlEsc($content) . '</string></value></member>'
. '<member><name>post_status</name><value><string>publish</string></value></member>'; . '<member><name>post_status</name><value><string>publish</string></value></member>';
if (is_string($excerpt) && trim($excerpt) !== '') {
$fields .= '<member><name>mt_excerpt</name><value><string>' . $this->xmlEsc(trim($excerpt)) . '</string></value></member>';
}
if ($categoryId) { if ($categoryId) {
$fields .= '<member><name>terms</name><value><struct>' $fields .= '<member><name>terms</name><value><struct>'
. '<member><name>category</name><value><array><data>' . '<member><name>category</name><value><array><data>'

View File

@@ -29,11 +29,12 @@
<?php endif; ?> <?php endif; ?>
<?php if ($article['wp_post_id'] && !empty($article['site_url'])): ?> <?php if ($article['wp_post_id'] && !empty($article['site_url'])): ?>
<?php $publishedUrl = !empty($article['wp_post_url']) ? (string) $article['wp_post_url'] : ((string) $article['site_url'] . '/?p=' . (int) $article['wp_post_id']); ?>
<div class="alert alert-info d-flex justify-content-between align-items-center"> <div class="alert alert-info d-flex justify-content-between align-items-center">
<div> <div>
<i class="bi bi-wordpress me-1"></i> <i class="bi bi-wordpress me-1"></i>
Post WordPress ID: <strong><?= $article['wp_post_id'] ?></strong> Post WordPress ID: <strong><?= $article['wp_post_id'] ?></strong>
| <a href="<?= htmlspecialchars($article['site_url']) ?>/?p=<?= $article['wp_post_id'] ?>" target="_blank">Zobacz na stronie</a> | <a href="<?= htmlspecialchars($publishedUrl) ?>" target="_blank">Zobacz na stronie</a>
</div> </div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<input type="file" id="imageFile" accept="image/jpeg,image/png,image/gif,image/webp" class="form-control form-control-sm" style="max-width: 250px;"> <input type="file" id="imageFile" accept="image/jpeg,image/png,image/gif,image/webp" class="form-control form-control-sm" style="max-width: 250px;">

View File

@@ -13,9 +13,10 @@
</head> </head>
<body class="bg-light-subtle"> <body class="bg-light-subtle">
<?php if (\App\Core\Auth::check()): ?> <?php if (\App\Core\Auth::check()): ?>
<div class="d-flex min-vh-100"> <div class="app-shell" id="js-app-shell">
<?php require __DIR__ . '/sidebar.php'; ?> <?php require __DIR__ . '/sidebar.php'; ?>
<div class="flex-grow-1 d-flex flex-column content-area"> <div class="sidebar-backdrop" id="js-sidebar-backdrop"></div>
<div class="app-main">
<?php require __DIR__ . '/header.php'; ?> <?php require __DIR__ . '/header.php'; ?>
<main class="p-4 flex-grow-1"> <main class="p-4 flex-grow-1">
<div class="container-fluid p-0"> <div class="container-fluid p-0">

View File

@@ -1,81 +1,110 @@
<nav class="sidebar d-flex flex-column flex-shrink-0 min-vh-100" style="width: 260px;"> <?php
<div class="sidebar-header"> $uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
<div class="d-flex align-items-center"> $uri = rtrim($uri, '/') ?: '/';
<div class="bg-primary rounded-3 p-2 me-3 shadow-sm">
<i class="bi bi-rocket-takeoff-fill text-white fs-4"></i> $isActive = function (string ...$paths) use ($uri): bool {
</div> foreach ($paths as $path) {
<div> if ($uri === $path || str_starts_with($uri, $path . '/')) {
<h5 class="mb-0 text-white fw-bold tracking-tight">BackPRO</h5> return true;
<p class="text-secondary small mb-0" style="font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em;">SEO Engine</p> }
</div> }
</div> return false;
};
$menuMain = $isActive('/', '/sites', '/seo');
$menuContent = $isActive('/global-topics', '/articles', '/statlink');
$menuAdmin = $isActive('/installer', '/settings', '/logs');
?>
<aside class="sidebar" id="js-sidebar">
<div class="sidebar__brand">
<span class="sidebar__brand-text">Back<strong>PRO</strong></span>
<button class="sidebar__collapse-btn" id="js-sidebar-collapse" title="Zwin menu" aria-label="Zwin menu">
<svg class="sidebar__collapse-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
</div> </div>
<div class="flex-grow-1 py-4 overflow-y-auto"> <nav class="sidebar__nav" aria-label="Menu glowne">
<div class="px-4 mb-3 text-uppercase text-secondary small fw-bold" style="font-size: 0.65rem; letter-spacing: 0.1em;">Menu główne</div> <!-- Glowne -->
<ul class="nav flex-column mb-4"> <details class="sidebar__group<?= $menuMain ? ' is-active' : '' ?>"<?= $menuMain ? ' open' : '' ?>>
<li class="nav-item"> <summary class="sidebar__group-toggle">
<a class="nav-link <?= \App\Core\Router::isCurrent('/') ? 'active' : '' ?>" href="/"> <span class="sidebar__icon">
<i class="bi bi-grid-1x2-fill"></i>Dashboard <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
</a> <rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</li> </svg>
<li class="nav-item"> </span>
<a class="nav-link <?= \App\Core\Router::isCurrent('/sites') ? 'active' : '' ?>" href="/sites"> <span class="sidebar__label">Glowne</span>
<i class="bi bi-browser-safari"></i>Strony WP <svg class="sidebar__toggle-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
</a> <path d="M6 9l6 6 6-6"/>
</li> </svg>
<li class="nav-item"> </summary>
<a class="nav-link <?= \App\Core\Router::isCurrent('/seo/stats') ? 'active' : '' ?>" href="/seo/stats"> <div class="sidebar__group-links">
<i class="bi bi-bar-chart-line-fill"></i>Statystyki SEO <a class="sidebar__sublink<?= $uri === '/' ? ' is-active' : '' ?>" href="/">Dashboard</a>
</a> <a class="sidebar__sublink<?= $isActive('/sites') ? ' is-active' : '' ?>" href="/sites">Strony WP</a>
</li> <a class="sidebar__sublink<?= $isActive('/seo') ? ' is-active' : '' ?>" href="/seo/stats">Statystyki SEO</a>
</ul> </div>
</details>
<div class="px-4 mb-3 text-uppercase text-secondary small fw-bold" style="font-size: 0.65rem; letter-spacing: 0.1em;">Zasoby i Treści</div> <!-- Tresci -->
<ul class="nav flex-column mb-4"> <details class="sidebar__group<?= $menuContent ? ' is-active' : '' ?>"<?= $menuContent ? ' open' : '' ?>>
<li class="nav-item"> <summary class="sidebar__group-toggle">
<a class="nav-link <?= \App\Core\Router::isCurrent('/global-topics') ? 'active' : '' ?>" href="/global-topics"> <span class="sidebar__icon">
<i class="bi bi-tags-fill"></i>Biblioteka Tematów <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
</a> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
</li> <polyline points="14 2 14 8 20 8"/>
<li class="nav-item"> <line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
<a class="nav-link <?= \App\Core\Router::isCurrent('/articles') ? 'active' : '' ?>" href="/articles"> </svg>
<i class="bi bi-journal-richtext"></i>Artykuły </span>
</a> <span class="sidebar__label">Tresci</span>
</li> <svg class="sidebar__toggle-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
</ul> <path d="M6 9l6 6 6-6"/>
</svg>
</summary>
<div class="sidebar__group-links">
<a class="sidebar__sublink<?= $isActive('/global-topics') ? ' is-active' : '' ?>" href="/global-topics">Biblioteka Tematow</a>
<a class="sidebar__sublink<?= $isActive('/articles') ? ' is-active' : '' ?>" href="/articles">Artykuly</a>
<a class="sidebar__sublink<?= $isActive('/statlink') ? ' is-active' : '' ?>" href="/statlink">StatLink</a>
</div>
</details>
<div class="px-4 mb-3 text-uppercase text-secondary small fw-bold" style="font-size: 0.65rem; letter-spacing: 0.1em;">Administracja</div> <!-- Administracja -->
<ul class="nav flex-column"> <details class="sidebar__group<?= $menuAdmin ? ' is-active' : '' ?>"<?= $menuAdmin ? ' open' : '' ?>>
<li class="nav-item"> <summary class="sidebar__group-toggle">
<a class="nav-link <?= \App\Core\Router::isCurrent('/installer') ? 'active' : '' ?>" href="/installer"> <span class="sidebar__icon">
<i class="bi bi-magic"></i>Instalator WP <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
</a> <circle cx="12" cy="12" r="3"/>
</li> <path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
<li class="nav-item"> </svg>
<a class="nav-link <?= \App\Core\Router::isCurrent('/settings') ? 'active' : '' ?>" href="/settings"> </span>
<i class="bi bi-sliders"></i>Ustawienia API <span class="sidebar__label">Administracja</span>
</a> <svg class="sidebar__toggle-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
</li> <path d="M6 9l6 6 6-6"/>
<li class="nav-item"> </svg>
<a class="nav-link <?= \App\Core\Router::isCurrent('/logs') ? 'active' : '' ?>" href="/logs"> </summary>
<i class="bi bi-shield-lock-fill"></i>Dziennik Zdarzeń <div class="sidebar__group-links">
</a> <a class="sidebar__sublink<?= $isActive('/installer') ? ' is-active' : '' ?>" href="/installer">Instalator WP</a>
</li> <a class="sidebar__sublink<?= $isActive('/settings') && !$isActive('/settings/database') ? ' is-active' : '' ?>" href="/settings">Ustawienia API</a>
</ul> <a class="sidebar__sublink<?= $isActive('/settings/database') ? ' is-active' : '' ?>" href="/settings/database">Baza danych</a>
</div> <a class="sidebar__sublink<?= $isActive('/logs') ? ' is-active' : '' ?>" href="/logs">Dziennik zdarzen</a>
</div>
</details>
</nav>
<div class="p-4 bg-black bg-opacity-10 border-top border-white border-opacity-5"> <div class="sidebar__footer">
<div class="d-flex align-items-center gap-3"> <div class="sidebar__user">
<div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center text-white" style="width: 32px; height: 32px; font-size: 0.8rem; font-weight: 600;"> <div class="sidebar__avatar">
<?= strtoupper(substr(\App\Core\Auth::user()['username'] ?? 'U', 0, 1)) ?> <?= strtoupper(substr(\App\Core\Auth::user()['username'] ?? 'U', 0, 1)) ?>
</div> </div>
<div class="overflow-hidden"> <div class="sidebar__user-info">
<div class="text-white small text-truncate fw-medium"><?= htmlspecialchars(\App\Core\Auth::user()['username'] ?? 'Użytkownik') ?></div> <div class="sidebar__user-name"><?= htmlspecialchars(\App\Core\Auth::user()['username'] ?? 'Uzytkownik') ?></div>
<a href="/logout" class="text-secondary small text-decoration-none hover-white d-flex align-items-center"> <a href="/logout" class="sidebar__user-logout">
<i class="bi bi-box-arrow-right me-1"></i> Wyloguj <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
Wyloguj
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</nav> </aside>

View File

@@ -0,0 +1,77 @@
<?php
$migrationStatus = is_array($status ?? null) ? $status : [];
$pending = (int) ($migrationStatus['pending'] ?? 0);
$total = (int) ($migrationStatus['total'] ?? 0);
$applied = (int) ($migrationStatus['applied'] ?? 0);
$pendingFiles = (array) ($migrationStatus['pending_files'] ?? []);
$logs = is_array($runLogs ?? null) ? $runLogs : [];
?>
<h4 class="fw-bold mb-4">Baza danych &mdash; Migracje</h4>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<div class="text-muted small text-uppercase fw-bold">Wszystkie</div>
<div class="fs-3 fw-bold text-dark"><?= $total ?></div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<div class="text-muted small text-uppercase fw-bold">Wykonane</div>
<div class="fs-3 fw-bold text-success"><?= $applied ?></div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<div class="text-muted small text-uppercase fw-bold">Oczekujace</div>
<div class="fs-3 fw-bold <?= $pending > 0 ? 'text-warning' : 'text-muted' ?>"><?= $pending ?></div>
</div>
</div>
</div>
</div>
<?php if ($pending > 0): ?>
<div class="alert alert-warning d-flex align-items-center justify-content-between">
<span><strong><?= $pending ?></strong> migracji oczekuje na wykonanie.</span>
<form action="/settings/database/migrate" method="post" class="d-inline">
<button type="submit" class="btn btn-primary btn-sm">Uruchom migracje</button>
</form>
</div>
<?php else: ?>
<div class="alert alert-success mb-4">Baza danych jest aktualna.</div>
<?php endif; ?>
<div class="card mb-4">
<div class="card-header">Oczekujace pliki migracji</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead>
<tr><th>Plik</th></tr>
</thead>
<tbody>
<?php if (empty($pendingFiles)): ?>
<tr><td class="text-muted">Brak oczekujacych migracji.</td></tr>
<?php else: ?>
<?php foreach ($pendingFiles as $filename): ?>
<tr><td><code><?= htmlspecialchars((string) $filename) ?></code></td></tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php if (!empty($logs)): ?>
<div class="card">
<div class="card-header">Logi ostatniego uruchomienia</div>
<div class="card-body">
<pre class="mb-0 small" style="white-space: pre-wrap;"><?php foreach ($logs as $line): ?><?= htmlspecialchars((string) $line) . "\n" ?><?php endforeach; ?></pre>
</div>
</div>
<?php endif; ?>

View File

@@ -46,8 +46,33 @@
<div class="mb-3"> <div class="mb-3">
<label for="article_generation_prompt" class="form-label">Prompt do generowania artykułu</label> <label for="article_generation_prompt" class="form-label">Prompt do generowania artykułu</label>
<textarea class="form-control" id="article_generation_prompt" name="article_generation_prompt" rows="6"><?= htmlspecialchars($settings['article_generation_prompt']) ?></textarea> <textarea class="form-control" id="article_generation_prompt" name="article_generation_prompt" rows="10"><?= htmlspecialchars($settings['article_generation_prompt']) ?></textarea>
<small class="text-muted">Dostępne placeholdery: {min_words}, {max_words}</small> <small class="text-muted">Dostepne placeholdery: {min_words}, {max_words}. Zalecenie: prompt opisuje styl i strukture, a temat jest dopinany automatycznie.</small>
</div>
<h6 class="mb-3 mt-4 border-bottom pb-2">Linkowanie wewnetrzne (Smart Matching)</h6>
<div class="form-check form-switch mb-3">
<input type="hidden" name="internal_linking_enabled" value="0">
<input class="form-check-input" type="checkbox" role="switch" id="internal_linking_enabled" name="internal_linking_enabled" value="1" <?= (string) ($settings['internal_linking_enabled'] ?? '1') !== '0' ? 'checked' : '' ?>>
<label class="form-check-label" for="internal_linking_enabled">Wlacz automatyczne linkowanie wewnetrzne</label>
</div>
<div class="row mb-3">
<div class="col">
<label for="internal_link_min_count" class="form-label">Min. liczba linkow</label>
<input type="number" class="form-control" id="internal_link_min_count" name="internal_link_min_count" min="0" max="8" value="<?= htmlspecialchars($settings['internal_link_min_count'] ?? '2') ?>">
</div>
<div class="col">
<label for="internal_link_max_count" class="form-label">Max. liczba linkow</label>
<input type="number" class="form-control" id="internal_link_max_count" name="internal_link_max_count" min="1" max="8" value="<?= htmlspecialchars($settings['internal_link_max_count'] ?? '4') ?>">
</div>
</div>
<div class="mb-3">
<label for="openai_embedding_model" class="form-label">Model embeddingow</label>
<input type="text" class="form-control" id="openai_embedding_model" name="openai_embedding_model" value="<?= htmlspecialchars($settings['openai_embedding_model'] ?? 'text-embedding-3-small') ?>" placeholder="text-embedding-3-small">
<small class="text-muted">Wykorzystywany do semantycznego dopasowania powiazanych artykulow.</small>
</div> </div>
<h5 class="mb-3 mt-4 border-bottom pb-2">Obrazki</h5> <h5 class="mb-3 mt-4 border-bottom pb-2">Obrazki</h5>

View File

@@ -0,0 +1,143 @@
<?php
$links = $links ?? [];
$stats = $stats ?? [];
$page = (int) ($page ?? 1);
$totalPages = (int) ($totalPages ?? 1);
$totalLinks = (int) ($totalLinks ?? 0);
$active = (int) ($stats['active'] ?? 0);
$expired = (int) ($stats['expired'] ?? 0);
$removed = (int) ($stats['removed'] ?? 0);
$failed = (int) ($stats['failed'] ?? 0);
$statusBadge = function (string $status): string {
return match ($status) {
'active' => '<span class="badge bg-success">Aktywny</span>',
'expired' => '<span class="badge bg-secondary">Wygasly</span>',
'removed' => '<span class="badge bg-secondary">Usuniety</span>',
'failed' => '<span class="badge bg-danger">Blad</span>',
default => '<span class="badge bg-light text-dark">' . htmlspecialchars($status) . '</span>',
};
};
?>
<h4 class="fw-bold mb-4">StatLink &mdash; Publikacje</h4>
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card text-center border-success border-opacity-25">
<div class="card-body py-3">
<div class="text-muted small text-uppercase fw-bold">Aktywne</div>
<div class="fs-3 fw-bold text-success"><?= $active ?></div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body py-3">
<div class="text-muted small text-uppercase fw-bold">Wygasle</div>
<div class="fs-3 fw-bold text-secondary"><?= $expired ?></div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body py-3">
<div class="text-muted small text-uppercase fw-bold">Usuniete</div>
<div class="fs-3 fw-bold text-secondary"><?= $removed ?></div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center border-danger border-opacity-25">
<div class="card-body py-3">
<div class="text-muted small text-uppercase fw-bold">Bledy</div>
<div class="fs-3 fw-bold text-danger"><?= $failed ?></div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Lista linkow (<?= $totalLinks ?>)</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th>Artykul</th>
<th>Strona</th>
<th>Anchor</th>
<th>Dodano</th>
<th>Wygasa</th>
<th>Status</th>
<th>ID</th>
</tr>
</thead>
<tbody>
<?php if (empty($links)): ?>
<tr><td colspan="7" class="text-muted text-center py-4">Brak linkow StatLink.</td></tr>
<?php else: ?>
<?php foreach ($links as $link): ?>
<tr>
<td>
<?php if (!empty($link['article_title'])): ?>
<a href="/articles/<?= (int) $link['article_id'] ?>" class="text-decoration-none">
<?= htmlspecialchars(mb_strimwidth((string) $link['article_title'], 0, 50, '...')) ?>
</a>
<?php else: ?>
<span class="text-muted">ID: <?= (int) $link['article_id'] ?></span>
<?php endif; ?>
</td>
<td class="text-nowrap"><?= htmlspecialchars((string) ($link['site_name'] ?? '')) ?></td>
<td>
<small class="text-muted"><?= htmlspecialchars(mb_strimwidth((string) $link['anchor'], 0, 40, '...')) ?></small>
</td>
<td class="text-nowrap">
<small><?= !empty($link['added_at']) ? date('Y-m-d', strtotime($link['added_at'])) : '-' ?></small>
</td>
<td class="text-nowrap">
<small><?= !empty($link['expires_at']) ? date('Y-m-d', strtotime($link['expires_at'])) : '-' ?></small>
</td>
<td><?= $statusBadge((string) $link['status']) ?></td>
<td><small class="text-muted"><?= $link['statlink_id'] ?? '-' ?></small></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php if ($totalPages > 1): ?>
<div class="card-footer">
<nav>
<ul class="pagination pagination-sm mb-0 justify-content-center">
<?php if ($page > 1): ?>
<li class="page-item"><a class="page-link" href="/statlink?page=<?= $page - 1 ?>">&laquo;</a></li>
<?php endif; ?>
<?php for ($i = max(1, $page - 2); $i <= min($totalPages, $page + 2); $i++): ?>
<li class="page-item <?= $i === $page ? 'active' : '' ?>">
<a class="page-link" href="/statlink?page=<?= $i ?>"><?= $i ?></a>
</li>
<?php endfor; ?>
<?php if ($page < $totalPages): ?>
<li class="page-item"><a class="page-link" href="/statlink?page=<?= $page + 1 ?>">&raquo;</a></li>
<?php endif; ?>
</ul>
</nav>
</div>
<?php endif; ?>
</div>
<?php if (!empty($links)): ?>
<div class="mt-3 text-muted small">
<strong>Legenda:</strong>
<?= $statusBadge('active') ?> linkowany w StatLink &middot;
<?= $statusBadge('expired') ?> oczekuje na usuniecie &middot;
<?= $statusBadge('removed') ?> usuniety ze StatLink &middot;
<?= $statusBadge('failed') ?> blad dodawania/usuwania
</div>
<?php endif; ?>