Linux — практическо ръководство

Част 7: Търсене и замяна в Linux — sed и perl на практика

Имам 200 HTML файла и трябва да вмъкна един ред преди друг. Не го правя на ръка. В тази статия описвам точно как решавам този проблем — с find, sed и perl, включително досадният въпрос с новите редове.

Тук говоря за реални команди, които използвам всеки ден. Не е теоретичен преглед на регулярни изрази. Всеки пример е тестван на реални HTML файлове и решава конкретен проблем, с който се сблъсквам при поддръжката на сайта си.

1. Защо просто не отворя файловете в редактор?

Когато имам един файл — да, отварям го и го редактирам. Когато имам 200 файла с еднаква структура и трябва да вмъкна същия fragment във всеки от тях — вече не.

По-важното е, че не искам да рискувам да пропусна някой файл или да въведа грешка заради умора. Един добре написан find + sed или perl команден ред работи предсказуемо на всякакъв брой файлове.

Освен това, ако командата е записана в shell history или в скрипт, мога да я повторя или адаптирам за следващата подобна задача за секунди.

2. Основната схема: find + exec

Почти всичките ми команди за масова обработка следват една и съща схема:

Основна схема
find . -name "*.html" -type f -exec КОМАНДА {} \;

Това намира всички .html файлове в текущата директория и поддиректориите й и изпълнява КОМАНДА за всеки от тях. Заместващият символ {} се разширява до името на текущия файл, а \; терминира командата.

Когато командата е по-сложна и имам нужда от shell конструкции като if, използвам bash -c:

Схема с bash -c
find . -name "*.html" -type f -exec bash -c '
    if ! grep -q "ТЪРСЕН_ТЕКСТ" "$1"; then
        sed -i "s|СТАРО|НОВО|g" "$1"
    fi
' _ {} \;
Защо _ след bash -c? Това е конвенция. bash -c приема команден низ и след него — списък от аргументи, които се достъпват като $0, $1 и т.н. Символът _ заема мястото на $0 (името на shell-а), за да мога да достъпя името на файла чрез $1.

3. Проверка преди замяна — защо е задължителна

Преди да вмъкна нещо, винаги проверявам дали то вече не съществува. Ако не направя тази проверка и стартирам командата два пъти, ще получа дублиран фрагмент.

Използвам grep -q за тиха проверка:

Проверка с grep -q
if ! grep -q "<div id=\"related-posts\"></div>" "$1"; then
    # Вмъквам само ако не съществува
fi

Флагът -q (quiet) потиска output-а — grep връща само exit code: 0 ако намери съвпадение, 1 ако не намери. С ! обръщам логиката: „ако НЕ е намерен, тогава вмъкни".

Не пропускам тази стъпка. Дублираните фрагменти в 200 файла са много по-лоши от липсващите — трудно се забелязват и могат да счупят layout или функционалност.

4. Първото нещо, което правя — бекап на всички файлове

Преди да пусна каквато и да е inplace команда върху файлове, правя пълен бекап. Не разчитам на -i.bak на sed или perl — това създава отделен .bak файл до всеки оригинален файл, което е хаос при 200 файла.

Вместо това създавам една отделна папка с timestamp и копирам всички HTML файлове там:

Бекап на всички HTML файлове преди масова замяна
# Създай папка за бекап в текущата директория
mkdir -p ./html_backup_$(date +%Y%m%d_%H%M%S)

# Копирай всички HTML файлове в бекъп папката
find . -name "*.html" -type f -exec cp {} ./html_backup_$(date +%Y%m%d_%H%M%S)/ \;

Резултатът е нещо подобно на ./html_backup_20250628_143052/ — една чиста папка с всички HTML файлове в тяхното оригинално състояние. Ако нещо се обърка, възстановявам с една команда:

