Портрет 4X_Pro
Был в Сети 11 сент. 2025 г., 23:01
Мультиблог
4X_Pro
Кратко о себе: Web-разработчик. Пишу на PHP, Python, JavaScript. Знаю Ruby и Go, со студенческих времён более-менее помню C и asm. Специализируюсь на ускорении загрузки сайтов и разработке ботов для Telegram. Linuxоид (использую Debian+LXDE). Сторонник IndieWeb, slow lifer.

Социальные сети


Новости сайта в Telegram

t.me/4x_pro

Компьютерное

Особенности работы с HTML в PHP 8.4

4X_Pro

В версиях PHP до 8.4 для очистки выборочной очистки HTML использовался класс DOMDocument, который изначально был предназначен для работы с XML. Однако в версии 8.4 его использование приводит к тому, что возвращается пустая строка. Стал искать решение, и выяснилось, что с этой версии именно для HTML появился новый класс: Dom\HTMLDocument, а для выборки с помощью XPath вместе с ним теперь следует использовать Dom\XPath.
В итоге код для обработки HTML приобретает примерно такой вид:
<?php
function process_html($html) {
    if (
version_compare(PHP_VERSION,'8.4','>=')) { // для PHP 8.4 и выше
      
$dom Dom\HTMLDocument::createFromString($html,LIBXML_HTML_NOIMPLIED);
      
$xpath = new Dom\XPath($dom);
    }
    else { 
// для более старых версий PHP
      
$html mb_encode_numericentity($html, [0x800x10FFFF0, ~0], $charset);      
      
$dom = new \DOMDocument('1.0',$charset);
      
$dom->formatOutput false;
      
$dom->loadHTML($htmlLIBXML_NONET|LIBXML_HTML_NOIMPLIED|LIBXML_HTML_NODEFDTD); // LIBXML_NONET — for protection against XXE, LIBXML_HTML_NOIMPLIED|LIBXML_HTML_NODEFDTD — to don't add DOCTYPE and html/body tags
      
$xpath = new DOMXPath($dom);            
    }

// TODO: код обработки

$result=$dom->saveHTML(); // возвращаем результат
if (version_compare(PHP_VERSION,'8.4','<')) $result = \mb_decode_numericentity($result,[0x800x10FFFF0, ~0], $charset);
return 
$result;
}


Ещё одно существенное отличие: Dom\XPath возвращает имена тегов в заглавном регистре, тогда как DOMXPath — в том регистре, в котором теги написаны в HTML-коде, что также может влиять на обработку.

Немного о юзабилити обычных ссылок

4X_Pro

