Category Archives: php

ffmpeg + php: Конвертируем в mp3 и нормализуем

Итак, наша задача преобразовать практически любой медиа-файл со звуком в mp3 и нормализовать звук.

Наша рабочая лошадка, которая здорово поможет нам в этом конечно же ffmpeg

ffmpeg

Основная команда кодирования медиа-файла в mp3 такая:

ffmpeg -i input.wma -vn -ac 1 -ab 40K output.mp3

Умный движок ffmpeg пытается угадать кодек по расширению файла для конечного файла, а расширение mp3 однозначно его трактует. Так же для входных файлов он сам распознаёт кодеки.

Параметры:
-vn – отключить кодирование видео, полезно, если мы будем пытаться брать звук из видео-файлов
-ac 1 – число каналов 1 (моно), для нашего проекта моно достаточно, можно поставить 2 для стерео
-ab 40K – аудио-битрейт в 40Кбай так же довольно низок, но для нашего проекта для голосовых записей 40К довольно. Можно указать хоть 128K, хоть 196К – смотрите сколько Вам необохидмо по качеству.

input.wma – входной медиа-файл. В принципе может быть абсолютно любым медиа, хоть аудио, хоть видео, ffmpeg распознаёт наверное все современные медиа-форматы, надо только его время от времени обновлять, так как новые кодеки и даже новые версии старых кодеков появляются регулярно.

output.mp3 – выходной MP3-файл. MP3 стандарт де-факто, не будем спорить с этим, хотя OGG тоже не плох, только по нему меньше шума и маркетинга было.

Вот в принципе и всё для кодирования.

Ниже я напишу как можно управлять ffmpeg из php.

Нормализация

А как же нормализация?

Один из простейших видов нормализации – приведении максимальной (пиковой) громкости к 0 Дб.
Как это сделать – показано ниже.

ffmpeg не имеет встроенного фильтра нормализации, но у него есть фильтр volume, который позволяет увеличивать громкость аудио-дорожки.

Мы можем написать например так:

ffmpeg -i input.wma -vn -ac 1 -ab 40K -af "volume=5dB" -f mp3 output.mp3

и эта команда при конвертировании увеличит громкость аудио на 5 децибел.
Но если мы будем тупо увеличивать громкость всех аудио на 5 децибел, то слишком тихие аудио не особо прибавят в громкости, а громкие аудио станут просто громче.

Если мы попробуем прибавлять громкость например на 15 децибел, то тогда громкие аудио могут начать просто хрипеть, что не есть хорошо для нас. Где же выход?

Изучить громкость аудио, и прибавить столько децибел, сколько потребуется (или вообще не прибавлять, если звук достаточно громкий).

Для изучения громкости аудио в ffmpeg есть аудио-фильтр volumedetect. Вот как им пользоваться:

ffmpeg -i input.wma -af "volumedetect" -f null /dev/null

Сразу отмечу, что в Windows окончание /dev/null надо поменять на NUL.

Эта команда разрешает ffmpeg изучить громкость аудио дорожки и выдать полезную информацию по вопросу, которая может выглядеть например так:

[Parsed_volumedetect_0 @ 0000000002b03b20] n_samples: 13738752
[Parsed_volumedetect_0 @ 0000000002b03b20] mean_volume: -31.8 dB
[Parsed_volumedetect_0 @ 0000000002b03b20] max_volume: -15.2 dB
[Parsed_volumedetect_0 @ 0000000002b03b20] histogram_15db: 696
[Parsed_volumedetect_0 @ 0000000002b03b20] histogram_16db: 78935

В этом захватывающем воображении выводе от ffmpeg нас будет интересовать только параметр max_volume, который в идеале должен стремиться к 0.

В нашем случае он равен -15.2 dB, то есть для данного файла к аудио-дорожке нам нужно прибавить 15 децибел, чтобы нормализовать её.

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

1. Изучаем вывод ffmpeg с фильтром volumedetect.
2. Парсим значение с max_volume и сохраняем его в переменной
3. Конвертим медиа-файл в mp3 с установленным фильтром volume=наша_переменнаяdB

На PHP данная задача решается в несколько строк кода и в следующем разделе мы увидим, как это действительно просто.

Кодим конвертер и нормализатор на PHP

В PHP мы будем использвовать вызов системной функции exec, которая будет вызывать ffmpeg с нужными параметрами.

Подразумевается, что ffmpeg установлен в нашей системе (на нашем сервере).

Итак, вот готовый код на PHP:

// пробуем конвертить файл в mp3 с помощью ffmpeg
// ffmpeg -i $rec -vn -ac 1 -ab 40K -f mp3 dest.mp3
 
$dest = uniqid('rec_').'.mp3'; // файл назначения
 
// Далее - наш медиа файл для конвертации и нормализации
// в принципе может быть любым медиа-файлом, который распознаёт ffmpeg,
// а он распознаёт много, если не всё
$src = 'somemediafile.amr';
 
