Продвигаем старые записи из архива в Twitter

Twitter

Хотите сделать приятелю или самому себе подарок в какой-нибудь знаменательный день? Любому человеку, который ведёт какой-нибудь блог и совмещает с этим активную жизнь в твиттере. Ведь не так уж и сложно добавить своему сайту несколько дополнительных переходов из этого микроблога.

Программиста хлебом не корми – дай над чем нибудь заморочиться. Заморочимся сегодня на твитах...

Один из советов книги «Платформа» про интернет-маркетинг гласит: «Продвигайте свои старые записи». Действительно, старые статьи блога порой не теряют своей актуальности и их не следует забрасывать и забывать. На них можно не только ссылаться с новых статей, но и активно делиться в социальных сетях наравне с новыми. Вреда от этого точно не будет. Их можно добавлять время от времени в твиттер, помечая, например, словами «Архив блога». Это честно.

Копировать URL и заголовок и дописывать хэштеги почти каждый день нам лениво (мы же программисты), особенно если число статей уже перевалило за сотню. Сегодня мы попробуем облегчить себе участь и автоматизировать процесс сборки текста твитов.

Итак, поехали!

Обход сайта

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

Но имеется куда более надёжный способ – это считывание XML-карты. Она, по крайней мере, имеется у большинства сайтов и имеет одинаковый формат.

Напишем компонент для работы с картой. Дополним его способностью рекурсивно «понимать» многостраничный формат:

namespace Parser;
 
class Sitemap
{
    private $html = '';
 
    public function load($url) {
        $http = curl_init($url);
        curl_setopt($http, CURLOPT_RETURNTRANSFER, 1);
        $result = curl_exec($http);
        $http_status = curl_getinfo($http, CURLINFO_HTTP_CODE);
        curl_close($http);
        $this->html = $result;
        return $http_status == 200;
    }
 
    public function getUrls() {
        $urls = [];
        if ($this->isSitemapIndex()) {
            foreach ($this->getSitemaps() as $sitemap_url) {
                $sitemap = new Sitemap();
                if ($sitemap->load($sitemap_url)) {
                    $urls = array_merge($urls, $sitemap->getUrls());
                }
            }
        } else {
            $matches = [];
            if (preg_match_all('@<loc>(?<url>[^<]*?)</loc>@is', $this->html, $matches)) {
                $urls = $matches['url'];
            }
        }
        return array_unique($urls);
    }
 
    private function isSitemapIndex() {
        return preg_match('@<sitemapindex[^>]*>@is', $this->html);
    }
 
    private function getSitemaps() {
        $sitemaps = [];
        $matches = [];
        if (preg_match_all('@<sitemap>[^<]*<loc>(?<url>[^<]*?)</loc>@is', $this->html, $matches)) {
            $sitemaps = $matches['url'];
        }
        return $sitemaps;
    }
}

Вместо использования CURL мы могли бы просто считать файл по удалённому адресу:

$this->html = file_get_contents($url)

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

Напишем теперь консольный скрипт elisdn.php:

<?php
 
namespace Parser;
 
require(dirname(__FILE__) . '/inc/Parser/Sitemap.php');
 
$sitemap = new Sitemap();
 
$map = 'http://www.elisdn.ru/sitemap.xml';
$match = '@http://www\.elisdn\.ru/blog/@i';
$exclude = '@http://www\.elisdn\.ru/blog/(49|53)/@i';
 
if ($sitemap->load($map)) {
    foreach ($map->getUrls() as $url) {
        if (preg_match($match, $url) && !preg_match($exclude, $url)) {
            echo $url . PHP_EOL;
        }
    }
} else {
    echo 'Sitemap is not loaded' . PHP_EOL;
}

Регулярные выражения выберут только статьи в блоге и пропустят все остальные разделы сайта.

Теперь запустим скрипт в консоли:

php elisdn.php

и на экран выведется список адресов.

Считывание страниц

У нас есть список адресов статей. Если бы мы обходили ленту постов, то бы получили все заголовки сразу вместе со ссылками. Но мы пойдём путём считывания каждой страницы, так как это даёт нам дополнительные возможности.

При написании статей с оптимизацией под поисковые системы заголовок окна <title> пишется отдельно, содержит более конкретное название, ключевые слова и не совпадает с заголовком статьи <h1>. Подход с загрузкой каждой страницы даёт нам доступ к получению содержимого поля <title> и мета-описания <meta name="description" />.

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

А мы пока напишем обработчик страницы. Объявим интерфейс (он нам вскоре пригодится):

namespace Parser;
 