Казалось бы, что может быть проще, чем вывести ссылку в HTML? Однако если создавать сайт, наполнением которого будут заниматься сами пользователи, обнаруживается множество тонкостей в плане юзабилити, которые нужно учитывать. Во-первых, ссылки, вставленные пользователями, могут быть слишком длинными из-за большого «хвоста» параметров (типичный пример — ссылка на страницы Интернет-магазинов с настроенными фильтрами) и переноситься на несколько строк, и в таких случаях их нужно визуально сокращать. Во-вторых, если в ссылке есть кириллица, она при копировании через буфер обмена преобразуется либо в punycode, либо в url encoding и становится нечитаемой. Пример подобного — ссылки на Wikipedia, глядя на которые, нельзя сказать до перехода, на какую статью они ведут. В-третьих, если ссылка ведёт на корневую страницу сайта, желательно показывать только доменное имя, без http или https в начале и / в конце.
Возникает необходимость сделать обработчик для ссылок без описаний (т.е. вида <a href="URL">URL</a>), который справлялся бы с перечисленными ситуациями. Я для себя написал следующую функцию:
<?php
function link_processor($matches) {
    
$url_parts parse_url($matches[3]);

        
// Blocks unallowed schemes
    
if (!empty($url_parts['scheme']) && !in_array(strtolower($url_parts['scheme']),array('http','https','ftp','tg','magnet'))) return '*** Invalid or malicious link! ***';
    
$scheme = empty($url_parts['scheme']) ? '' $url_parts['scheme'];
    
    if (
$matches[3]!==$matches[5]) return $matches[0]; // skipping links with user's description

        // processing URL parts
    
$host = (!empty($url_parts['host'])) ? mb_strtoupper(mb_substr($url_parts['host'],0,1)).mb_substr($url_parts['host'],1) : '';
    
$path = (empty($url_parts['path']) || $url_parts['path']==='/') ? '' $path urldecode($url_parts['path']);
        
$query = (!empty($url_parts['query'])) ? $url_parts['query'] : '';    
    
$fragment = (!empty($url_parts['fragment'])) ? '#'.urldecode($url_parts['fragment']) : '';    
    
    
// Wikipedia
    
if (strtolower(substr($host,-14))==='.wikipedia.org' && mb_substr($path,0,6)==='/wiki/'$new_link 'Wikipedia: '.str_replace('_',' ',mb_substr($path,6));
    
// Google
    
elseif (strtolower(substr($host,-10))==='google.com' && $path==='/search'  && substr($query,0,2)==='q=') {
       
$pos strpos($query,'&');
       if (
$pos!==false$query=substr($query,0,$pos);
       
$new_link "Google: ".urldecode(substr($query,2));
    }
    
// Yandex
    
elseif ((strtolower(substr($host,-5))==='ya.ru' || strtolower(substr($host,-9))==='yandex.ru') && $path==='/search/'  && substr($query,0,5)==='text=') {
       
$pos strpos($query,'&');
       if (
$pos!==false$query=substr($query,0,$pos);
       
$new_link "Yandex: ".urldecode(substr($query,5));
    }
    
// Telegram
    
elseif (strtolower(substr($host,-4))==='t.me' || $scheme=='tg'$new_link 'Telegram: '.mb_substr($url_parts['path'],1);
    
// YouTube
    
elseif (strtolower(substr($host,-11))==='youtube.com' || strtolower(substr($host,-8))==='youtu.be') {
        
$new_link 'YouTube: ';        
        if (
$path==='/watchv') {
            
$pos strpos($query,'&');
           if (
$pos!==false$new_link.=substr($query,0,$pos);
           else 
$new_link.=$query;
        }
        if (
$path==='/watch') {
          if (
preg_match('|v=([\w\-]+)|',$query,$match)) $new_link.=$match[1];
          else 
$new_link $matches[5]; // if v parameter not found, fallbacks to initial link description
        
}
        else 
$new_link.=mb_substr($path,1);
        if (
preg_match('|t=(\d+)|',$query,$match)) { // if time offset specified
            
$time intval($match[1]);
            
$min floor($time/60);
            
$sec $time 60;
            
$new_link.=" ($min:$sec)";
        }
    }
    else {
        if (
$query && strpos($path,'search')===false && !preg_match('/search=|text=|query=|filter=/i',$query)) $query='?...';
        elseif (
strlen($query)>32$query=substr($query,0,32).'…';
        
        
$host idn_to_utf8($host);
        if (empty(
$path)) $host=mb_strtoupper(mb_substr($host,0,1)).mb_substr($host,1);        
        if (
mb_strtolower($host)==='vk.com' || mb_strtolower($host)==='m.vk.com'$host='VK.com';

                 if (
mb_strlen($path)>48$path mb_substr($path,0,6).'…'.mb_substr($path,-6);
            if (
mb_strlen($fragment)>32$path mb_substr($path,0,32).'…';

        
        
$new_link $host.$path.$query.$fragment;
    }

    return 
str_replace('>'.$matches[5].'<','>'.$new_link.'<',$matches[0]);
}


Эта функция сокращает пути длиннее 48 символов, строки параметров и anchors длиннее 32, скрывает префиксы и делает первую букву имени домена заглавной. Для её корректной работы необходим модуль mbstring.
Кроме перечисленного выше, эта функция умеет также распознавать поисковые ссылки на Яндекс и Google, и ссылки на YouTube, выделяя из них идентификатор ролика и время, с которого тот начнёт воспроизводиться, если оно указано.

Вызывать её нужно с помощью preg_replace_callback (предположим, что текст для обработки лежит в переменной $text):
<?php
    $text 
preg_replace_callback('|<a\s+([^>]*?)href=(["\']?)(\S+)\2([^>]*)>(.*?)</a\s*>|isu','link_processor',$text);


Посмотреть код в действии с тестовыми примерами можно на onlinephp.io/c/edbb8.

Заголовки Sec-CH-UA-Mobile и HTTP_SEC_CH_UA_PLATFORM

4X_Pro

Несколько лет назад я писал заметку о том, как определить мобильный броузер с помощью регулярного выражения для User Agent. Однако в современных броузерах на основе Chrome появился дополнительный и более простой механизм: при обращении к сайту эти броузеры передают специальные заголовки Sec-CH-UA-Mobile и Sec-CH-UA-Platform. В первом приходит значение ?0 для desktop-версии и ?1 для мобильной, во втором — платформа в виде одной из строк: "Android", "Chrome OS", "Chromium OS", "iOS", "Linux", "macOS", "Windows" или "Unknown".
В PHP эти заголовки можно получить как $_SERVER['HTTP_SEC_CH_UA_MOBILE'] и $_SERVER['HTTP_SEC_CH_UA_PLATFORM'] соответственно. (Буквы должны быть именно заглавными.)
Данный механизм поддерживается с 89 версии Chrome, но всё ещё имеет статус экспериментального. В Firefox и Safari поддержка на данный момент, по данным CanIUse, отсутствует.

Выделение хештегов из текста

4X_Pro

С помощью уже упоминавшегося класса IntlBreakIterator можно легко выделить из текста хеш-теги. Для этого создадим итератор не по предложениям, а по словам с помощью: createWordInstance. Итерация с помощью IntlBreakIterator выдаёт смещения границ слов, что в данном случае не очень удобно. Поэтому воспользуемся методом IntlBreakIterator::getPartsIterator. Он создаёт ещё один итератор, который возвращает уже непосредственно сами слова. Далее, когда встретится символ # (он в режиме WordInstance считается отдельным словом), запоминаем этот факт и следующее слово обрабатываем как хеш-тег.

Вот пример кода:
function getHashTags($text) { $breaker =IntlBreakIterator::createWordInstance('ru_RU'); $breaker->setText($text); $iterator = $breaker->getPartsIterator(); $hashtags = array(); $mode = false; // теперь в $item будут сами слова, а не их смещения foreach ($iterator as $item) { // выставляем $mode в TRUE, если следующее слово нужно обработать как хеш-тег if ($item==='#') $mode=true; elseif ($mode && trim($item) && mb_strlen($item)>1) { // ряд дополнительных проверок, чтобы в хеш-теги не попадал мусор $hashtags[] = mb_strtolower($item); // обработка закочена, поэтому сбрасываем $mode до тех пор, пока не попадётся новый символ # $mode = false; } else $mode=false; } return $hashtags; }
Проверка mb_strlen($item)>1 нужна для отработки случаев вида #! #;,так как в этом случае знаки препинания рассматриваются как отдельные слова. Кроме того, хештеги из одной буквы обычно не имеют смысла.

Автоматическое создание анонса текста на PHP

4X_Pro

При создании скриптов для новостей или блогов часто требуется автоматически сформировать анонс из нескольких первых предложений полного текста, не превышающий определённой длины. Типичный подход для этих целей — это выделить с помощью mb_substr начальную часть текста нужной длины, а потом обрезать получившуюся часть до последней точки.

Но не все знают, что с версии 5.5 в PHP в модуле intl предусмотрен специальный класс IntlBreakIterator, который позволяет решать эту задачу более эффективно. У него есть несколько статических методов для создания итераторов, позволяющих выделять границы отдельных символов (createCharacterInstance), слов (createTitleInstance и createWordInstance, первый выделяет слова с включением пробелов и знаков препинания после них, второй — рассматривает пробелы и знаки препинания как отдельные слова) и целых предложений (createSentenceInstance). Для нашей задачи потребуется последний.

Далее всё просто: создаём экземпляр этого класса, передаём ему текст и получаем смещение конца ближайшего предложения с помощью методов preceding (ближайшая граница до нужной длины) и following (ближайшая граница после):
$test = 'Это тестовый текст. Текст длинный! И с многоточиями… Но нужно лишь только несколько начальных предложений. '; // задаём длину анонса $max_length = 80; // создаём экземпляр класса и указываем локаль для русского языка $breaker =IntlBreakIterator::createSentenceInstance('ru_RU'); // задаём текст для обработки $breaker->setText($test); // получаем ближайшую границу предложения перед указанной в $max_length позицией $offset = $breaker->preceding($max_length); // если оказалось, что первое предложение больше требуемой длины, берём его целиком, иначе анонс будет пустым if ($offset==0) $breaker->following($max_length); // выделяем начало текста $summary = substr($test,0,$offset); // выводим то, что получилось print $summary;
Важно: IntlBreakIterator возвращает смещения в байтах, поэтому для выделения строки нужно использовать substr, а не mb_substr!

Сохранение JPEG в строку, а не файл с помощью PHP GD

4X_Pro

Иногда при работе с изображением с помощью PHP GD может потребоваться сохранить его в строку, а не файл. Например, такое может потребоваться для вывода картинки прямо в коде страницы в формате base64-encoded.
Первое что приходит в голову — это использовать ob_start для создания дополнительного буфера вывода, вывести изображение в него, и дальше получить изображение через ob_get_clean(). Но, как выяснилось, на практике это не работает должным образом — возвращается пустая строка. Возможно, проблема в нулевых байтах или в том, что в строке есть последовательности, которые с точки зрения utf-8 являются некорректными.
Но, как выяснилось, в PHP есть другое решение: открытие файла в памяти (в качестве имени файла нужно указать "php://memory") и работа с ним как с потоком. В этом случае можно добавить стандартный потоковый фильтр, кодирующий данные в base64 на лету. Это даёт возможность избежать проблем с бинарной строкой, описанных выше. Получаем такой код:
function jpeg_to_string($img, $jpeg_quality=80) {   $stream = fopen("php://memory", "w+"); // открываем поток в памяти   stream_filter_append($stream, 'convert.base64-encode', STREAM_FILTER_WRITE); //добавляем фильтр   imagejpeg($img, $stream, $jpeg_quality); // сам вывод картинки   rewind($stream); // возвращаемся к началу потока   $data = stream_get_contents($stream); // читаем содержимое потока в строку   fclose($stream);      return $data; }

Выборочная очистка HTML

4X_Pro

Как известно, любой текст, который вводится пользователем и затем отображается на сайте, нужно обезопасить от XSS-атак — вставок JavaScript, которые могут украсть идентификатор сессии или совершить какие-либо нежелательные действия от имени пользователя. Если текст не предполагает сложного форматирования, то сделать это достаточно легко: пропустить его через функцию htmlspecialchars, которая экранирует все небезопасные символы и превратит HTML в обычный текст. Но как быть, если пользователю нужно разрешить использовать форматирование текста, например, вставку картинок, ссылок, текста с курсивом и жирным начертанием, видео?

Первое, что приходит в голову — это воспользоваться функцией strip_tags со списком разрешённых тегов. Увы, эта функция имеет существенный недостаток: если тег разрешён, то она позволяет использовать в нём любые атрибуты, в том числе и атрибуты обаботчиков событий (onclick, onmouseover и так далее), на которые легко можно повесить вредоносный код.

Другой вариант — это использование специальных языков разметки, например, BBCode или Markdown, которые затем преобразовывать в HTML. Главный недостаток такого подхода — в том, что это существенно сужает выбор WYSIWYG-редакторов, так как далеко не в каждом из них есть поддержка этих языков.
Поэтому приходится прибегать к другому решению — использованию расширения DOM и удалению все тех тегов и атрибутов, которых нет в белом списке. Для начала решим, как будем задавать этот белый список. На мой взгляд, самый эффективный вариант — это хеш-массив, где ключи — это теги, а значения — массивы разрешённых для тега атрибутов (в примерах кода дальше будем считать, что он лежит в $tags, а HTML-код для очистки — в HTML)
Для начала просто удалим все те теги, которых нет в списке разрешённых, с помощью функции strip_tags:
$html = strip_tags($html,'<'.join('><',array_keys($tags)).'>');
Теперь загрузим HTML-код в объект DOMDocument и создадим объект XPath для поиска атрибутов тегов и выполним этот поиск:
$html = mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, ~0], 'UTF-8'); // без этого не будет корректно работать с UTF-8 if (!class_exists('DOMDocument')) throw new Exception('DOM extension not loaded!'); $dom = new DOMDocument(); $dom->formatOutput = false; $dom->loadHTML($html,LIBXML_NONET|LIBXML_HTML_NOIMPLIED|LIBXML_HTML_NODEFDTD); $xpath = new DOMXPath($dom); $nodes = $xpath->query('//@*');
В переменной $nodes лежит список таких узлов-атрибутов. Пройдёмся по ним и проверим, есть ли тег (на него указывает $node->parentNode->nodeName) в списке разрешённых тегов и есть ли сам атрибут ( $node->nodeName) в списке разрешённых атрибутов для этго тега. Если его там не будет, обратимся к родительскому элементу через $node->parentNode и вызовем метод removeAttribute для его удаления.
foreach ($nodes as $node) {   $tagname = $node->parentNode->nodeName;   if (!empty($tags[$tagname])) {     $attrs = $tags[$tagname];     $attrname = $node->nodeName;     if (!in_array($attr_name,$attrs)) $node->parentNode->removeAttribute($attrname);   } }
Теперь осталась ещё одна задача: проверить атрибуты href и src на наличие адресов вида javascript:alert('Небезопасно'). Для этого найдём с помощью XPath все атрибуты href и src, и проверим, какой протокол для ссылок используется. Если там есть слово script (так как кроме javascript, можно использовать ещё и vbscript), будем считать такую ссылку небезопасной и заменим её на безопасное значение "#":
    $links = $xpath->query('//@href|//@src');     foreach ($links as $link) {       $scheme = parse_url($link->textContent,PHP_URL_SCHEME);       if (strpos(strtolower($scheme),'script')!==false) {                $link->nodeValue='#'; // removing dangerous link address       }     }

Теперь осталось только сохранить обработанный HTML-код из DOM-дерева обратно в строку:
$html = $dom->saveHTML();
Посмотреть полный код, оформленный в виде класса HTMLCleaner со статическим методом clean, можно в приложенном файле. В этом же классе определён набор констант с наиболее часто требующимися тегами и их атрибутами: TAGS_MINIMUM — только a и img, TAGS_MEDIA — для мультимедиа-тегов audio и video, TAGS_INLINE — для самых частых строчных тегов оформления, TAGS_FORMAT — для типичных блочных тегов. При необходимости их можно объединять через операцию +.

Немного о заголовках в PHP

4X_Pro

Те, кто занимается разработкой на PHP уже давно и не использует frameworks, наверное, не раз сталкивались с необходимостью правильно выводить код HTTP-ответа с правильной версией протокола и текстовым описанием (например, Ok для 200, Not Found для 404 и т.д.).
Раньше для этих целей приходилось писать множество проверок условий вида:
if ($status==200) header($_SERVER['HTTP_PROTOCOL'].' 200 Ok'); elseif ($status==404) header($_SERVER['HTTP_PROTOCOL'].' 404 Not Found'); // elseif обработка других статусов.
Задача эта совершенно рутинная, и возникает вопрос, неужели нельзя это сделать средствами самого языка PHP. Как выяснилось, ещё в версии 5.4 такая возможность появилась — это функция http_response_code, которой достаточно передать числовой код статуса ответа. Например, если вызвать http_response_code(404), то сервер автоматически сгенерирует заголовок HTTP/1.1 404 Not Found (если запрос был по протоколу HTTP 1.1). Также можно вызвать функцию без параметров, чтобы получить код, который был установлен до этого (по умолчанию он равен 200). Так что теперь выдача заголовка сводится к всего одной строчке кода!
Кроме того, в той же версии появилась ещё одна полезная функция: header_register_callback. Эта функция позволяет устанавливать обработчик, который будет вызван перед отправкой HTTP-заголовков клиенту. В нём можно использовать функции headers_list для получения списка подготовленных заголовков и header_remove для удаления тех, которые требуется исключить из отправки.

Regexp для выноса URL ссылки в скобки за её текстом

4X_Pro

Иногда бывает нужно вынести адрес ссылки в скобки за её текстом, то есть преобразовать её из вида

<a href="http://example.com">Текст</a>
в формат
Текст (http://example.com)
Такое, например, полезно при выдаче данных в RSS, который впоследствии импортируется в Twitter.
Делается это достаточно легко с помощью такого регулярного выражения:
$text = preg_replace('|<a\W[^>]*?href=[\'"]([^>]+?)[\'"][^>]*?>(.*?)</a>|u','$2 ($1)',$text);

Полный список чаcовых поясов в PHP

4X_Pro

Как известно, в PHP есть функция date_default_timezone_set, которая устанавливает часовой пояс, который будет использоваться по умолчанию при работе с датами. На вход она принимает строки вида "Europe/Moscow", "Africa/Algiers". Полный список этих строк есть в мануале, но каждый раз переписывать его оттуда (или хотя бы выбирать те, которые используются среди целевой аудитории сайта) — напрасная трата сил и времени. Кроме того, если нужно хранить в базе часовой пояс каждого пользователя, то лучше сохранять его как число, а не как строку.
Оказалось, что в PHP предусмотрена функция для решения этой проблемы. Называется она DateTimeZone::listIdentifiers. По умолчанию выводит вообще все имеющиеся идентификаторы временных зон. Но можно получить список часовых поясов для отдельного региона или даже нескольких, передав их в качестве первого параметра: DateTimeZone::listIdentifiers(DateTimeZone::EUROPE | DateTimeZone::ASIA);
Также можно установить в первом параметре значение DateTimeZone::PER_COUNTRY и в качестве второго передать двухбуквенный код страны. Важно: буквы в коде должны быть заглавными:
DateTimeZone::listIdentifiers(DateTimeZone::PER_COUNTRY,'RU');
Дальше всё просто: получить список идентификаторов, пронумеровать значения и вывести в select, а дальше сохранить номер выбранного значения в базе вместо строки.


Страницы:
Задать вопрос