$out = array(); // массив для получения результатов вывода ffmpeg
// вызываем ffmpeg в первый раз для получения инфо о громкости
// обратите внимание на окончание команды 2>&1,
// оно необходимо так как ffmpeg выпоняет вывод в stderr а не в stdout
// а exec перехватывает только вывод от stdout
exec("ffmpeg -i $src -af \"volumedetect\" -f null /dev/null 2>&1", $out);
 
// теперь пытаемся нормализовать звук тупо по max_volume стремящимся к 0dB
// доп.комментарии смотрите после этого листинга
$db = '';
for($i=0; $i < count($out); $i++)
	if(preg_match('/max_volume:\s+-?(\d+)/',$out[$i],$m)) {
		if(intval($m[1]) > 0)
                  $db = "-af \"volume={$m[1]}dB\"";
		break;
	}
 
$cmd = "ffmpeg -i $src -vn -ac 1 -ab 40K $db -f mp3 $dest";
exec($cmd);
 
// проверим наличие сконверженного файла
if(filesize($dest) == 0) {
	@unlink($dest);
	echo 'Ошибка конвертирования записи';
}
else
// теперь в $dest у нас должен остаться конечный нормализованный звук в mp3 формате
  echo 'Ok. Получили файл '.$dest;

В коде есть комментарии, но здесь можно прояснить ещё пару моментов.

Мы используем preg_match(‘/max_volume:\s+-?(\d+)/’,$out[$i],$m) для поиска и вычленения положительного значения max_volume из строки вида [Parsed_volumedetect_0 @ 0000000002b03b20] max_volume: -15.2 dB.

Тут мы отбрасываем любое кол-во пробелов и возможный знак `-` после подстроки max_volume:,
и затем считываем целое значенире в переменную массива $m[1].

Если оно больше нуля, то мы добавляем в вызов второй команды ffmpeg фильтр “-af \”volume={$m[1]}dB\””,
чтобы усилить звук, иначе мы опускаем этот параметр вовсе.

Второе. Чтобы понять, что мы получили конечный файл, мы проверяем его размер. Если попытка конвертации не удалась по какой-то причине (неверный или неизвестный ffmpeg медиа-формат файла), то размер этого файла будет равен 0.

Это ещё не всё

Мы добились многого, но не всего. В идеале нам нужно ещё вырезать лишние куски тишины (иногда в записях голосов бывает слишком много раздражающих пауз, которые к тому же увеличивают итоговый размер файла), и в идеале применить бы ещё фильтр выравниватель, так как некоторые записи могут быть разного уровня громкости в различных местах.

Хочется чтобы всё было ОК.

Я присматривался к утилите sox, но она для mp3 требует MAD, а я не люблю этого.

Я люблю когда всё работает out of the box, к чему и сам стремлюсь.

Удачи вам, друзья, до новых встреч!

Находим GEO-позицию (координаты) объекта (города)

Итак, нам надо вычислить широту/долготу (longitude/latitude) какого-либо объекта на карте
(города, страны и т.п.).

Как это можно сделать? Быстро и без проблем, как я люблю!?

Два способа – использовать базу локально, и находить гео-координаты обхекта по например SQL запросу из таблицы,
но тут пара недостатков – объем такой базы может быть довольно велик, и поиск может занимать много времени
(что особенно неприятно при многократных запросах, что при средней посещаемости сайта уже может подвешивать его).

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

Второй способ, к которому я и прибёг – находить гео-координаты с помощью какого-либо онлайнового сервиса.
Недостаток – зависимость от доступности данного сервиса.

Я выбрал Яндекс.Карты API, тем более для этого сейчас даже не нужен получать ключ, как того требуют Google Maps например. Кроме того, Яндекс органично работают с русскими названиями, за что им отдельное спасибо. И не надо мучаться с переводом – поиском эквивалентов.

А запрос к геокодеру выглядит просто:

http://geocode-maps.yandex.ru/1.x/?geocode=ИСКОМЫЙ_ОБЪЕКТ

В ответ по умолчанию API выдаст XML страничку с инфой по объекту(-ам), из которой можно легко вычленить его (их) координаты.

Но для меня прощё использовать JSON, поэтому я буду использовать такой запрос:

http://geocode-maps.yandex.ru/1.x/?format=json&results=1&geocode=ИСКОМЫЙ_ОБЪЕКТ

Да, тут пара лишних параметров появилась – format=json думаю и так ясно, а вот results=1 сделано для того, чтобы получить лишь один результат среди нескольких возможных, так как по умолчанию Яндекс.Карты API выдаст инфо по всем одноименным объектам.

Дополнительные параметры которые могут использоваться в запросе смотрите здесь – http://api.yandex.ru/maps/doc/geocoder/desc/concepts/input_params.xml

Итак, после составления корректного запроса нам нужно лишь принять и обработать ответ.

А сделать это с помощью того же PHP можно одной строкой.

Но сначала посмотрим ответ, например, после запроса

http://geocode-maps.yandex.ru/1.x/?format=json&results=1&geocode=Волгоград