Възстановяване от бекъп
find ./html_backup_20250628_143052/ -name "*.html" -type f -exec cp {} . \;
Внимание при възстановяване: горната команда копира файловете обратно в текущата директория, но запазва оригиналните пътища. Ако файловете са в поддиректории, трябва да използвам по-прецизен подход или rsync:
Възстановяване с rsync (запазва структура)
rsync -av --delete ./html_backup_20250628_143052/ ./

Защо не ползвам само -i.bak

sed -i.bak и perl -i.bak създават file.html.bak до всеки file.html. При 200 файла това означава 200 допълнителни файла, разпръснати из цялата директория. След като се уверя, че всичко е наред, трябва да изтрия 200 .bak файла — още една команда, още един риск.

С един бекъп папка имам: едно място за възстановяване, лесно изтриване на целия бекъп с rm -rf ./html_backup_20250628_143052/ и ясен timestamp кога е направен.

Моят навик: бекъп папката е първата команда, която изпълнявам. Чак след като видя, че файловете са там, започвам със замяната. Това ми струва 2 секунди и ми спестява часове при проблем.

5. Проблемът с новите редове в sed

Ето къде повечето уроци за sed спират или дават грешен съвет. Когато искам да заменя нещо с текст, който съдържа нов ред, командата:

❌ Това НЕ работи в повечето sed
sed -i "s|старо|ново\nоще|g" file.html

...в много ситуации не работи. Причината е, че стандартният sed (POSIX sed, GNU sed без определени флагове, BSD sed на macOS) третира \n в replacement низа литерално като backslash и n, а не като нов ред.

Това е един от най-често срещаните източници на объркване при работа с sed. Има поне три начина да се справя с това — всеки с свои предимства и недостатъци.

6. Вариант 1: sed с буквални нови редове

Най-простият и най-переносимият начин е да вложа буквален нов ред в replacement низа. В bash това означава просто да натисна Enter в средата на низа:

sed с буквален нов ред
sed -i "s|<div id=\"guide-nav\"></div>|<div id=\"related-posts\"></div>
 <div id=\"guide-nav\"></div>|g" file.html

Bash интерпретира новия ред вътре в двойни кавички като част от низа. sed получава реален newline символ в replacement низа и го записва във файла.

Този начин работи навсякъде — GNU sed, BSD sed, BusyBox sed. Не зависи от версии или флагове.

Реален пример: вмъкване на div в 200 HTML файла

Ето командата, която използвах, за да вмъкна <div id="related-posts"></div> преди <div id="guide-nav"></div> във всички HTML файлове:

find + sed с буквален нов ред — реален пример
find . -name "*.html" -type f -exec bash -c '
    if ! grep -q "<div id=\"related-posts\"></div>" "$1"; then
        sed -i "s|<div id=\"guide-nav\"></div>|<div id=\"related-posts\"></div>
 <div id=\"guide-nav\"></div>|g" "$1"
    fi
' _ {} \;
Предимство: работи на всяка платформа без изключения. Недостатък: командата изглежда грозно в терминала и е трудна за копиране/поставяне, защото новият ред е част от самата команда.

7. Вариант 2: sed с $'\n'

Bash има синтаксис $'...', който интерпретира escape последователности вътре в единични кавички. Това означава, че $'\n' се разширява до реален нов ред:

$'\n' в bash
echo $'ред1\nред2\nред3'

Мога да комбинирам това с sed. Ключът е, че $'\n' се разширява от bash преди да се подаде на sed. Така sed получава реален newline символ, без да му трябва поддръжка на \n в replacement:

sed с $'\n'
NEWLINE=$'\n'
sed -i "s|старо|ново${NEWLINE}още|g" file.html

Или директно в командата:

sed с $'\n' директно
sed -i "s|старо|ново$'\n'още|g" file.html

Кога използвам $'\n'

Използвам този подход, когато командата е част от скрипт и искам да е четлива. В one-liner от терминала предпочитам или буквалния нов ред, или perl.