interface Page
{
    public function load($url);
    public function getH1();
    public function getTitle();
    public function getDescription();
    public function getKeywords();
}

и сам класс страницы:

namespace Parser;
 
class HTMLPage implements Page
{
    private $html = '';
 
    public function load($url) {
        $http = curl_init($url);
        curl_setopt($http, CURLOPT_RETURNTRANSFER, 1);
        $result = curl_exec($http);
        $http_status = curl_getinfo($http, CURLINFO_HTTP_CODE);
        curl_close($http);
        $this->html = $result;
        return $http_status == 200;
    }
 
    public function getH1() {
        $matches = [];
        if (preg_match('@<h1[^>]*>(?P<value>[^<]*?)</h1>@is', $this->html, $matches)) {
            return $matches['value'];
        }
        return '';
    }
 
    public function getTitle() {
        $matches = [];
        if (preg_match('@<title>(?P<value>[^<]*?)</title>@is', $this->html, $matches)) {
            return $matches['value'];
        }
        return '';
    }
 
    public function getDescription() {
        $matches = [];
        if (
            preg_match('@<meta name="description" content="(?P<value>[^"]*?)"\s?/?>@is', $this->html, $matches) ||
            preg_match('@<meta content="(?P<value>[^"]*?)" name="description"\s?/?>@is', $this->html, $matches)
        ) {
            return $matches['value'];
        }
        return '';
    }
 
    public function getKeywords() {
        $matches = [];
        if (
            preg_match('@<meta name="keywords" content="(?P<value>[^"]*?)"\s?/?>@is', $this->html, $matches) ||
            preg_match('@<meta content="(?P<value>[^"]*?)" name="keywords"\s?/?>@is', $this->html, $matches)
        ) {
            return $matches['value'];
        }
        return '';
    }
}

Можно добавить и другие методы. Например getCategory для извлечения рубрики из хлебных крошек или получение меток. Но это уже будет зависеть от HTML-кода каждого сайта. Если требуется получать категории и метки, то вместо изменения кода можно, например, включить вывод имени категории и списка меток в поле <meta name="description" />. По крайней мере, так могут делать некоторые плагины для разных CMS.

Загрузку мы сделали аналогично методу Sitemap::load. Кто-то усмотрит в этом возможность наследования этого метода от общего класса. Например, HTMLPage и Sitemap можно унаследовать от класса XMLPage, так как HTML тоже относится к XML. Но наследование будет только мешать, так как во всём остальном (кроме одинакового метода load) эти классы играют разные роли. Они совершенно разные по назначению.

Как более удобное решение можно вынести операции с CURL в отдельный класс Loader и передавать этот компонент внутрь Sitemap и HTMLPage через конструктор.

Это можно было бы сделать так:

class HTMLPage implements Page
{
  private $html = '';
  private $loader;
 
  public function __construct(Loader $loader) {
      $this->loader = $loader;
  }
 
  public function load($url) {
      $this->html = $this->loader->load($url);
      return $this->html !== false;
  }
 
  ...
}

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

Теперь мы спокойно можем доработать наш скрипт для вывода заголовков с помощью методов getTitle или getH1:

<?php
 
namespace Parser;
 
require(dirname(__FILE__) . '/inc/Parser/Sitemap.php');
require(dirname(__FILE__) . '/inc/Parser/HTMLPage.php');
 
...
 