{"response":{"Attribution":"","GeoObjectCollection":
{"metaDataProperty":{"GeocoderResponseMetaData":
{"request":"Волгоград","found":"2","results":"1"}},
"featureMember":[{"GeoObject":{"metaDataProperty":
{"GeocoderMetaData":{"kind":"locality",
"text":"Россия, Волгоград","precision":"other",
"AddressDetails":{"Country":{"AddressLine":"Волгоград","CountryNameCode":"RU",
"CountryName":"Россия","AdministrativeArea":
{"AdministrativeAreaName":"Волгоградская область",
"SubAdministrativeArea":{"SubAdministrativeAreaName":
"городской округ Волгоград","Locality":{"LocalityName":"Волгоград"}}}}}}},
"description":"Россия","name":"Волгоград",
"boundedBy":{"Envelope":
{"lowerCorner":"44.257092 48.413306",
"upperCorner":"44.689524 48.886671"}},
"Point":{"pos":"44.516939 48.707103"}}}]}}}

Много разной информации, но в нашем случае нас интересуют только поле pos объекта Point.
Для этого просто делаем декод из JSON в PHP массив, и отбираем нужные поля:

// в $city у нас искомый объект
$url = 'http://geocode-maps.yandex.ru/1.x/?format=json&results=1&geocode='.$city;
$ar = json_decode(file_get_contents($url),true);
$coords = $ar['response']['GeoObjectCollection']['featureMember'][0]['GeoObject']['Point']['pos'];
list($lon,$lat) = explode(' ',$coords);

После выполнения этого PHP-участка кода в переменных $lon и $lat у нас образуется долгота и широта соответственно.

А это как раз есть то, что нам нужно было по определению.

Удачи!

Быстрая валидация форм на javascript

Чтобы быстро валидировать форму на JavaScript достаточного такого кода:
Note: используем jquery.

Да, в качестве контекста при вызове этой функции следует использовать саму форму, так как поиск элементов выполняются только в текущем контексте и не затронет посторонние элементы.

var tryFormSubmitfunction = function ()
{
// проверяем валидность ввода данных
	var res = true,	// результат вовзрата по умолчанию
		f = this;	// контекст поиска полей в форме
	$('.req', f).removeClass('need-req');
	$('.req', f).each(function(index) {
		if(res && (
			$.trim($(this).val()) == '' || ($(this).hasClass('mail') 
&& !$(this).val().match(
/^[\+A-Za-z0-9][\+A-Za-z0-9\._-]*[\+A-Za-z0-9_]*@([A-Za-z0-9]+([A-Za-z0-9-]*[A-Za-z0-9]+)*\.)+[A-Za-z]+$/
))
		)) {
			$(this).addClass('need-req').get(0).select();
 
			setTimeout(
				function() { 
                        $('.req', f).removeClass('need-req'); },3000);
			res = false;
		}
	});
 
	return res;
}

Описание

Смысл кода такой: проверяем все поля имеющие класс req, и если оно пустое, прерываем сабмит (отправку на сервер) формы, наделяем такой элемент классом need-req и фокусируемся на нём, чтобы пользователь ввёл всё-таки какие-нибудь данные.

Кроме того, класс need-req удаляется с этого элемента через 3 секунды (см. строку с функцией setTimeout), чтобы не отвлекать внимание.

И ещё один плюсик – если элемент ввода имеет класс mail, то мы проводим доп.проверку на наличия в нём корректного адреса электронной почты (email) – см. функцию match().

То есть в нашем случае мы используем классы req и mail не для украшательств (хотя их можно и украсить), а для подсказки JavaScript-коду. Класс need-req мы используем как раз для украшательств, то есть подсказки пользователю, что этот элемент требует его внимания (см. ниже).

Использование

Используем для каждой формы примерно так:

$('form').submit(tryFormSubmit);

В самой форме для всех полей, требующих заполнения хотя бы одним символом указываем класс req, а для поля, в котром нужно иметь корректный email адрес добавляем ещё класс mail, примерно так:

          <div class="item">
            <label for="name">Ваше имя</label>
            <input class="text req" name="name" value="" />
          </div>
          <div class="item">
            <label for="surname">Ваша фамилия</label>
            <input class="text req" name="surname" value="" />
          </div>
          <div class="item">
            <label for="email">Укажите Ваш E-mail</label>
            <input class="text req mail" name="email" value="" />
          </div>
          <div class="item">
            <label for="comment">Ваши вопросы</label>
            <textarea name="comment"></textarea>
          </div>

Для поля Ваши вопросы данные не обзятельны, для всех остальных нужно ввести хотя бы один символ, а для поля email требуется валидный адрес email (см. regexp в коде функции-валидатора).

Для выделения ошибочного поля (за раз выделяется только одно поле – мы же пишем быструю простую функцию) используем класс need-req в CSS файле и опишем его к примеру так:

/* класс при выделении неверного элемента формы */
.need-req
{
	outline: double red 5px;
}

Всё, полная валидация форм готова практически за 5 минут.
Благодаря jQuery, конечно.

Всё работает и работает так как надо, так как мы хотим.

Удачи и счастья!

PHP: Групповая обработка файлов