Важно: $'\n' е bash-специфичен синтаксис. Не работи в plain POSIX sh или в други shell-ове като dash (което е /bin/sh на много Linux дистрибуции). Ако скриптът ми започва с #!/bin/bash, няма проблем.

Реален пример: вмъкване на script tag с $'\n'

find + sed с $'\n' — вмъкване на script tag
NL=$'\n'
find . -name "*.html" -type f -exec bash -c '
    if ! grep -q "<script src=\"/js/related.js\" defer></script>" "$1"; then
        sed -i "s|<script src=\"/js/nav-config.js\" defer></script>|<script src=\"/js/related.js\" defer></script>${NL}<script src=\"/js/nav-config.js\" defer></script>|g" "$1"
    fi
' _ {} \;
Предимство: командата е на един ред и се копира лесно. Недостатък: изисква bash (не работи с sh).

8. Вариант 3: perl — най-чистият вариант

Когато нещата стават сложни — много редове за вмъкване, специални символи, нужда от по-мощни регулярни изрази — преминавам към perl.

perl -i -pe прави същото като sed -i: чете файл ред по ред, прилага замяната и записва резултата обратно. Разликата е, че perl разбира \n в replacement низа без допълнителни трикове:

perl -i -pe с \n
perl -i -pe 's|старо|ново\nоще|g' file.html

Това просто работи. perl интерпретира \n като нов ред както в pattern, така и в replacement. Няма нужда от $'\n', няма нужда от буквални нови редове.

Реален пример: вмъкване на script tag с perl

Същата задача — вмъкване на <script src="/js/related.js" defer></script> преди <script src="/js/nav-config.js" defer></script> — но с perl:

find + perl — реален пример от моя сайт
find . -name "*.html" -type f -exec bash -c '
    if ! grep -q "<script src=\"/js/related.js\" defer></script>" "$1"; then
        perl -i -pe "s|<script src=\"/js/nav-config.js\" defer></script>|<script src=\"/js/related.js\" defer></script>\n<script src=\"/js/nav-config.js\" defer></script>|g" "$1"
    fi
' _ {} \;

Забелязвам, че тук използвам двойни кавички за perl командата, защото вътре има двойни кавички за HTML атрибутите — те са екранирани с \". Алтернативно мога да използвам единични кавички за perl и да сменя разделителя:

perl с единични кавички и различен разделител
perl -i -pe 's{<script src="/js/nav-config.js" defer></script>}{<script src="/js/related.js" defer></script>\n<script src="/js/nav-config.js" defer></script>}g' file.html
Предимство: \n работи естествено, по-мощни регулярни изрази, по-чист синтаксис при сложни замени. Недостатък: perl може да не е инсталиран на минимални системи (макар че на почти всеки Linux е наличен).

9. sed срещу perl — кое избирам?

Критерийsedperl
Наличност Във всяка Unix система Почти навсякъде, но не гарантирано
\n в replacement Не работи стандартно; нужда от трикове Работи директно
Производителност при много файлове По-бърз за прости замени Сравнимо бърз
Сложни регулярни изрази Основни (BRE/ERE) PCRE — пълен набор
Многоредова замяна Възможна, но тромава Естествена с \n
Четливост на командата Зависи от подхода за \n По-чиста при сложни случаи

Моето правило е просто: за едноредова замяна без нови редове използвам sed. Щом се появи нужда от \n в replacement, преминавам към perl.

10. Разделители — защо не винаги използвам /

Когато търся или замествам пътища до файлове, URL-та или HTML с атрибути, стандартният разделител / създава нужда от екраниране:

❌ С / разделител — много екраниране
sed -i "s/\/js\/nav-config\.js/\/js\/related\.js\n\/js\/nav-config\.js/g" file.html

Вместо това използвам | като разделител — и в sed, и в perl:

✅ С | разделител — чисто и четливо
sed -i "s|/js/nav-config\.js|/js/related\.js\n/js/nav-config\.js|g" file.html