foreach ($map->getUrls() as $url) {
    if (preg_match($match, $url) && !preg_match($exclude, $url)) {
        $page = new HTMLPage();
        if ($page->load($url) {
            echo $page->getTitle() . PHP_EOL;
        }
    }
}

Создание твитов

У нас есть заголовки и адреса. Пришло время собирать из них сообщения для Twitter:

namespace Twitter;
 
class Tweet
{
    private $prefix = '';
    private $text = '';
    private $url = '';
    private $via = '';
 
    public function __construct($text, $url = '', $via = '', $prefix = '') {
        $this->prefix = $prefix;
        $this->text = $text;
        $this->url = $url;
        $this->via = $via;
    }
 
    public function getMessage() {
        $prefix = $this->prefix ? $this->prefix . ' ' : '';
        $text = $this->text;
        $url = $this->url ? ': ' . $this->url : '';
        $via = $this->via ? ' via ' . $this->via : '';
        return $prefix . $text . $url . $via;
    }
}

В итоге метод getText склеит заголовок, двоеточие, адрес и наш ник (если они, конечно же, переданы) в такую конструкцию:

Использование поведений Behavior в Yii: http://www.elisdn.ru/blog/41/usage-of-behaviors-in-yii via @elisdnru

если в качестве $text передан заголовок <h1> данной страницы.

Дополним скрипт созданием твитов:

<?php
 
namespace Parser;
 
use Twitter\Tweet;
 
require(dirname(__FILE__) . '/inc/Parser/Sitemap.php');
require(dirname(__FILE__) . '/inc/Parser/Page.php');
require(dirname(__FILE__) . '/inc/Parser/HTMLPage.php');
require(dirname(__FILE__) . '/inc/Twitter/Tweet.php');
 
$via = '@elisdnru';
 
$prefix = 'Архив:';
 
...
 
foreach ($map->getUrls() as $url) {
    if (preg_match($match, $url) && !preg_match($exclude, $url)) {
        $page = new HTMLPage();
        if ($page->load($url) {
            $tweet = new Tweet($page->getH1(), $url, $via, $prefix);
            echo $tweet->getMessage() . PHP_EOL;
        }
    }
}

Пока будем просто выводить на экран с помощью echo.

Загрузка классов

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

Давайте создадим файл inc/autoload.php с таким содержимым:

<?php
spl_autoload_register(function ($className) {
    $chunks = explode('\\', ltrim($className, '\\') );
    $file = dirname(__FILE__) . '/' . implode('/', $chunks) . '.php';
    if(file_exists($file)) {
        include($file);
        return class_exists($className, false) || interface_exists($className, false);
    }
    return false;
});

Всю группу require в нашем скрипте заменим на одну строку включения этого файла:

<?php
 
namespace Parser;
 
use Twitter\Tweet;
 
require(dirname(__FILE__) . '/inc/autoload.php');
 
...

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

Например, при создании твита конструкцией new Tweet(...) система определит его пространство имён по строке use Twitter\Tweet; и запустит нашу функцию со значением Twitter\Tweet в аргументе $className. Для класса HTMLPage значение use мы не указали, поэтому интерпретатор будет его искать в текущем пространстве имён, то есть как Parser\HTMLPage.

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

Идём дальше.

Хэштеги

Если мы просто опубликуем твит в нашей ленте, то никто, кроме наших читателей, его не увидит. Несмотря на нашу скромность, мы бы хотели разместить сообщение с так называемыми хэштегами. Это просто слова, помеченные символом решётки #. При публикации они автоматически превращаются в ссылки, ведущие на поиск по ним же. Если у вас блог по программированию, то неплохо было бы «засветиться» среди других твитов по хэштегу вашего языка.

Например, хотелось бы добавить после адреса страницы нечто подобное этому примеру:

Выносим CRUD действия контроллеров в классы в Yii: http://www.elisdn.ru/blog/24/vinosim-deistviia-kontrollerov-v-klassi #PHP #Yii #CRUD via @elisdnru

то есть блок #PHP #Yii #CRUD после адреса.

Напишем новый класс Tagger и поместим его тоже в папку inc/Twitter:

namespace Twitter;
 
class Tagger
{
    protected $require_tags = [];
    protected $matched_tags = [];
 
    public function __construct(array $require_tags, array $matched_tags) {
        $this->require_tags = $require_tags;
        $this->matched_tags = $matched_tags;
    }
 
    public function getTags($text) {
        $filtered_text = str_replace(['. ', ': ', '? ', '&'], ' ', ' ' . $text . ' ');
        $tags = [];
        foreach ($this->require_tags as $tag) {
            $tags[] = '#' . $tag;
        }
        foreach ($this->matched_tags as $match => $tag) {
            if (mb_stripos($filtered_text, $match, null, 'UTF-8') !== false) {
                $tags[] = '#' . $tag;
            }
        }
        return implode(' ', array_unique($tags));
    }
}

Он, для удобства, будет поддерживать два набора тегов: постоянные (обязательные) и контекстные. Например, мы хотим задать для его страниц некоторые хэштеги:

// Постоянные хэштеги
$required_tags = [
    'программирование',
];
 
// Контекстные хэштеги ('совпадение' => 'хэштег')
$matched_tags = [
    ' паттерн' => 'паттерны',
    ' seo ' => 'SEO',
    ' html ' => 'HTML',
    ' php' => 'PHP',
    ' jquery' => 'jQuery',
    '.js ' => 'JavaScript',
    ' composer' => 'composerphp',
    ' flash ' => 'Flash',
    ' yii ' => 'Yii',
    ' yii2 ' => 'Yii2',
    ' rbac ' => 'RBAC',
    ' crud ' => 'CRUD',
    ' dao ' => 'DAO',
    ' ar ' => 'ActiveRecord',
];
 
$tagger = new Tagger($required_tags, $matched_tags);

Мы намеренно добавили дополнительные пробелы к тексту ' ' . $text . ' ' при сравнении, чтобы не было проблем с поиском в начале и конце строки. Также в методе getTags отфильтровали мешающие символы.

Постоянные будут сопровождать каждый твит, а вторые будут подключать из указанного массива хэштег-значение только при найденном вхождении ключа. Теперь если статья будет называться «Установка Yii2 через Composer», то подтянется обязательный #программирование и автоматически добавятся теги #Yii2 #composerphp.

Добавим работу с тегами в класс нашего твита:

namespace Twitter;
 
class Tweet
{
    private $prefix = '';
    private $text = '';
    private $url = '';
    private $tags = '';
    private $via = '';
 
    public function __construct($text, $url = '', $tags = '', $via = '', $prefix = '') {
        $this->prefix = $prefix;
        $this->text = $text;
        $this->url = $url;
        $this->tags = $tags;
        $this->via = $via;
    }
 
    public function getMessage() {
        $prefix = $this->prefix ? $this->prefix . ' ' : '';
        $text = $this->text;
        $url = $this->url ? ': ' . $this->url : '';
        $tags = $this->tags ? ' ' . $this->tags : '';
        $via = $this->via ? ' via ' . $this->via : '';
        return $prefix . $text . $url . $tags . $via;
    }
}

и попробуем запустить тот самый консольный скрипт, дополненный обработкой хэштегов:

// Извлекаем из страницы элементы
$h1 = $page->getH1();
$title = $page->getTitle();
$description = $page->getDescription();
$keywords = $page->getKeywords();
// Склеиваем фразу для поиска
$words = $h1 . ' ' . $title . ' ' . $description . ' ' . $keywords;
// Получаем теги на основе ключевых слов
$tags = $tagger->getTags($words);
// Передаём данные в конструктор твита
$tweet = new Tweet($h1, $url, $tags, $via, $prefix);
// Выводим на экран (или записываем в файл)
echo $tweet->getMessage() . PHP_EOL;

Автоподстановка хэштегов на основе текста должна заработать.

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

Сокращение ссылок

Со статьёй с длинным заголовком и переводом (или транслитом) этого заголовка в адресе можно и не уложиться в разрешённые в Twitter 140 символов и сообщения будут обрезаться. Так что если в вашем блоге слишком длинные адреса, то с ними не обойтись без сервиса сокращения ссылок.

Зайдём, например, на bitly.com, вставим в верхнее поле любой URL и нажмём «Short». В результате получится короткий адрес вроде http://bit.ly/1bNnRWZ. Если по нему перейти, то произойдёт перенаправление на оригинальную страницу. Аналогичную услугу предоставляют ещё несколько сервисов, но многие привязаны к конкретным приложениям и сайтам. Остановимся на BitLy.

Щёлкаем ссылку «Sign up» на странице сервиса и регистрируем аккаунт. После этого щёлкаем по своему имени пользователя в правом верхнем углу и выбираем пункт настроек профиля «Settings». На первой вкладке под полем своего электронного адреса видим строку «Your email address is not verified. Click here to verify your account». Запускаем верификацию и следуем инструкциям в пришедшем к нам письме. Этот шаг необходим, так как без подтверждения email-адреса нам не дадут API-ключ.

Снова щёлкаем по имени пользователя в углу и переходим на страницу Tools, где в самом конце страницы находим ссылку на раздел сайта для разработчиков (bitly dev site). В списке на открывшейся странице переходим в «Manage My Apps». Остался последний шаг – ввести текущий пароль, нажать «Generate Token» и скопировать к себе полученный сорокасимвольный хеш.

Добавляем к нашему проекту в новую папку inc/Shorter файлы Shorter.php, DummyShorter.php и BitLy.php:

namespace Shorter;
 
interface Shorter
{
    public function shortUrl($url);
}
namespace Shorter;
 
class DummyShorter implements Shorter
{
    public function shortUrl($url) {
        return $url;
    }
}
namespace Shorter;
 
class BitLy implements Shorter
{
    private $token;
 
    public function __construct($token) {
        $this->token = $token;
    }
 
    public function shortUrl($url) {
        $query = 'https://api-ssl.bitly.com/v3/shorten?access_token=' . $this->token . '&longUrl=' . urlencode($url);
        $response = json_decode(file_get_contents($query));
        return $response->data->url;
    }
}

Это, первым делом, интерфейс, определяющий способ взимодействия данного компонента с остальными в нашем приложении, ничего не делающая заглушка DummyShorter и BitLy – уже полноценный рабочий компонент для одноимённого сервиса.

Теперь создаём сокращатель адресов с использованием нашего API-ключа (замените на свой) и передаём в распоряжение нашему скрипту:

use Shorter\BitLy;
use Twitter\Tagger;
use Twitter\Tweet;
 
...
 
// Сокращатель URL
$shorter = new BitLy('0000000000000000000000000000000000000000');
 
foreach (...) {
    ...
    // Сокращаем адрес
    $short_url = $shorter->shortUrl($url);
    ...
    // И передаём уже его
    $tweet = new Tweet($h1, $short_url, $tags, $via, $prefix);
    // Выводим на экран (или записываем в файл)
    echo $tweet->getMessage() . PHP_EOL;
}

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

Повторные запуски пригодятся для отладки и подбора параметров. Их и попробуем вскоре оптимизировать.

Запись твитов в файл

Для добавления каждого готового твита в файл отчёта подойдёт функция file_put_contents с флагом FILE_APPEND. Не забудем вначале удалить старый файл, чтобы не вписывать в него по второму кругу. Инкапсулируем работу с отчётом в класс Result:

namespace Parser;
 
class Result
{
    protected $file = '';
 
    public function __construct($file) {
        $this->file = $file;
        if (file_exists($this->file)) {
            unlink($this->file);
        }
    }
 
    public function add($text) {
        file_put_contents($this->file, $text . PHP_EOL, FILE_APPEND);
    }
}

Теперь при инициализации объектов создадим экземпляр нашего драйвера:

...
// Запись результатов в файл
$result = new Result(dirname(__FILE__) . '/twits_elisdn.txt');
...

и будем передавать ему каждый твит:

...
$tweet = new Tweet($h1, $short_url, $tags, $via, $prefix);
// Получаем текст твита
$text = $tweet->getMessage();
// Записываем в файл и выводим на экран
$result->add($text);
echo $text . PHP_EOL;

Для вывода на экран можно задействовать цветной вывод с помощью класса ConsoleLogger, использовавшегося нами ранее при написании консольного минимизатора:

use Logger\ConsoleLogger;
use Shorter\BitLy;
use Twitter\Tagger;
use Twitter\Tweet;
...
$logger = new ConsoleLogger();
...
$result->add($text);
$logger->write($text);
// Показываем корректность длины твита
$length = mb_strlen($text, 'UTF-8');
if ($length <= 140) {
    $logger->writelnSuccess($length);
} else {
    $logger->writelnError($length);
}

Здесь мы проверили длину твитов и подсветили красным «зашкалившие» за 140 символов.

Кеширование

Итак, теперь можно попробовать «поиграться» с хэштегами и подобрать нужные. Но есть небольшое неудобство: после каждого изменения скрипта весь процесс нужно запускать сначала. А это очень долго, так как заново приходится загружать страницы и сокращать ссылки. Это и лишняя нагрузка на сервер.

Для решения этой проблемы желательно записывать где-то у себя уже загруженные элементы.

Первым делом, продумаем работу нашего кеша. Если вы уже работали с какими-либо хранилищами, то наверняка помните их базовый интерфейс:

namespace Cache;
 
interface Cache
{
    public function get($key);
    public function set($key, $value);
    public function has($key);
    public function remove($key);
    public function flush();
}

Любое значение можно записать, получить, проверить наличие и удалить. Также есть команда полной очистки всех данных.

Мы будем для простоты записывать данные в файл:

namespace Cache;
 
class FileCache implements Cache
{
    private $file;
    private $data = [];
 
    public function __construct($file = 'cache.txt') {
        $this->file = $file;
    }
 
    public function get($key) {
        $this->load();
        return array_key_exists($key, $this->data) ? $this->data[$key] : null;
    }
 
    public function set($key, $value) {
        $this->load();
        $this->data[$key] = $value;
        $this->update();
    }
 
    public function has($key) {
        $this->load();
        return array_key_exists($key, $this->data);
    }
 
    public function remove($key) {
        $this->load();
        if (array_key_exists($key, $this->data)) {
            unset($this->data[$key]);
        }
        $this->update();
    }
 
    public function flush() {
        $this->data = [];
        $this->update();
    }
 
    private function load() {
        $this->data = $this->loadData();
    }
 
    private function update() {
        $this->updateData($this->data);
    }
 
    private function loadData() {
        $this->checkFile();
        return require($this->file);
    }
 
    private function checkFile() {
        if (!file_exists($this->file)) {
            $this->updateData([]);
        }
    }
 
    private function updateData($data) {
        file_put_contents($this->file, '<?php' . PHP_EOL . 'return ' . var_export($data, true) . ';');
    }
}

Данный код использует хранение массива данных в виде PHP-файла с конструкцией return array(...).

Создадим объект кеша:

use Cache\FileCache;
 
...
 
// Кеширование полученных ранее данных
$cache = new FileCache(dirname(__FILE__) . '/cache.txt');
// Если передали флаг очистки кеша
if (in_array('--reset', $argv)) {
    $cache->flush();
}
 
...

Уже можно попробовать использовать его по назначению. Но мы пойдём не совсем явным путём. Создадим ещё один класс в папке inc/Shorter:

namespace Shorter;
 
use Cache\Cache;
 
class CachedShorter implements Shorter
{
    private $shorter;
    private $cache;
 
    public function __construct(Shorter $shorter, Cache $cache) {
        $this->shorter = $shorter;
        $this->cache = $cache;
    }
 
    public function shortUrl($url) {
        $key = $this->generateCacheKey($url);
        if ($this->cache->has($key)) {
            $value = $this->cache->get($key);
        } else {
            $value = $this->shorter->shortUrl($url);
            $this->cache->set($key, $value);
        }
        return $value;
    }
 
    private function generateCacheKey($url) {
        return 'short_' . md5($url);
    }
}

Он принимает объект типа Shorter и сам является им же. Внутри он транслирует вызов тому же методу shortUrl оригинального объекта, но не просто возвращает его значение, а дополнительно кеширует его. Теперь вместо:

// Сокращатель URL
$shorter = new BitLy('0000000000000000000000000000000000000000');

мы можем «обернуть» сокращатель этим кэширующим объектом:

// Сокращатель URL
$shorter = new CachedShorter(new BitLy('0000000000000000000000000000000000000000'), $cache);

При этом работа нашего скрипта никак не изменится, так как BitLy и CachedShorter реализуют один и тот же интерфейс Shorter и содержат один и тот же метод shortUrl. То есть они полностью взаимозаменяемы.

У нас образовалась ситуация, когда один объект хранит в себе другой сложный или тяжеловесный оригинальный объект и оборачивает его так, чтобы помимо простой трансляции вызовов одноимённых методов контролировать доступ к нему. При этом снаружи все думают, что работают с оригинальным объектом напрямую. Такой шаблон проектирования (и такой оборачивающий класс) называют Заместителем (Proxy).

В нашем частном случае мы сделали простой кэширующий прокси. Он запускает работу оригинального объекта только когда ему это понадобится.

Сделаем то же самое для класса HTMLPage:

namespace Parser;
 
use Cache\Cache;
 
class CachedHTMLPage implements Page
{
    private $page;
    private $cache;
    private $data = [
        'h1' => '',
        'title' => '',
        'description' => '',
        'keywords' => '',
    ];
 
    public function __construct(Page $page, Cache $cache) {
        $this->page = $page;
        $this->cache = $cache;
    }
 
    public function load($url) {
        $key = $this->generateCacheKey($url);
        if ($this->cache->has($key)) {
            $this->data = unserialize($this->cache->get($key));
            return true;
        } else {
            $load = $this->page->load($url);
            $data = [
                'h1' => $this->page->getH1(),
                'title' => $this->page->getTitle(),
                'description' => $this->page->getDescription(),
                'keywords' => $this->page->getKeywords(),
            ];
            $this->cache->set($key, serialize($data));
            $this->data = $data;
            return $load;
        }
    }
 
    public function getH1() {
        return $this->data['h1'];
    }
 
    public function getTitle() {
        return $this->data['title'];
    }
 
    public function getDescription() {
        return $this->data['description'];
    }
 
    public function getKeywords() {
        return $this->data['keywords'];
    }
 
    private function generateCacheKey($url) {
        return 'page_' . md5($url);
    }
}

Здесь немного сложнее, так как методов много. Данный класс также хранит у себя оригинальный объект $page, но при вызове load($url) либо считывает значения из кеша (если они там есть), либо загружает один раз $this->page->load($url) и сразу сохраняет в кеш.

Аналогично производим замену:

// Создаём экземпляр страницы для текущего адреса
$page = new HTMLPage();

на:

// Создаём экземпляр страницы для текущего адреса
$page = new CachedHTMLPage(new HTMLPage(), $cache);

При декорировании или проксировании использование интерфейса не всегда нужно. Можно было не вводить интерфейс Page, а для сохранения типа объекта наследоваться прямо от оригинального класса:

class CachedHTMLPage extends HTMLPage

но тогда бы было не так просто уследить за всеми защищёнными (protected) полями и методами. Представьте, что класс HTMLPage содержал бы несколько десятков protected методов. И они все бы унаследовались в CachedHTMLPage. Потом при вызове new B(new A()) в памяти создалось бы два крупных экземпляра этих классов. А при использовании интерфейса каким бы «тяжёлым» ни был оригинальный класс A, замещающий его класс B будет лёгким и чистым.

Таким образом, для сокращателя ссылок и загрузчика страниц теперь стал доступен файловый кеш.

Итог

В результате мы сейчас немного «заморочились» и сделали классный генератор твитов.

Для возможности работы с несколькими сайтами все параметры для конкретного сайта вынесем в конфигурационный файл config_elisdn.php:

<?php
 
return [
 
    'sitemap' => 'http://www.elisdn.ru/sitemap.xml',
 
    'match' => '@http://www\.elisdn\.ru/blog/@i',
    'exclude' => '@http://www\.elisdn\.ru/blog/(49|53)/@i',
 
    'cache_file' => 'cache_elisdn.txt',
 
    // Вставьте свой ключ, если нужен сокращатель
    'bitly_key' => '',
 
    // Подпись твитов
    'via' => '@elisdnru',
 
    // Префикс твитов
    'prefix' => 'Архив: ',
 
    'tags' => [
 
        // Постоянные хэштеги
        'required' => [
            //'программирование',
        ],
 
        // Контекстные хэштеги ('совпадение' => 'хэштег')
        'matched' => [
            ' программировани' => 'программирование',
            ' администрировани' => 'администрирование',
            ' UX-дизайн' => 'UX',
            ' интернет-бизнес' => 'бизнес',
            ' саморазвити' => 'саморазвитие',
            ' тайм-менеджмент' => 'ТМ',
            ' паттерн' => 'паттерны',
            ' windows' => 'Windows',
            ' seo ' => 'SEO',
            ' smm ' => 'SMM',
            ' html ' => 'HTML',
            ' php' => 'PHP',
            ' jquery' => 'jQuery',
            '.js ' => 'JavaScript',
            ' composer' => 'composerphp',
            ' flash ' => 'Flash',
            ' yii ' => 'Yii',
            ' yii2 ' => 'Yii2',
            ' rbac ' => 'RBAC',
            ' crud ' => 'CRUD',
            ' dao ' => 'DAO',
            ' ar ' => 'ActiveRecord',
        ],
 
    ],
 
];

и создадим общий скрипт tweets.php:

#!/usr/bin/env php
<?php
/**
 * @author ElisDN <mail@elisdn.ru>
 * @link http://www.elisdn.ru/blog/58/old-posts-promotion-on-twitter
 * @version 1.0
 */
 
namespace Parser;
 
use Cache\FileCache;
use Logger\ConsoleLogger;
use Shorter\BitLy;
use Shorter\CachedShorter;
use Shorter\DummyShorter;
use Twitter\Tagger;
use Twitter\Tweet;
 
require(dirname(__FILE__) . '/inc/autoload.php');
 
// Вывод информации на экран
$logger = new ConsoleLogger();
 
#############################
 
// Проверка на правильность вызова скрипта
if (empty($argv[1]) || empty($argv[2])) {
    $logger->writelnNotice('php tweets.php <config_file> <result_file> [--reset]');
    die(1);
}
 
// Получение переданных параметров
$config_file = $argv[1];
$result_file = $argv[2];
$reset_cache = in_array('--reset', $argv);
 
// Проверка и загрузка файла конфигурации
if (!file_exists($config_file)) {
    $logger->writelnError('Configuration file is not found');
    die(1);
}
 
$config = require($config_file);
 
#############################
 
// Для записи твитов в файл
$result = new Result($result_file);
 
// Генератор хэштегов
$tagger = new Tagger($config['tags']['required'], $config['tags']['matched']);
 
// Кеширование уже полученных ранее данных
$cache = new FileCache($config['cache_file']);
 
// Если передали флаг очистки кеша
if ($reset_cache) {
    $cache->flush();
}
 
// Включить окращатель URL если указан ключ
if (!empty($config['bitly_key'])) {
    $shorter = new CachedShorter(new BitLy($config['bitly_key']), $cache);
} else {
    $shorter = new DummyShorter();
}
 
#############################
 
$sitemap = new Sitemap();
// Загружаем карту сайта
if ($sitemap->load($config['sitemap'])) {
    // Обходим адреса
    foreach ($sitemap->getUrls() as $url) {
        // Проверяем по списку исключений
        if (preg_match($config['match'], $url) && !preg_match($config['exclude'], $url)) {
            // Создаём экземпляр страницы для текущего адреса
            $page = new CachedHTMLPage(new HTMLPage(), $cache);
            // Если страница загружена
            if ($page->load($url)) {
                // Сокращаем адрес
                $short_url = $shorter->shortUrl($url);
                // Извлекаем из страницы элементы
                $h1 = $page->getH1();
                $title = $page->getTitle();
                $description = $page->getDescription();
                $keywords = $page->getKeywords();
                // Получаем теги на основе ключевых слов
                $words = $h1 . ' ' . $title . ' ' . $description . ' ' . $keywords;
                $tags = $tagger->getTags($words);
                // Передаём данные в конструктор твита
                $tweet = new Tweet($h1, $short_url, $tags, $config['via'], $config['prefix']);
                // Генерируем сообщение
                $text = $tweet->getMessage();
                // Записываем готовый твит в файл и выводим на экран
                $result->add($text);
                $logger->write($text);
                // Показываем корректность длины твита
                $length = mb_strlen($text, 'UTF-8');
                if ($length <= 140) {
                    $logger->writelnSuccess($length);
                } else {
                    $logger->writelnError($length);
                }
            } else {
                $logger->writelnError($url);
            }
        }
    }
} else {
    $logger->writelnError('Sitemap is not loaded');
    die(1);
}

Теперь запустим генерацию, указав файл с параметрами и файл для записи твитов:

php tweets.php config_elisdn.php tweets_elisdn.txt

На экране будут выводиться твиты с их длиной:

Архив: Yii и хранение настроек в базе данных: http://bit.ly/1foXwoJ #программирование #PHP #Yii #CRUD via @elisdnru [115]
Архив: Гибкая настройка разрешений для ролей RBAC: http://bit.ly/1hlIrms #программирование #PHP #Yii #RBAC via @elisdnru [120]
Архив: Получение курсов валют с сайта Центробанка: http://bit.ly/1hlIqyQ #программирование #PHP #Yii via @elisdnru [114]
Архив: Выносим CRUD действия контроллеров в классы в Yii: http://bit.ly/1foXwFj #программирование #PHP #Yii #CRUD via @elisdnru [127]

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

При каждом повторном запуске команды все уже готовые твиты будут мгновенно перегенерироваться по данным из кеша. Уже можно будет спокойно экспериментировать с хэштегами, не боясь перегрузить сервер. Также в любой момент можно будет нажать Ctrl+C или Ctrl+Break для остановки парсинга и запустить его когда-нибудь позже. Процесс продолжится с точки останова, а не начнётся сначала.

Но стоит запустить скрипт с флагом сброса --reset:

php tweets.php config_elisdn.php tweets_elisdn.txt --reset

Кэш очистится и загрузка страниц пойдёт сначала. Можно также удалить файл кеша вручную.

Готовый комплект файлов можно либо собрать по фрагментам кода в статье, либо забрать здесь:

Полученные твиты теперь можно импортировать в любой сервис-планировщик для отложенной публикации их по расписанию в вашей ленте. Данную операцию можно будет повторять снова и снова в будущем для любого более-менее грамотно сделанного сайта.

Другие статьи

В комментариях к предыдущей статье появилось предложение пошагово разобрать процесс разработки целого приложения на Yii2. Инструкция по созданию блога выложена в официальном руководстве. Пока для первой части, но это, думаю, ненадолго. Конечно же, там не было раскрыто специфических моментов, но, тем не менее, руководство имелось. Оно открыто для изменений: каждый знакомый с системами контроля версий может это руководство и блог доработать.

С момента предыдущей публикации на сайте прошло два месяца. За это время практически наступило лето, начал набирать обороты мой замороженный с декабря по апрель марафонский контентный проект, изучено много материалов в различных областях знаний. Но главное событие этой весны для некоторых программистов – это выход beta-версии нового Yii 2.0. Это не может не радовать, так как практически на наших глазах произошёл вход Yii в экосистему фреймворков нового поколения.

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

Недавно в обратную связь поступил вопрос. Один из читателей поинтересовался, как можно сделать удобный вывод иерархических данных, построенных по принципу Adjacency List, в виджете CGridView вместо CTreeView. Это, например, могут быть вложенные статические страницы, категории или пункты меню, хранимые в базе данных. Попробуем решить этот вопрос.

Комментарии

 

Андрей

Большое спасибо! Купил!

Ответить

 

Сергей

Вот за это спасибище!

Ответить

 

Всеволод
Комментарий удалён
Ответить

Оставить комментарий

Войти | Завести аккаунт


(никто не увидит)



Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>