Давече встала передо мной проблема – перевести весь сайт на UTF-8 с Windows-1251. Для этого нам надо (помимо возможно нового парсинга значений из БД) просто перекодировать все страницы сайта.
Вручную я за десять секунд могу перекодировать текстовый файл в Notepad++, но если таких файлов десятки или сотни, что нормально для стандартного сайта, это может встать геморроем.

Конечно, тут нужна групповая обработка файлов. Что делать? Юзать утилиту iconv – но она опять таки предназанчена для конвертации одного файла, а для групповой обработки надо изощряться через всякий find’ы, пайпы и т.п. Я не настолько пока силён в Unix-командах, чтобы быстро и без труда сделать всё как надо.

Для Windows нашлось (опять таки при поверхностном гуглении, так как времени для долгого изыскания нету) пара утилит, но они все какие-то корявые оказались и тупые.

Что делать? Писать самому! Тем более, что у нас под рукой есть такой простой и мощный язык как PHP Hypertext Preprocessor.

Первым делом нам надо написать универсальную функцию, которая обходит дерево каталогов и выбирает имена файлов для обработки.

Сразу определим параметры этой функции –

function processFiles($dir, $callback, $mask = '/.*/', $recursive = true)
{ ... }

Параметры:
$dir – имя каталога для начала обхода.

$callback – функция, которая будет вызвана с именем файла в качестве единственного параметра. Решим, что при вызове этой функции мы будем устанавливать рабочий каталог тот, где находится данный файл, чтобы функция могла лекго читать и писать данные, сообразуясь с этим.

$mask – PCRE – маска файлов. Функция $callback будет вызываться только для тех файлов, для которых preg_match($mask, полное
имя файла)
функция вернёт true.
Благодаря этому параметру мы можем отобрать только те файлы, которые нам действительно нужны, а не перекладывать эту функцию на вызываемую функцию (блин, что за каламбур получился? ладно, пусть будет).
По умолчанию (это не обязательный параметр) мы выбираем все файлы, кроме скрытых (то есть те, имя которых начинается с точки – по Unix соглашению).

$recursive – также необязательный параметр (по умолчанию true) – вызывать ли рекурсивно обработку файлов в подпапках текущей папки.
Обычно нам так и нужно, поэтому дефолтное значение – да, вызывать.

Вот полной код этой функции обхода дерева каталогов на PHP:

// рекурсивный (по умолчанию) обход папок
// recursive walking directories
function processFiles($dir, $callback, $mask = '/.*/', $recursive = true)
{
	$dh = opendir($dir);
	if(!$dh)
		return;
 
	$curDir = getcwd();
	chdir($dir);
 
	while (($entry = readdir($dh)) !== false) {
		if(substr($entry,0,1) == '.')
		// hidden entry
			continue;
		$fullName = $dir.'/'.$entry;
		if(is_dir($entry)) {
			if ($recursive)
				processFiles($entry, $callback, $mask);
			continue;
		}
		if(preg_match($mask,$fullName))
			call_user_func($callback,$entry);
	}
 
	chdir($curDir);
}

В этом куске кода нет ничего сложного, все PHP-функции довольно стары и стандартны. Можно отметить только, что пропуская скрытые файлы – hidden entry (по Unix соглашению – то есть файлы и папки, начинающиеся с точки), мы параллельно пропускаем `.` и `..` вхождения, которые также включаются функцией readdir в листинг файлов.

Теперь осталось дело за малым – написать callback-функцию для обработки файлов.

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

Самое простое что приходит на ум – это вывод полного имени файла, чтобы убедиться, что отбираются именно нужные нам файлы, которые можно безболезненно обрабатывать.

Вот как может выглядеть такая функция:

// просто выведем полное имя файла (с текущим путём)
function listFiles($name)
{
    echo 'File: '.getcwd().'/'.$name.' ('.file_exists($name).')<br>';
}

Теперь попробуем применить наши новые две функции с маской для файлов – выберем только текстовые файлы с расширениями php,html,js,css или всех тех, что находятся в папке doc (и её подпапках):

processFiles('.', 'listFiles', '#\.php$|\.html$|\.js$|\.css$|doc/#');

Изучив в браузере (я запускаю свои скрипты через браузер для текущего сервера, но вы можете конечно использовать CLI, для этого только в выводе вместо ‘<br>’ нужно использовать “\n”) вывод и убедившись, что отбираются именно нужные нам файлы, можно приступить к конечной цели нашей задачи – переконвертация файлов из кодировки Windows-1251 в кодировку UTF-8.

На PHP такая функция может занимать от одной до трёх строчек. Чтобы было понятнее (я люблю ясность во всём, но не в ущерб краткости иногда), сделаем сначала такую функцию из трёх строчек:

function reEncode($name)
{
// конвертируем файл из Windows-1251 в UTF-8
	$in = file_get_contents($name);
	$out = iconv('windows-1251','utf-8',$in);
	file_put_contents($name, $out);
}

Благодаря мощной PHP-функции iconv конвертация не вызывает никаких проблем, если мы знаем названия кодировок, с которыми работаем, и iconv тоже их знает.

А вот, чтобы не быть голословным, тоже самое, но одной строчкой:

file_put_contents($name, iconv('windows-1251','utf-8',file_get_contents($name)));