И sed, и perl позволяват произволен символ след s да бъде разделител. Избирам символ, който не се среща в търсения или заместващия текст.

11. Флаг -i — inplace редакция

И sed -i, и perl -i редактират файла „на място" — прочитат го, прилагат промените и записват резултата върху оригиналния файл.

Резервно копие с -i

Мога да направя резервно копие преди редакция:

sed -i с резервно копие
sed -i.bak "s|старо|ново|g" file.html

Това създава file.html.bak с оригиналното съдържание. При perl е аналогично:

perl -i с резервно копие
perl -i.bak -pe 's|старо|ново|g' file.html
Ако вече имам бекъп папка от раздел 4, -i.bak е излишен. Използвам -i.bak само когато тествам на 1-2 файла без пълен бекъп. За масова обработка разчитам на бекъп папката и използвам -i без суфикс.

12. Как тествам преди да пусна наистина

Моят процес е винаги един и същ:

1. Бекъп

цялата директория

2. Dry run

без -i, виждам output

3. Пускам

на всички файлове

Стъпка 1: Бекъп

Както описах в раздел 4 — създавам бекъп папка с timestamp.

Стъпка 2: Dry run

Премахвам -i и виждам какво би се променило, без да променям файла:

Dry run — виждам резултата в терминала
perl -pe "s|<script src=\"/js/nav-config.js\" defer></script>|<script src=\"/js/related.js\" defer></script>\n<script src=\"/js/nav-config.js\" defer></script>|g" file.html

Стъпка 3: Пускам на всички

Чакато съм сигурен, пускам пълната команда с find и -i. Бекъп папката е моят застрахователен полис — ако нещо не е наред, възстановявам за секунди.

13. Специални символи — какво да екранирам

Когато търся HTML, често срещам символи, които имат специално значение в регулярни изрази:

СимволЗначение в regexЕкраниране
.Всеки символ\.
*Нулев или повече повторения\*
[ ]Клас символи\[ \]
( )Група (засечане)\( \) в sed; ( ) в perl
&Всичко, което е съвпаднало (sed)\&
/Разделител (ако е използван)\/ или смяна на разделител
|Алтернатива (в perl)\|
Важно за & в sed: в replacement низа на sed, символът & означава „цялото съвпадение". Ако заместващият текст съдържа литерален &, трябва да го екранирам с \&. В perl това не е проблем — там се използва $& за същата цел.

14. Замяна на цял ред, не само част от него

Понякога не искам да заменям част от ред, а целия ред. В sed мога да използвам c\ (change):

Замяна на цял ред с sed
sed -i '/<old-tag>/c\<new-tag>' file.html

В perl мога да заменя от началото до края на реда:

Замяна на цял ред с perl
perl -i -pe 's/^.*<old-tag>.*$/*<new-tag>*/' file.html

Или още по-чисто — проверявам дали редът съдържа търсения текст и заменям целия ред:

perl — условна замяна на цял ред
perl -i -pe 's/^.*<old-tag>.*$/<new-tag>/ if /<old-tag>/' file.html

15. Вмъкване преди или след определен ред

Понякога не искам да заменям, а просто да вмъкна нов ред преди или след съществуващ:

Вмъкване преди ред с sed
sed -i '/<div id="guide-nav">/i\<div id="related-posts"></div>' file.html
Вмъкване след ред с sed
sed -i '/<div id="guide-nav">/a\<div id="related-posts"></div>' file.html

i\ вмъква преди съвпадналия ред, a\ — след него. Това е често по-чист подход от s|...|...|, когато не ми трябва да заменям част от реда.

В perl аналогичните команди са:

Вмъкване преди ред с perl
perl -i -pe 'print "<div id=\"related-posts\"></div>\n" if /<div id="guide-nav">/' file.html

print преди if гарантира, че новият ред се изважда преди съвпадналия. За да вмъкна след, използвам:

perl — вмъкване след ред
perl -i -pe '$_.="<div id=\"related-posts\"></div>\n" if /<div id="guide-nav">/' file.html

