Как да преместиш WordPress сайт без достъп до хостинг, база данни или пароли
Представете си следната ситуация, която е по-честа, отколкото ви се струва: имате си сайт на WordPress — новини, статии, стотици снимки, трупани с години — но сайтът се поддържа от някой друг. Външна фирма, бивш колега или човек, с когото пътищата ви са се разделили. Съдържанието е вашето, но достъпът е у него: няма cPanel, няма FTP, няма достъп до базата данни, няма и парола за wp-admin. И този някой, по една или друга причина, не иска да ви даде нищо — нито архив, нито експорт.
Класическият отговор е „значи си вързан“. Но не сте. Наскоро ми се наложи да преместя точно такъв сайт — от site.com на нов адрес site.bg — без нито един достъп до стария хостинг. Няколко хиляди публикации и десетки хиляди изображения бяха пренесени 1:1, със запазени адреси и дати. В тази статия ще обясня как става това, защо изобщо е възможно и къде са подводните камъни.
Накратко: всеки стандартен WordPress по подразбиране има публична врата към цялото си съдържание — REST API-то на адрес
/wp-json/. През нея всеки може да прочете статиите, страниците, категориите и списъка с файлове — без парола. Оттам нататък е въпрос на организация да ги пресъздадеш на новия сайт.
Ключът: REST API-то на WordPress
От версия 4.7 (края на 2016 г.) насам всеки WordPress идва с вградено REST API — програмен интерфейс, през който темата, мобилните приложения и редакторът Gutenberg си говорят със сайта. Важното за нас е, че по подразбиране четенето на публичното съдържание е отворено за всички. Не защото някой е сбъркал настройка — така е замислено. Публикуваните статии така или иначе са видими за всеки посетител; API-то просто ги дава в подреден, машинно четим вид.
Пробата отнема пет секунди. Отваряте в браузъра (или с curl) адреса на сайта плюс /wp-json/wp/v2/posts:
curl -s "https://site.com/wp-json/wp/v2/posts?per_page=1" | jq '.[0] | {id, slug, date, title: .title.rendered}'
{
"id": 4821,
"slug": "otvoreno-zasedanie-na-obshtinskiya-savet",
"date": "2026-05-14T09:32:11",
"title": "Открито заседание на общинския съвет"
}
Ако получите такъв JSON — сайтът е ваш за сваляне (в смисъл на съдържание). API-то отдава няколко „колекции“, всяка на свой адрес:
| Адрес (endpoint) | Какво връща |
|---|---|
/wp-json/wp/v2/posts |
Всички публикации — заглавие, съдържание, дата, slug, категории, featured изображение |
/wp-json/wp/v2/pages |
Статичните страници (За нас, Контакти…) |
/wp-json/wp/v2/categories |
Дървото от категории (с родител/дете) |
/wp-json/wp/v2/tags |
Етикетите |
/wp-json/wp/v2/media |
Медийната библиотека — с директни URL-и към всеки файл |
С други думи, WordPress сам ви подава каталог на всичко, което съдържа. Задачата ни се свежда до три стъпки: 1) извличане на този каталог, 2) пресъздаване на съдържанието на новия сайт и 3) сваляне на самите файлове.
Стъпка 1: Извличане (сваляме каталога)
API-то не връща всичко наведнъж — дава максимум 100 записа на страница (параметър per_page=100) и се разлиства с page=1, 2, 3…. Колко страници има общо, ни го казва самият сървър в HTTP хедъра X-WP-TotalPages:
curl -sI "https://site.com/wp-json/wp/v2/posts?per_page=100" | grep -i x-wp- x-wp-total: 3447 x-wp-totalpages: 35
3447 публикации, разпределени в 35 страници. Оттам нататък един прост цикъл сваля всичко в JSON файлове. Важен детайл: подреждаме по orderby=id&order=asc, за да е предвидим редът, а когато страниците свършат, WordPress връща грешка rest_post_invalid_page_number — това ни е сигналът за спиране:
DOMAIN="https://site.com"
mkdir -p data
for type in posts pages categories tags media; do
page=1
while : ; do
out="data/${type}_$(printf '%03d' $page).json"
curl -s "$DOMAIN/wp-json/wp/v2/$type?per_page=100&page=$page&orderby=id&order=asc" -o "$out"
# свършиха ли страниците?
if grep -q 'rest_post_invalid_page_number' "$out"; then rm "$out"; break; fi
if [ "$(jq 'length' "$out")" -lt 1 ]; then rm "$out"; break; fi
echo " $type страница $page — ок"
page=$((page+1))
done
done
За голяма медийна библиотека (в моя случай над 50 000 файла = над 500 страници) това върви няколко минути и е добре да се пусне „на заден план“ (напр. с systemd-run или tmux), за да не прекъсне, ако затворите терминала. Накрая в папка data/ имаме пълен, локален препис на целия сайт — само че като „суровина“ в JSON.
Стъпка 2: Пресъздаване на новия сайт
На новия site.bg (там вече имаме пълен достъп — той е нашият) вдигаме чист WordPress и започваме да сипваме съдържанието обратно. Тук работим с WP-CLI — командния инструмент на WordPress. Има два реда, за които трябва да сме внимателни:
- Първо категориите, после статиите. Категориите имат родител-дете йерархия, затова се създават на няколко „минавания“ — първо родителите, после децата — и през цялото време пазим таблица „стар ID → нов ID“, за да вържем после статиите правилно.
- Запазваме slug и дата. Ако новата статия получи същия
slugи същата дата на публикуване, крайният URL съвпада със стария — а това е решаващо за SEO (повече за това по-долу).
За хиляди статии не пускаме WP-CLI по веднъж на статия (много бавно) — по-удобно е кратък PHP скрипт, изпълнен през wp eval-file, който минава през JSON-а и вика wp_insert_post() за всеки запис. Ето сърцевината (опростена):
foreach (glob('data/posts_*.json') as $file) {
foreach (json_decode(file_get_contents($file), true) as $p) {
// пренасочваме всички стари адреси към новия домейн
$body = preg_replace(
'#https?://(www\.)?site\.com#i',
'https://site.bg',
$p['content']['rendered']
);
wp_insert_post([
'post_title' => $p['title']['rendered'],
'post_content' => $body,
'post_name' => $p['slug'], // същият slug
'post_date' => $p['date'], // същата дата
'post_status' => 'publish',
'post_type' => 'post',
'post_category' => map_categories($p['categories']), // стар→нов ID
]);
}
}
Забележете реда с preg_replace — минаваме през цялото съдържание и пренаписваме всяко срещане на стария домейн към новия. Иначе снимките и вътрешните връзки в статиите щяха да сочат обратно към стария сайт, който по някое време ще изчезне.
Стъпка 3: Файловете (медийната библиотека)
Дотук пренесохме текста. Снимките обаче са реални файлове, които все още седят на стария хостинг. Добрата новина: API-то ни е дало директен URL към всеки от тях в полето source_url. А файловете в wp-content/uploads/ са публични — тоест свалят се без никаква парола, просто с curl.
Вадим списъка с всички URL-и от свалените JSON-и и ги теглим паралелно (по 6 наведнъж), като запазваме същата структура от папки — така новите пътища ще съвпаднат едно към едно със старите:
# всички адреси на файлове, без дубликати
jq -r '.[].source_url' data/media_*.json | sort -u > media-urls.txt
wc -l media-urls.txt # напр. 53331 файла
# теглим паралелно, пазейки пътя wp-content/uploads/ГГГГ/ММ/...
xargs -P6 -n1 -I{} sh -c '
url="{}"
path=$(echo "$url" | sed -E "s#https?://[^/]+/##")
mkdir -p "$(dirname "$path")"
curl -sfL "$url" -o "$path"
' < media-urls.txt
След това остава да „запознаем“ новия WordPress с тези файлове — да ги регистрира като прикачени файлове (attachments) в базата, за да ги вижда в библиотеката и в статиите. Това пак става с малък PHP скрипт, който за всеки файл създава запис през wp_insert_attachment(), като пази съответствието „стар ID → нов ID“. Накрая за всяка статия задаваме featured изображението (голямата снимка в началото) според старото поле featured_media.
Подводните камъни (защото винаги ги има)
На теория звучи гладко. На практика има три места, където се спъва всеки, който го прави за пръв път — и е добре да ги знаете предварително.
1. Адресите (permalinks) и SEO
Пресен WordPress по подразбиране прави „грозни“ адреси от вида ?p=123. Старият сайт почти сигурно е ползвал /%postname%/ (адрес = заглавието). Ако не настроите новия сайт по същия начин, всички стари линкове в Google ще дадат 404 и трафикът пропада. Отиваме в Настройки → Постоянни връзки и избираме същата структура. Тъй като запазихме slug-овете на стъпка 2, новите адреси излизат идентични на старите — старите резултати в Google продължават да работят.
Съвет: при някои хостинг конфигурации бутонът „Запази“ на постоянните връзки не успява да презапише файла
.htaccessсам. Ако след смяната статиите пак дават 404, впишете стандартните WordPress правила в.htaccessръчно — и адресите тръгват.
2. Видеата и „скритото“ съдържание
Тук е най-коварното. Някои теми пазят част от съдържанието не в текста на статията, а в скрити полета (custom fields / post meta). Класически пример: видео публикация, при която YouTube клипът е записан в отделно поле на темата, а не в тялото на статията. Резултатът? В REST API-то полето content.rendered е празно — и тези статии идват без видеото.
Решението е елегантно: за засегнатите статии не гледаме API-то, а сваляме готовата HTML страница (както я вижда посетителят) и с регулярен израз изваждаме адреса на вградения клип:
# сваляме реалната страница и вадим YouTube embed-а html=$(curl -s "https://site.com/nazvanie-na-statiyata/") echo "$html" | grep -oE 'youtube\.com/embed/[A-Za-z0-9_-]+'
После вграждаме клипа обратно в новата статия като нормален <iframe>. Същият подход върши работа за всичко, което темата рисува от скрити полета — стига да се вижда в готовата страница, може да се извади оттам. Поуката: API-то дава каквото знае; браузърът вижда крайния резултат.
3. Миниатюрите (thumbnails)
WordPress не пази само оригиналната снимка — за всяка прави няколко изрязани размера (за списъци, за featured, за галерии). Тези размери зависят от темата. Свалихме оригиналите, но новата тема иска свои размери, които още не съществуват — затова в началото списъците и заглавните снимки може да излизат празни. Лек — една команда, която пре-генерира всички размери за новата тема:
wp media regenerate --yes --only-missing
За десетки хиляди снимки това е дълга операция (при мен ~2 часа), но е „пусни и забрави“ — най-добре пак на заден план.
Ами ако REST API-то е изключено?
Честен въпрос — не винаги вратата е широко отворена. Някои плъгини за сигурност или ръчни настройки затварят публичното API. Тогава има резервни варианти, по ред на удобство:
- RSS фийдът (
/feed/) — рядко го изключват, а често отдава пълния текст на последните статии. Не е толкова богат като API-то, но е начало. - Sitemap-ът (
/sitemap.xml, /sitemap_index.xml) — дава пълен списък на всички адреси на сайта, който после може да се обходи (crawl-не) страница по страница. - Директно обхождане на HTML-а — най-трудоемкото: сваляш страниците и вадиш съдържанието от тях. Работи винаги (щом сайтът е онлайн), но иска повече чистене.
В над 90% от случаите обаче API-то е отворено — просто защото никой не се е сетил да го затвори, а и повечето теми разчитат на него.
Малко здрав разум накрая
Всичко описано тук вади само публично видимо съдържание — това, което така или иначе всеки посетител може да прочете. Тук няма разбиване на пароли, няма влизане в чужда база данни, няма заобикаляне на защита. Просто помолваме сайта да ни даде в подреден вид това, което вече показва на всички. Естествено — правете го за своето собствено съдържание (или с изричното съгласие на собственика му). Целта на този пример е точно тази: да си вземете обратно това, което е ваше, когато някой го държи „заложник“.
Изводът
„Нямам достъп до хостинга, значи не мога да си взема сайта“ е мит. Докато сайтът е онлайн, той сам раздава съдържанието си през вратата, която WordPress отваря по подразбиране. С малко търпение и няколко реда скрипт се пренасят хиляди статии и десетки хиляди снимки — със запазени адреси, дати и SEO — на хостинг, който вие контролирате.
Поуката, ако трябва да я сведем до едно изречение: съдържанието ви никога не бива да е заложник на нечия парола. А ако някой в момента ви държи сайта и не иска да ви даде достъп — вече знаете, че това не е задънена улица.