Я лично предпочитаю использовать первый вариант, так как он не намного длиннее, но на порядок понятнее. Впрочем, это, наверное, дело вкуса.

Итак, наша задача успешно решена – вызываем обработку наших файлов примерно так:

processFiles('.', 'reEncode', '#\.php$|\.html$|\.js$|\.css$|doc/#');

Запускаем её для текущего каталога, и через какое-то время (зависит от кол-во файлов и папок) все наши текстовые файлы стали UTF-8!

Красота! Но на этом мы не останавливаемся. Нашу замечательную функцию processFiles мы можем использовать в самых разнообразных целях, какие только нам нужны.

Например, конвертим все наши документы в Unix-EOL (\n вместо \r\n на концах строк):

function toUnixEOL($name)
{
// конвертим концы строк из стандарта Windows (\r\n) в стандарт Unix (\n)
	$in = file_get_contents($name);
	$out = str_replace("\r\n","\n",$in);
	file_put_contents($name, $out);
}

Так же мы можем запустить групповую обработку для изображений – создадим для всех наших изображений миниатюры размером к примеру 150*150 точек в формате PNG черно-белого варианта (см. ниже).

Благодря PHP эта задача кажется легче чем думается, и нам не нужны никакие сторонние утилиты, платные или бесплатные – мы всё можем сделать своими руками, точнее своим мозгом, а что может быть приятнее?

Итак, вызов инструкций:

// полный путь для папки с миниатюрами (thumbs)
$targetDir = getcwd().'/inc/thumbs';
// если папка не существует, создадим её
if(!@opendir($targetDir))
	mkdir($targetDir);
processFiles('./inc/img', 'processImage', '/\.jpg$|\.jpeg$|\.png$|\.gif$/');

сделает из изображений в папке ./inc/img миниатюры 150*150 пикселей и поместит их в папку ./inc/thumbs

А вот как может выглядеть функция processImage:

/* ***********************************************
	Обработка изображений
	Вход -
		$name - имя изображения в текущей папке
********************************************** */
function processImage($name) {
// уменьшаем картинку до 150*150 пикселей,
// преобразуем в чёрно-белое 
// и сохраняем как PNG в отдельной папке
	global $targetDir;
	// имя для нового изображения
	$pathInfo = pathinfo($name);
	$fileName = $targetDir.'/'.$pathInfo['filename'].'.png';
 
        // распознаём только JPEG (JPG), PNG и GIF
	$him = @imagecreatefromjpeg($name);
	if(!$him) {
		$him = @imagecreatefromgif($name);
		if(!$him) {
			$him = @imagecreatefrompng($name);// может это PNG?
			if(!$him)
				return false;
		}
	}
	// удалим файл, если есть случайно уже
	if(file_exists($fileName))
            unlink($fileName);
 
	resizeImage($him,150,$fileName);	// сохраняем оригинально изображение
	// чистим память (поможем PHP)
	imagedestroy($him);
	unset($him);
 
	return true;
}
 
/*
	преобразуем изображение -
	изменим размеры до 150*150 пикселей,
	превратим в черно-белое и сохраним в формате PNG
 
	Вход:
		$originalImage - GD-дескриптор оригинального изображения
		$newWidth - новая ширина (и высота в нашем случае)
		$fileName - путь для сохранения обработанного изображения
*/
function resizeImage($originalImage,$newWidth,$fileName){
    // получим оригинальные размеры
    $width  = imagesx($originalImage);
    $height = imagesy($originalImage);
 
    // для сохранения ASPECT RATIO можно воспользоваться
    // след. закомментированной формулой
    // $newHeight = round(($height * $newWidth) / $width);
 
    // но мы делаем высоту изображения равной его ширине
    $newHeight = $newWidth;	// make it square
 
    // resize the original image
    $newImage = imagecreatetruecolor($newWidth, $newHeight);
    imagecopyresampled($newImage, $originalImage, 0, 0, 0, 0, 
                          $newWidth, $newHeight, $width, $height);
    // преобразуем в черно-белое (зачем? - просто для примера)
    imagefilter($newImage, IMG_FILTER_GRAYSCALE);
    // сохраняем новое изображение
    imagepng($newImage, $fileName);
    imagedestroy($newImage);
    unset($newImage);
}

Что ещё можно делать с файлами в групповой обработке? Переименовывать! Это уже задача для 1-го класса Школы PHP в Хогвардсе!

Удачи и счастливого кодинга.

Создаём свой собственный bit.ly – укорачиватель ссылок

Как всегда, предыстория

В одном из наших проектов ссылки так разрослись, что я решил всё же подрезать их, а то некрасиво стало выглядеть. Хотя в принципе, раньше мне было на это наплевать.

Да, я знаю bit.ly – bitly.com – был наверное одним из первых укорачивателей ссылок, сейчас этих сервисов расплодилось, хоть ешь.

Итак, захотел я ссылки укоротить, зашёл на bitly узнать как бы мне это быстрее и эффектнее сделать, и увидел, что надо регаться у них, получать всякие разные API tokens, и т.д. и .т.п. Короче, геморрно, неохота разбираться даже.

