Автоматическое сжатие и оптимизация картинок на сайте

Изображения нужно сжимать для ускорения скорости загрузки сайта, но как это сделать? На многих хостингах нет возможности устанавливать приложения, поэтому использование unix приложений optipng, pngcrush, jpegtran отпадает. Выкачивать картинки и сжимать их FileOptimizer или другими программами не продуктивно т.к. через время всю процедуру нужно повторять.

Решение – использовать сервисы для сжатия изображений по API, например tinypng.com. Поддерживает PNG и JPG до 5mb, бесплатен до 500 фото в месяц.

Напишем скрипт который по крону будет автоматом собирать файлы и отправлять их на сжатие.

1

Создадим таблицу `optimize_img`, в которой будут храниться пути и имена файлов, статус обработки и ошибки. Поле `diff` используется для хранения разницы между оригинальным и сжатым файлом для статистики.

CREATE TABLE IF NOT EXISTS `optimize_img` (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `img` varchar(255) NOT NULL,
  `done` tinyint(1) UNSIGNED NOT NULL DEFAULT '0',
  `error` varchar(255) NOT NULL,
  `diff` int(11) UNSIGNED NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
SQL
2

Найдем все картинки в нужных категориях сайта с помощью рекурсивной версии функции glob() и добавим новые в БД.

// Подключение к БД.
$dbh = new PDO('mysql:dbname=db_name;host=localhost', 'логин', 'пароль');

// Категории в которых лежат картинки.
$dirs = array(
	__DIR__ . '/themes',
	__DIR__ . '/uploads',
);

// Расширения файлов.
$exts = array('png', 'jpg', 'jpeg');

function glob_recursive($pattern, $flags = 0)
{
	$files = glob($pattern, $flags);
	foreach (glob(dirname($pattern) . '/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
		$files = array_merge($files, glob_recursive($dir . '/' . basename($pattern), $flags));
	}
	return $files;
}

foreach ($dirs as $dir) {
	foreach (glob_recursive($dir . '/*.*') as $file) {
		$ext = strtolower(substr(strrchr($file, '.'), 1));
		if (in_array($ext, $exts)) {
			$file = str_replace(__DIR__, '', $file);
			$sth = $dbh->prepare("SELECT * FROM `optimize_img` WHERE `img` = ?");
			$sth->execute(array($file));		
			$isdb = $sth->fetch(PDO::FETCH_ASSOC);
			if (empty($isdb)) {
				$sth = $dbh->prepare("INSERT INTO `optimize_img` SET `img` = ?");
				$sth->execute(array($file));
			}
		}
	}	
}
PHP

Таблица заполнится данными:

Таблица БД заполнится данными

3

Получим ключ к API, для этого нужно зарегистрироваться на странице tinypng.com/developers.

После отправки формы придет письмо с ссылкой в личный кабинет.

tinypng.com ключ API

Получим запись из БД и отправим POST-запрос в формате JSON:

// Ключ API tinypng.com
$api_key = 'xxxxxxxxxxxxxxxxxxxxxxxxxxx';

$ch = curl_init('https://api.tinify.com/shrink');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_USERPWD, ':' . $api_key); 
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json')); 	
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, 
	json_encode(
		array(
			'source' => array(
				'url' => 'http://example.com' . str_replace('%2F', '/', rawurlencode($item['img']))
			)
		)
	)
); 

$res = curl_exec($ch);
curl_close($ch);    

$res = json_decode($res, JSON_OBJECT_AS_ARRAY);
var_dump($res);
PHP

URL-кодирование str_replace('%2F', '/', rawurlencode($item['img'])) нужно для файлов с русскими названиями и пробелами иначе возникают ошибки.

Сервис возвращает данные в JSON:

Array(
	[input] => Array(
		[size] => 26109
		[type] => image/png
	)
	[output] => Array(
		[size] => 11465
		[type] => image/png
		[width] => 155
		[height] => 130
		[ratio] => 0.4391
		[url] => https://api.tinify.com/output/90uwbe47cg741bh68r805wzapwp9brfh
	)
)
JS

Обработаем ответ. Если все прошло без ошибок, исходный файл переименуется в .bak, на его место с копируется новый.

if (!empty($res['error'])) {
	// Сервис вернул ошибку.
	$sth = $dbh->prepare("UPDATE `optimize_img` SET `error` = ? WHERE `id` = ?");
	$sth->execute(array($res['message'], $item['id']));	
} elseif (!empty($res['output']['url'])) {
	// Файл сжат, пробуем заменить им исходный.
	$file = __DIR__ . $item['img'];

	if (rename($file, $file . '.bak')) {
		if (copy($res['output']['url'], $file)) {
			$diff = $res['input']['size'] - $res['output']['size'];

			$sth = $dbh->prepare("UPDATE `optimize_img` SET `done` = 1, `diff` = ? WHERE `id` = ?");
			$sth->execute(array($diff, $item['id']));	
			exit;
		} else {
			rename($file . '.bak', $file);
		}
	}

	$sth = $dbh->prepare("UPDATE `optimize_img` SET `error` = ? WHERE `id` = ?");
	$sth->execute(array('Ошибка замены файла', $item['id']));
}
PHP