16. Какво правя, когато имам специални символи навсякъде

Когато търсеният или заместващият текст е пълен с кавички, наклонени черти и точки, понякога е по-лесно да използвам fixed string вместо regex.

В perl използвам \Q...\E за литарален търсен текст:

perl с \Q — литерален търсен текст
perl -i -pe 's|\Q<script src="/js/nav-config.js" defer></script>\E|<script src="/js/related.js" defer></script>\n<script src="/js/nav-config.js" defer></script>|g' file.html

\Q...\E в perl цитира всичко между себе си — точките, звездичките и другите специални символи се третират като литерални. Това премахва нуждата от екраниране на всяка точка и наклонена черта в търсения текст.

Трик, който използвам често: \Q...\E около търсения текст и нормален replacement. Така не се тревожа за специални символи в това, което търся.

17. Обработка на файлове с различни encoding

Ако някои файлове са в UTF-8, а други в друг encoding, мога да укажа encoding на perl:

perl с указан encoding
perl -i -CS -Mutf8 -pe 's|старо|ново\nоще|g' file.html

-CS активира UTF-8 за stdin/stdout и -Mutf8 зарежда utf8 pragma. За български текст в HTML файлове обикновено не ми се налага — повечето ми файлове са вече в UTF-8 и perl работи коректно.

18. Комбиниране на множество замени

Когато трябва да направя няколко замени в един файл, мога да ги верижирам:

perl с множество замени
perl -i -pe '
    s|старо1|ново1|g;
    s|старо2|ново2\nново3|g;
    s|старо4|ново4|g;
' file.html

Това е по-ефективно от три отделни perl извиквания, защото файлът се чете и записва само веднъж. Същото може да се направи и с sed чрез множество -e аргументи:

sed с множество замени
sed -i -e 's|старо1|ново1|g' -e 's|старо2|ново2|g' file.html

19. Как избягвам бинарни файлове

Понякога в директорията има файлове, които изглеждат като .html, но са бинарни (например генерирани грешно или смесени с binary data). sed -i върху такъв файл може да го съсипе.

Добавям проверка с file:

Пропускане на бинарни файлове
find . -name "*.html" -type f -exec bash -c '
    if file "$1" | grep -q "text"; then
        if ! grep -q "ТЪРСЕН_ТЕКСТ" "$1"; then
            perl -i -pe "s|СТАРО|НОВО\nСТАРО|g" "$1"
        fi
    fi
' _ {} \;
Не е задължително за добре структурирани проекти. Добавям тази проверка, когато работя с директория, която не контролирам напълно — например миграция на стари файлове.

20. Производителност при много файлове

Когато имам хиляди файлове, find -exec с bash -c стартира нов процес за всеки файл. Това е бавно. По-бързи алтернативи:

Опция 1: xargs

find + xargs за по-бърза обработка
find . -name "*.html" -type f -print0 | xargs -0 perl -i -pe 's|старо|ново\nстаро|g'

-print0 и -0 използват null byte като разделител — безопасно за имена на файлове с интервали и специални символи. xargs подава максималния брой аргументи на едно perl извикване.

Без grep -q проверка! При xargs не мога лесно да добавя условие „замени само ако не съществува". Използвам xargs само когато съм сигурен, че искам да заменя във всички файлове, или когато замяната е идемпотентна (повторното прилагане не променя нищо).

Опция 2: while loop

while loop — по-бърз от -exec bash -c
while IFS= read -r -d '' f; do
    if ! grep -q "ТЪРСЕН_ТЕКСТ" "$f"; then
        perl -i -pe "s|СТАРО|НОВО\nСТАРО|g" "$f"
    fi
done < <(find . -name "*.html" -type f -print0)

Това стартира само един bash процес и извиква perl за всеки файл отделно — но без допълнителния bash -c overhead на -exec.

21. Трите варианта за нов ред — обобщена таблица