И тут мне на ум пришло – я же могу создать свой bitly, думаю, дело это не хитрое. Весь алгоритм – составил пару значений ключ-ссылка, и переходишь по ссылке, получив ключ.

И я сел, почесал голову, и примерно за пару часов накатал готовенький скрипт типа bitly, правда, отлаживать ещё пару дней пришлось, но если вы кодер, вы в курсе.

Начинаем

Первым делом определимся со структурой таблицы.

Сразу оговорюсь, что мы тут будем использовать троицу AMP – Apache, MySQL, PHP, но вместо MySQL можно использовать тот же SQlite или любую другую СУБД. Да и сервер и язык сервера может также быть любым по вашему вкусу – только надо будет соответственно тогда подредактировать наш скрипт.

В идеале нам нужно всего два текстовых поля – uri и key. Но нам желательно вести хотя бы простенькую статистику по ссылкам, ведь интересно, что с ними происходит, правда?

Итак, сядем и продумаем следующую структуру таблицы:

id INT(11) PRIMARY KEY AUTOINCREMENT – unique id
key VARCHAR(16) UNIQUE KEY – ключ
uri TEXT – полная ссылка
dtm DATETIME – дата и время создания
dthit DATETIME – дата и время последнего перехода по ссылке
hits INT(11) – число переходов по ссылке

Поле id тут в принципе не обязательно, наверное, уникального ключа key длиной до 16 символов достаточно, но я привык в каждой таблице определять ид, без него как-то скучно.

Протокол нашего скрипта

Назовём скрипт go.php – кратко и понятно.

Основные запросы к нему будут такие:

go.php?to=[KEY] – переход по ссылке, ассоциированной с данным ключом KEY.

go.php?new=[URI] – создание и вывод в браузер нового ключа для ссылки URI

Определимся, что ключ может быть длиной от 3-х до 16-ти символов и содержать только латинские буквы, большие и малые, и цифры.

Поиск ключа и переход по ссылке

Итак, при наличии параметра to выполняем поиск uri по значению ключа из этого параметра и переходим по нему. Всё.

Тут есть только один маленький нюанс: дефолтный супер-массив PHP $_GET нам не подойдёт, так как мы можем принять строку стороннего uri, в котором могут содержаться такие же имена параметров.

Поэтому разбираем PHP параметр от Апача – $_SERVER[‘QUERY_STRING’].

Для выявления корректного запроса перехода по ключу используем простое PCRE выражение: /^to=([a-zA-Z\d]{‘.BITLY_MIN.’,’.BITLY_MAX.’})$/

Константы BITLY_MIN и BITLY_MAX определяют мин и макс длину ключа (3 и 16 соотв. – смотрите ниже в листинге полного скрипта).
Часть кода, делающая соотв. проверку, может выглядеть так:

// строка запроса, $_GET нам не подходит
$q = $_SERVER['QUERY_STRING'];
$m = '/^to=([a-zA-Z\d]{'.BITLY_MIN.','.BITLY_MAX.'})$/';
 
if(preg_match($m,$q,$ar)) {
// передан ключ для перехода
}

Всё что нам теперь остаётся – найти uri по полученному ключу и перейти по нему, предварительно обновив статистику (см. полный код в листинге внизу поста).

Создаём новый ключ для ссылки

При создании нового ключа добавим пару интересных, имхо, возможностей – параметр len – определяет конкретную длину ключа (по умолчанию примем длину ключа за 8 символов – 2.2×1014 вариантов?) или параметр key – желаемый ключ для ссылки (длиной от 3 до 16 символов и также состоящий только из латинских букв и цифр). Я не опечатался с `или` – должен быть задан лишь один дополнительный параметр, потому что тогда второй параметр теряет смысл.

Кроме того, для универсальности, дополнительный параметр должен предшествовать параметру new, чтобы не влиять на содержимое ссылки, ведь в переданной нам ссылке могут быть свои параметры, среди которых случайно может оказаться параметр len или key.

А что, если там окажется параметр new, спросите вы и застанете меня врасплох (шутка). Для этого будем исследовать всю ту же PHP-переменную от Апача $_SERVER[‘QUERY’] и определим три PCRE выражения, жестко регламентирующие порядок и содержимое наших параметров:

$m1 = '/^len=(\d\d?)&new=(.*)$/';
$m2 = '/^key=(\w{'.BITLY_MIN.','.BITLY_MAX.'})&new=(.*)$/';
$m3 = '/^new=(.*)$/';

Таким образом, согласно этим 3-м простеньким PCRE выражениям мы в 1-ом выделяем len и new, во втором key и new, и в третьем – только new.

Тут главное обратите внимание на символ ^ в начале строк, которые означают начало строки. Мы выбираем наши параметры только из начала запроса, а всё что в середине его, например, в переданной ссылке, нас интересует только косвенно.

Теперь определяем переменные согласно preg_match‘ам, и создаём новый ключ, как показано в полном листинге внизу поста.

URI мы проверяем на нестрогое соответствие, а если такой URI уже есть в нашей базе, выдадим связанный с ним ключ, если только мы не создаём свой пользовательский ключ через параметр key.