Соберем и оптимизируем весь код в один файл optimize_img.php

При каждом запуске скрипта идет проверка на наличие несжатых файлов в БД, если очередь закончилась, то идет поиск и добавление новых файлов, и так по кругу.

<?php
// Подключение к БД.
$dbh = new PDO('mysql:dbname=db_name;host=localhost', 'логин', 'пароль');

// Категории в которых лежат картинки.
$dirs = array(
	__DIR__ . '/themes',
	__DIR__ . '/uploads',
);

// Расширения файлов.
$exts = array('png', 'jpg', 'jpeg');

// Ключ API tinypng.com
$api_key = 'xxxxxxxxxxxxxxxxxxxxxxxxxxx';

function glob_recursive($pattern, $flags = 0)
{
	$files = glob($pattern, $flags);
	foreach (glob(dirname($pattern) . '/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
		$files = array_merge($files, glob_recursive($dir . '/' . basename($pattern), $flags));
	}
	return $files;
}

// Получим файл из очереди.
$sth = $dbh->prepare("SELECT * FROM `optimize_img` WHERE `done` = 0 AND `error` = '' ORDER BY `id` DESC");
$sth->execute();
$item = $sth->fetch(PDO::FETCH_ASSOC);

if (empty($item)) {
	// Если больше нет файлов, соберем и добавим новые.
	foreach ($dirs as $dir) {
		foreach (glob_recursive($dir . '/*.*') as $file) {
			$ext = strtolower(substr(strrchr($file, '.'), 1));
			if (in_array($ext, $exts)) {
				$file = str_replace(__DIR__, '', $file);
				$sth = $dbh->prepare("SELECT * FROM `optimize_img` WHERE `img` = ?");
				$sth->execute(array($file));		
				$isdb = $sth->fetch(PDO::FETCH_ASSOC);
				if (empty($isdb)) {
					$sth = $dbh->prepare("INSERT INTO `optimize_img` SET `img` = ?");
					$sth->execute(array($file));
				}
			}
		}	
	}
} elseif (!is_file(__DIR__ . $item['img'])) {
	// Если файл уже удалили.
	$sth = $dbh->prepare("DELETE FROM `optimize_img` WHERE `id` = ?");
	$sth->execute(array($item['id']));	
} else { 
 	// Отправка файла в tinypng.com
	$ch = curl_init('https://api.tinify.com/shrink');
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
	curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
	curl_setopt($ch, CURLOPT_HEADER, false);
	curl_setopt($ch, CURLOPT_USERPWD, ':' . $api_key); 
	curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json')); 	
	curl_setopt($ch, CURLOPT_POST, 1);
	curl_setopt($ch, CURLOPT_POSTFIELDS, 
		json_encode(
			array(
				'source' => array(
					'url' => 'http://example.com' . str_replace('%2F', '/', rawurlencode($item['img']))
				)
			)
		)
	); 

	$res = curl_exec($ch);
	curl_close($ch);    

	$res = json_decode($res, JSON_OBJECT_AS_ARRAY);
	//var_dump($res);

	if (!empty($res['error'])) {
		// Сервис вернул ошибку.
		$sth = $dbh->prepare("UPDATE `optimize_img` SET `error` = ? WHERE `id` = ?");
		$sth->execute(array($res['message'], $item['id']));	
	} elseif (!empty($res['output']['url'])) {
		// Файл сжат, пробуем заменить им исходный.
		$file = __DIR__ . $item['img'];

		if (rename($file, $file . '.bak')) {
			if (copy($res['output']['url'], $file)) {
				$diff = $res['input']['size'] - $res['output']['size'];

				$sth = $dbh->prepare("UPDATE `optimize_img` SET `done` = 1, `diff` = ? WHERE `id` = ?");
				$sth->execute(array($diff, $item['id']));	
				exit;
			} else {
				rename($file . '.bak', $file);
			}
		}

		$sth = $dbh->prepare("UPDATE `optimize_img` SET `error` = ? WHERE `id` = ?");
		$sth->execute(array('Ошибка замены файла', $item['id']));
	}
}
PHP
4

Чтобы не превысить лимит 500 шт в месяц, скрипт должен запускаться:

31 день * 24 часа = 744 часов / 500 ≈ 1 раз в 1,5 часа, округлим до интервала в 2 часа.

Запуск скрипта в cron по URL выполняется следующий командой:

/usr/local/bin/wget -O - -q "http://example.com/optimize_img.php"

Временной интервал (минута, час, день, месяц, день недели):

0 */2 * * *

Мастерхост

Настройка Cron в Мастерхост

Timeweb

Настройка Cron в Timeweb

RU-CENTER

Настройка Cron в nic.ru

5

Чтобы отслеживать состояние процесса оптимизации, можно использовать следующий скрипт.

<!doctype html>
<html lang="ru">
<head>
	<meta charset="utf-8">
	<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
	<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
	<title>Отчет оптимизации изображений</title>
</head>
<body>
	<div class="container" style="max-width: 900px;">
		<h2>Отчет оптимизации изображений</h2>
		<dl class="row">
			<dt class="col-sm-3">Всего:</dt>
			<dd class="col-sm-9">
				<?php 
				$dbh = new PDO('mysql:dbname=db_name;host=localhost', 'логин', 'пароль');
				$sth = $dbh->prepare("SELECT COUNT(`id`) FROM `optimize_img`");
				$sth->execute();
				echo $sth->fetch(PDO::FETCH_COLUMN);
				?> шт						
			</dd>	
			<dt class="col-sm-3">Завершено:</dt>
			<dd class="col-sm-9">
				<?php 
				$sth = $dbh->prepare("SELECT COUNT(`id`) FROM `optimize_img` WHERE `done` = 1");
				$sth->execute();
				echo $sth->fetch(PDO::FETCH_COLUMN);
				?> шт						
			</dd>		
			<dt class="col-sm-3">Оптимизировано:</dt>
			<dd class="col-sm-9">
				<?php 
				$sth = $dbh->prepare("SELECT SUM(`diff`) FROM `optimize_img` WHERE `done` = 1");
				$sth->execute();
				echo round($sth->fetch(PDO::FETCH_COLUMN) / 1024 / 1024, 2);
				?> МБ			
			</dd>	
			<dt class="col-sm-3">Ошибки:</dt>
			<dd class="col-sm-9">
				<?php 
				$sth = $dbh->prepare("SELECT COUNT(`id`) FROM `optimize_img` WHERE `error` <> ''");
				$sth->execute();
				echo $sth->fetch(PDO::FETCH_COLUMN);
				?> шт						
			</dd>
		</dl>

		<?php 
		$sth = $dbh->prepare("SELECT * FROM `optimize_img` WHERE `error` <> ''");
		$sth->execute();
		$errors = $sth->fetchAll(PDO::FETCH_ASSOC);	
		if (!empty($errors)) {
			foreach ($errors as $error) {
				?>
				<div class="alert alert-danger" role="alert">
					<strong><?php echo $error['img']; ?></strong>
					<br><?php echo $error['error']; ?>
				</div>
				<?php
			}
		}
		?>				
	</div>
</body>
</html>
HTML
26.10.2018, обновлено 01.05.2020
28411

Комментарии 2

Константин Левченко Константин Левченко
20 мая 2020 в 13:56
У меня не заработал скрипт, подправил пару моментов:
1) `error` varchar(255) NOT NULL, => `error` varchar(255) NULL,
Иначе при выполнении запроса "INSERT INTO `optimize_img` SET `img` = ?" выкидывало ошибку: отсутсвует значение по умолчанию для `error`.
Соответственно запрос:
// Получим файл из очереди.
$sth = $dbh->prepare("SELECT * FROM `optimize_img` WHERE `done` = 0 AND `error` IS NULL ORDER BY `id` DESC");
2) Отказывалась работать copy:
if (copy($res['output']['url'], $file)) {
...
}
заменил на:
if (rename($file, $file . '.bak')) {
$ch = curl_init($res['output']['url']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$output = curl_exec($ch);
$fh = fopen($file, 'w');
if (fwrite($fh, $output) !== FALSE) {
fclose($fh);
$diff = $res['input']['size'] - $res['output']['size'];
$sth = $dbh->prepare("UPDATE `optimize_img` SET `done` = 1, `diff` = ? WHERE `id` = ?");
$sth->execute(array($diff, $item['id']));
exit;
} else {
rename($file . '.bak', $file);
}
}
Ivan Polynin Ivan Polynin
21 октября 2020 в 09:35
У сервиса https://tinypng.com/ немного изменился API, переписал данный скрипт под https://resmush.it/ плюс у них нету ограничений по количеству запросов. Если кому то пригодится:
// Подключение к БД.
$dbh = new PDO('mysql:dbname=;host=localhost', '', '');
 
// Категории в которых лежат картинки.
$dirs = array(
	__DIR__ . '/images',
	__DIR__ . '/templates',
);
 
// Расширения файлов.
$exts = array('png', 'jpg', 'jpeg');
 
// Ключ API tinypng.com
$api_key = '19kcqG0gpz4t3bPwDXVssfW31BWmb2K4';
 
function glob_recursive($pattern, $flags = 0)
{
	$files = glob($pattern, $flags);
	foreach (glob(dirname($pattern) . '/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
		$files = array_merge($files, glob_recursive($dir . '/' . basename($pattern), $flags));
	}
	return $files;
}
 
// Получим файл из очереди.
$sth = $dbh->prepare("SELECT * FROM `optimize_img` WHERE `done` = 0 AND `error` = '' ORDER BY `id` DESC");
$sth->execute();
$item = $sth->fetch(PDO::FETCH_ASSOC);
 
if (empty($item)) {
	// Если больше нет файлов, соберем и добавим новые.
	foreach ($dirs as $dir) {
		foreach (glob_recursive($dir . '/*.*') as $file) {
			$ext = strtolower(substr(strrchr($file, '.'), 1));
			if (in_array($ext, $exts)) {
				$file = str_replace(__DIR__, '', $file);
				$sth = $dbh->prepare("SELECT * FROM `optimize_img` WHERE `img` = ?");
				$sth->execute(array($file));		
				$isdb = $sth->fetch(PDO::FETCH_ASSOC);
				if (empty($isdb)) {
					$sth = $dbh->prepare("INSERT INTO `optimize_img` SET `img` = ?");
					$sth->execute(array($file));
				}
			}
		}	
	}
} elseif (!is_file(__DIR__ . $item['img'])) {
	// Если файл уже удалили.
	$sth = $dbh->prepare("DELETE FROM `optimize_img` WHERE `id` = ?");
	$sth->execute(array($item['id']));	
} else { 
 	// Отправка файла в tinypng.com
 	var_dump($item);
 	
 	
 	
 	$file = __DIR__ .$item["img"];
    $mime = mime_content_type($file);
    $info = pathinfo($file);
    $name = $info['basename'];
    $output = new CURLFile($file, $mime, $name);
    $data = array(
        "files" => $output,
    );

    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, 'http://api.resmush.it/?qlty=90');
    curl_setopt($ch, CURLOPT_POST,1);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
    $result = curl_exec($ch);
    if (curl_errno($ch)) {
       $result = curl_error($ch);
    }
    curl_close ($ch);
    
    $res = json_decode($result, JSON_OBJECT_AS_ARRAY);
    var_dump($res);
 	
 
	if (!empty($res['error'])) {
		// Сервис вернул ошибку.
		$sth = $dbh->prepare("UPDATE `optimize_img` SET `error` = ? WHERE `id` = ?");
		$sth->execute(array($res['error'], $item['id']));	
	} elseif (!empty($res['output'])) {
		// Файл сжат, пробуем заменить им исходный.
		$file = __DIR__ . $item['img'];
 
		if (rename($file, $file . '.bak')) {
			if (copy($res['dest'], $file)) {
				$diff = $res['src_size'] - $res['dest_size'];
 
				$sth = $dbh->prepare("UPDATE `optimize_img` SET `done` = 1, `diff` = ? WHERE `id` = ?");
				$sth->execute(array($diff, $item['id']));	
				exit;
			} else {
				rename($file . '.bak', $file);
			}
		}
 
		$sth = $dbh->prepare("UPDATE `optimize_img` SET `error` = ? WHERE `id` = ?");
		$sth->execute(array('Ошибка замены файла', $item['id']));
	}
}

, чтобы добавить комментарий.

Другие публикации

Использование API Яндекс Диска на PHP
Можно найти множество применений Яндекс Диска на своем сайте, например, хранение бекапов и отчетов, обновление прайсов,...
55902
+20
Работа с JSON в PHP
JSON (JavaScript Object Notation) – текстовый формат обмена данными, основанный на JavaScript, который представляет собой набор пар {ключ: значение}. Значение может быть массивом, числом, строкой и...
114074
+15
Примеры использования cURL в PHP
cURL PHP – это библиотека предназначенная для получения и передачи данных через такие протоколы, как HTTP, FTP, HTTPS....
219539
+21
Интеграция с платежной системой PayKeeper в PHP
Платежная платформа PayKeeper позволяет принимать оплату заказов по ссылке, используя данный метод можно с легкостью...
9525
+1
Примеры использования PDO MySQL
В статье приведены основные примеры работы с расширением PHP PDO. Такие как подключение к БД, получение, изменение и...
103951
+8
Как вывести метки на Яндекс.Картах из MySQL+PHP
В статье рассмотрены примеры как вывести метку на карту из БД и вывод других объектов, которые находятся рядом.
21661
+11