ПодходСинтаксисРаботи сЧетливостПреносимост
Буквален нов ред "s|a|b\nc|g" (Enter в низа) Всички sed и perl ⭐⭐ ⭐⭐⭐
$'\n' "s|a|b$'\n'c|g" Само bash + sed/perl ⭐⭐⭐ ⭐⭐ (bash only)
perl \n perl -pe 's|a|b\nc|g' Само perl ⭐⭐⭐ ⭐⭐ (нужен perl)

22. Моите реални команди — как изглеждат в практиката

Ето двете команди, които използвах при последната промяна в сайта си. Първата вмъква script tag с perl, втората — div с sed:

Вмъкване на related.js script tag (perl)

Реална команда от fedia.eu — perl вариант
find . -name "*.html" -type f -exec bash -c '
    if ! grep -q "<script src=\"/js/related.js\" defer></script>" "$1"; then
        perl -i -pe "s|<script src=\"/js/nav-config.js\" defer></script>|<script src=\"/js/related.js\" defer></script>\n<script src=\"/js/nav-config.js\" defer></script>|g" "$1"
    fi
' _ {} \;

Вмъкване на related-posts div (sed с буквален нов ред)

Реална команда от fedia.eu — sed вариант
find . -name "*.html" -type f -exec bash -c '
    if ! grep -q "<div id=\"related-posts\"></div>" "$1"; then
        sed -i "s|<div id=\"guide-nav\"></div>|<div id=\"related-posts\"></div>
 <div id=\"guide-nav\"></div>|g" "$1"
    fi
' _ {} \;

И двете команди следват един и същ модел: find намира файловете, grep -q проверява дали фрагментът вече съществува, и само при липса се извършва замяната. Разликата е само в инструмента за самата замяна — perl за случая с script tag-а, sed за случая с div-а.

23. Командите накратко

КомандаЗа какво я използвам
mkdir -p ./html_backup_$(date +%Y%m%d_%H%M%S)Създавам бекъп папка с timestamp.
find . -name "*.html" -exec cp {} БЕКАП/ \;Копирам всички HTML в бекъп папката.
find . -name "*.html" -type fНамирам всички HTML файлове.
grep -q "ТЕКСТ" файлТихо проверявам дали текст съществува.
sed -i "s|старо|ново|g" файлЗаменям текст в файл (без нови редове).
sed -i "s|a|b\nc|g" файлЗаменям с буквален нов ред в replacement.
sed -i "s|a|b$'\n'c|g" файлЗаменям с $'\n' за нов ред (bash only).
perl -i -pe 's|a|b\nc|g' файлЗаменям с \n — работи естествено в perl.
sed -i '/ PATERN /i\НОВ_РЕД' файлВмъквам ред преди съвпадение.
sed -i '/ PATERN /a\НОВ_РЕД' файлВмъквам ред след съвпадение.
perl -i -pe 's|\Q...\E|...|g'Литарален търсен текст без екраниране.
rsync -av --delete БЕКАП/ ./Възстановявам от бекъп папка.
find ... -print0 | xargs -0 perl ...Бърза обработка на много файлове.

24. Заключение

Търсенето и замяната в множество файлове не е сложна задача, ако знам правилните инструменти и — по-важното — правилните трикове за новите редове.

sed е достатъчен за повечето прости замени. Когато се появи нужда от \n в replacement низа, имам три варианта: буквален нов ред (най-переносим), $'\n' (най-четим в bash), или perl (най-чист синтаксис).

Но първото нещо, което правя, преди да напиша каквато и да е замяна, е бекъп. Една папка с timestamp, една команда за копиране и една команда за възстановяване. Тези 2 секунди ми дават спокойствието да експериментирам без страх.

Моят принцип: ако ще редактирам повече от 5 файла по един и същ начин, вече пиша команден ред, а не отварям файловете един по един. Но винаги — първо бекъп, после замяна. Спестеното време се натрупва, а рискът е минимален.