Результат мы просто выводим в браузер в виде простой текстовой строки через мой любимый php метод die. Таким образом, для получения ключа надо просто прочитать вывод от браузера:

$url = 'http://www.some-site.com/очень-очень длинная неприятная ссылка, которую надо бы укоротить';
$key = file_get_contents('http://mysite.ru/go.php?new='.$url);
$shorten_url = 'http://mysite.ru/go.php?to='.$key;

Теперь вместо $url мы можем использовать $shorten_url и быть в шоколаде!
Здесь mysite.ru – сайт, на котором мы разместили свой супер-скрипт go.php, чей полный листинг вы найдёте в конце этого поста.

Вот ещё пример получения нового ключа и использовния его:

// полная ссылка на пост-оплату через сервер РБК Мани
$pars = "em={$email}&sum={$sum}&title=".urlencode('Оплата за комплект '.$set_title);
$rbk_href = 'http://mysite.ru/rbk.php?'.$pars;
// используем наш собственный bitly
$key = file_get_contents('http://mysite.ru/go.php?new='.$rbk_href);
$rbk_href = 'http://mysite.ru/go.php?to='.$key;

Здесь мы, не мудрствуя лукаво, забрали ключ через file_get_contents.

Немного статистики

Да, раз уж мы фиксируем дату/время последнего обращения к ссылке и число таких обращений (переходов), то неплохо было бы иметь возможность быстро получать инфо по отдельным ссылкам, чтобы каждый раз не залазить в PHPMyAdmin. Для это придумаем ещё один ключ – stat, который будем использовать совместно с ключом key.

И опять же по нашему соглашению в начале запроса должен идти параметр key, и сразу после него – параметр stat. Для выявления этого составим такое PCRE выражение:

$m = '/^key=(\w{'.BITLY_MIN.','.BITLY_MAX.'})&stat/';

Если мы нашли такое соответствие в строке запроса, выводим в одной простой строке дату/время в формате MySQL и через символ пайпа (|) счётчик обращений к ссылке – эту строку легко можно будет пропарсить и учесть.

Кусок такого код может выглядеть так:

$m = '/^key=(\w{'.BITLY_MIN.','.BITLY_MAX.'})&stat/';
 
if(preg_match($m,$q,$ar)) {
  $key = $ar[1];
  // ищем связанные данные
  $r = $db->prepare("SELECT dthit,hits FROM $bitly WHERE `key`=?");
  if($r->execute(array($key))) {
    list($dthit,$hits) = $r->fetch(PDO::FETCH_NUM);
    die($dthit.'|'.$hits);
  }
  // bad key, very bad
  die($biterr['badkey']);
}

Простенькая статистика готова, а если нам будет нужна полная статистика, можно использовать тот же PHPMyAdmin или написать свою админку под это дело.

Готовый продукт

Итак, наш скрипт практически готов – смотрите полный листинг рабочего скрипта ниже.

Правда, остаётся пара нюансов (issues по-английски) – о первом я уже упоминал, это отрезание хэша браузером от строки запроса, а второй – защита от спамерских атак, когда на нас может хлынуть поток “левых” запросов и зафлудить нашу таблицу ссылок.

Последний нюанс решается технически по IP, то есть можно добавить поле ip для получателя нового ключа, и если этот ip запрашивает, например, более 100 ссылок в час, блокируем его на какое-то время (сутки-двое), чтобы отдохнул, проспался. Совсем блокировать такой ip наверное не надо, так как спамеры в большинстве случаев используют протрояненные боксы обычных юзеров или взломанные сервера, которые после лечения становятся “белыми”.

А нюанс с символом хэша (#) я пока не знаю как обойти, если узнаю – добавлю сюда инфо, ОК?

А пока всё, я пью какао, а вы – изучайте полный скрипт нашего bitly!

Тем более он совсем невелик – не более 200 строк PHP кода вместе с комментариями и отступами.

<?php
/* 
     ** Файл GO.PHP - наш собственный bitly **
 
  Параметры GET:
 
	go - ключ для перехода по связанному с ним uri
 
	new - создать новый ключ для указанного в этом параметре URI
 
	При параметре new можно ПЕРЕД НИМ указать один из следующих параметров:
 
		key - ключ для связи (длиной BITLY_MIN..BITLY_MAX)
		len - желаемая длина BITLY_MIN..BITLY_MAX
 
	Примечание - можно указывать параметр 
	только либо key, либо len,
	ибо при применении одного второй теряет смысл.
 
	key должен состоять из ASCII символов A..Z, a..z и цифр
 
	Так же эти параметры (если есть) 
	должны ПРЕДШЕСТВОВАТЬ параметру new
 
        Используем простую таблицу БД, описание которой
        смотрите выше в этом посте
 
*/
 
//////////////////////////////////////////////////////
/////////////////	CONNECT DBASE	/////////////////
////////////////////////////////////////////////////
$db = new PDO(
	'mysql:host=localhost;dbname=OUR_DATABASE;charset=utf-8',
	'USER',
	'PASSWORD',
	array(PDO::MYSQL_ATTR_INIT_COMMAND=>'SET NAMES UTF8')
);
 
//////////////////////////////////////////////////////
///////////////	ОПРЕДЕЛИМ КОНСТАНТЫ	/////////////////
////////////////////////////////////////////////////
 
define('BITLY_MIN',3);		// min length of a key
define('BITLY_MAX',16);		// max length of a key
 
define('BITLY_DEFAULT_LENGTH',8);	// max wanted length of a key
 
// строки сообщений о непорядке
$biterr = array(
	'badreq' => '* BAD REQ *',	// плохой запрос
	'badkey' => '* BAD KEY *',	// плохой ключ
	'baduri' => '* BAD URI *'	// плохой URI
);
 
// рабочая таблица
$bitly = 'bitly';
 
// строка запроса, $_GET нам не подходит
$q = $_SERVER['QUERY_STRING'];
$m = '/^to=([a-zA-Z\d]{'.BITLY_MIN.','.BITLY_MAX.'})$/';
 
if(preg_match($m,$q,$ar)) {
	// чистим ключ
	$key = $ar[1];
 
	// запрашиваем связанный с ним uri
	$r = $db->prepare("SELECT uri FROM $bitly WHERE `key`=?");
	$r->execute(array($key));
	$ar = $r->fetch(PDO::FETCH_ASSOC);
 
	if(!$ar) {
	// не используем первый die, так как он может 
	// привести в замешательство пользователя?
	//	die($biterr['badkey']);
		header('Location: /');
		die;
	}
 
	$r = $db->prepare("UPDATE $bitly SET dthit=NOW(), hits=hits+1 WHERE `key`=?");
	$r->execute(array($key));
 
	header('Location: '.$ar['uri']);
	die;
}
// может, запрос статистики по ключу?
$m = '/^key=(\w{'.BITLY_MIN.','.BITLY_MAX.'})&stat/';
if(preg_match($m,$q,$ar)) {
  $key = $ar[1];
  // ищем связанные данные
  $r = $db->prepare("SELECT dthit,hits FROM $bitly WHERE `key`=?");
  if($r->execute(array($key))) {
    list($dthit,$hits) = $r->fetch(PDO::FETCH_NUM);
    die($dthit.'|'.$hits);
  }
  // bad key, very bad
  die($biterr['badkey']);
}
 
// если не переход и не статистика, может создать новый ключ для ссылки?
$key = false;
$len = BITLY_DEFAULT_LENGTH;
 
$m1 = '/^len=(\d\d?)&new=(.*)$/';
$m2 = '/^key=([a-zA-Z\d]{'.BITLY_MIN.','.BITLY_MAX.'})&new=(.*)$/';
$m3 = '/^new=(.*)$/';
 
if(preg_match($m1,$q,$ar)) {
	$len = intval($ar[1]);
	$uri = $ar[2];
}
else if(preg_match($m2,$q,$ar)) {
	$key = $ar[1];
	$uri = $ar[2];
}
else if(preg_match($m3,$q,$ar))
	$uri = $ar[1];
else
// bad request
	die($biterr['badreq']);
 
// проверим URI на соотвтествие (нестрогое,
// нам тут строгое в принципе и не нужно)
if(!isUrl($uri))
	die($biterr['baduri']);
 
// пробуем определить есть ли уже такой uri
$r = $db->prepare("SELECT `key` FROM $bitly WHERE uri=?");
$r->execute(array($uri));
$ar = $r->fetch(PDO::FETCH_ASSOC);
// если есть, выведем его
// при условии, что мы не просим свой ключ
if(!$key && $ar)
	die($ar['key']);
 
if($len < BITLY_MIN || $len > BITLY_MAX)
	$len = BITLY_DEFAULT_LENGTH;
 
$r = $db->prepare("INSERT INTO $bitly (`key`, `uri`, `dtm`) 
							VALUES (:key, :uri, NOW())");
$r->bindParam(':key', $key);
$r->bindParam(':uri', $uri);
 
if(!$key)
  do {
	$key = randomKey($len);
  } while(!$r->execute());
else {
	if(!$r->execute())
		die($biterr['badkey']);
}
 
die($key);	// all is OK?
 
/////////////////////////////////////////////////////////////////////////////
function randomKey($len = 7)
{
	$chars = 'ABCDEFGHIJKLM012345NOPQRSTUVWXYZabcdefghijklm6789nopqrstuvwxyz';
	$key = '';
	$c = strlen($chars);
 
	for ($i = 0; $i < $len; ++$i)
		$key .= substr($chars, (mt_rand() % $c), 1);
	return $key;
}
////////////////////////////////////////////////////////////////////
function isUrl($test)
{
// этот скрипт я откопал где-то на stackoverflow
    if (strpos($test, ' ') > -1)
        return false;
	if(strlen($test) > 10000)
		return false;
 
    if (strpos($test, '.') > 1) {
        $check = @parse_url($test);
        return is_array($check)
            && isset($check['scheme'])
            && isset($check['host']) 
			&& count(explode('.', $check['host'])) > 1;
	}
    return false;
}