Уселся позавчера на ночь почитать спецификацию Pingback (а что, у всех свои развлечения :-) ). А она оказалось такой маленькой, простой и понятной, что я как-то сразу и написал все за пару часов, попивая чаек (какой-то из дешевых юннаньских красных). Не откладывая, спешу поделиться кодом и соображениями.

Да, и кстати... Пользуясь случаем хочу повториться, что Pingback — вещь, и он заслуживает того, чтобы быть распространенным протоколом межсайтового уведомления, а не прозябать, как сейчас, реализованным только в WordPress по большому счету. Так что, уважаемые программисты, отройте где-нибудь пару-тройку часов времени и реализуйте клиент или сервер или и то, и другое!

Bzr-репозиторий http://softwaremaniacs.org/code/cicero/
Работающий форум http://softwaremaniacs.org/forum/

Посмотреть, как это работает, можно, написав в форум пост со ссылкой на свой блог (не надо спамить чужие блоги тестовыми пингами!), если он поддерживает Pingback.

Реализация

Алгоритм pingback'а очень простой:

  1. при создании новой статьи из ее текста вынимаются ссылки
  2. из них отбираются те, которые ведут вовне форума
  3. содержимое ссылки загружается, и в заголовках или теле ответа ищется адрес pingback-сервера (другими словами, выясняется, поддерживает ли софт на том сервере pingback)
  4. у pingback-сервера через XML-RPC вызывается определенный метод, уведомляющий его о том, что в форумной статье такой-то содержится ссылка на его такую-то страницу (это самая простая часть, как будет видно дальше)

В коде я все это запихнул в один метод у Article. Он вышел чуть пухловатым, но зато он прост по сути и, надеюсь, читаем:

def ping_external_links(self):

  # Пара временных переменных: 
  # - URL к индексу форума, чтобы определять внешние ссылки
  # - абсолютный URL страницы топика этой статьи
  from django.contrib.sites.models import Site
  domain = Site.objects.get_current().domain
  from django.core.urlresolvers import reverse
  index_url = reverse('cicero_index')
  topic_url = 'http://%s%s' % (domain, reverse('cicero.views.topic', args=(self.topic.forum.slug, self.topic.id)))

  # определение внешней ссылки: должна быть либо с другого домена,
  # либо по крайней мере не внутри форума
  def is_external(url):
    from urlparse import urlsplit
    scheme, server, path, query, fragment = urlsplit(url)
    return server != '' and \
           (server != domain or not path.startswith(index_url))

  # поиск ссылки на pingback-сервер внутри содержимого внешней страницы
  # по регулярному выражению, как прописано в спецификации
  def search_link(content):
    match = re.search(r'<link rel="pingback" href="([^"]+)" ?/?>', content)
    return match and match.group(1)

  # парсинг HTML статьи и отбор внешних ссылок
  from BeautifulSoup import BeautifulSoup
  soup = BeautifulSoup(self.html())
  links = [a['href'] for a in soup.findAll('a') if is_external(a['href'])]

  # обход всех ссылок с попыткой их пинговать
  from xmlrpclib import ServerProxy, Fault
  from urllib2 import urlopen
  for link in links:
    try:
      f = urlopen(link)

      # определение ссылки на pingback-сервер
      # из HTTP-заголовков или первых 500 КБ страницы
      server_url = f.info().get('X-Pingback', '') or search_link(f.read(512 * 1024))
      if server_url:

        # если ссылка есть, вызываем XML-RPC процедуру
        # со ссылками откуда и куда делается пинг
        server = ServerProxy(server_url)
        server.pingback.ping(topic_url, link)
    except (IOError, Fault):
      pass
    f.close()

Тут, знаете, хочется сказать большое спасибо Питону и его библиотекам, как стандартным, так и сторонним. Разложение URL'а на части, регулярные выражения, парсинг HTML, HTTP-запрос с анализом заголовков и собственно XML-RPC вызов делаются простыми и, главное, очевидными средствами. Это, как мне кажется, очень большая составляющая того, почему разработка на Питоне оказывается быстрой.

Мелкая засада с транзакциями

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

if form.is_valid():
  article = form.save()
  article.ping_external_links()
  return HttpResponseRedirect(...)

Так вот здесь во время вызова пинга внешний сервер не увидит у меня на сайте этой самой новой статьи. Потому что при включенном автоматическом управлении транзакциями они коммитятся только после завершения работы всей view.

Обошел я проблему просто: вручную коммичу транзакцию прямо перед ping_external_links. То, что коммит происходит до завершения view, в этом случае совершенно безопасно с точки зрения целостности данных, потому что после этого момента они больше не меняются.

Всякие страхи

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

Вся пинговая механика делается синхронно и, соответственно, блокирует выполнение запроса пользователя. А значит, если пользователь поставит ссылку на какой-нибудь дохлый сервер, то сохранение его статьи будет долго-долго висеть, дожидаясь таймаута. В принципе, данные к тому времени уже сохранены, но все равно неприятно. Как решать — пока не знаю. Решение, например, с отфоркиванием пингующего процесса кажется чересчур сложными для такой простой вещи.

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

Про сервер

Напоследок пару слов о реализации серверной части протокола.

В общем-то, я и не собирался его делать с самого начала. Тех, кто не помнит почему, процитирую свой комментарий на этот счет:

«сам форум не будет регистрировать пинги извне» — может стоит оставить эту фичу? Ведь на топики в форумах, особенно полезные, часто ссылаются.

Проблемы две: как их там показывать, и главное, кто их там будет читать? По опыту большинства пингов к комментариям этого блога, я вижу, что это скорее интересный мне как автору эго-бустер “вот на меня поставили ссылку”. В форумном же топике толпа ссылок с разных сайтов “вот глянь, тут есть решение”, мне кажется, не будет никому интересна… Нет?

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

Комментарии: 21

  1. [...] Теперь по идее в комментариях к статье на блоге про реализацию Pingback должна появиться ссылка на этот [...]

  2. Мрачный аббат

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

    А если вытаскивать ссылки из сообщений, которые нужно пингануть, и складывать их в какую-нибудь очередь. А запускающийся раз в n времени отдельный скрипт будет их уже пинговать. Тут же вроде не так уж важна орперативность пинга.

    Заодно можно смотреть чтоб не было много ссылок на один и тот же ресурс и еще что-нибудь. а?

  3. Alex Lebedev

    Иван, у меня вызывает сомнение следующий фрагмент кода:

    except (IOError, Fault):
    pass
    f.close()

    При возникновении незапланированной ошибки указатель на файл не будет закрыт. Вот пример кода, в котором такая обработка ошибок не сработает:

    >>> from xmlrpclib import ServerProxy, Fault
    >>> server = ServerProxy('http://nowhere.com.ua.ru')
    >>> server.pingback
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "c:\python25\lib\xmlrpclib.py", line 1147, in __call__
        return self.__send(self.__name, args)
      File "c:\python25\lib\xmlrpclib.py", line 1437, in __request
        verbose=self.__verbose
      File "c:\python25\lib\xmlrpclib.py", line 1191, in request
        headers
    xmlrpclib.ProtocolError: <ProtocolError for nowhere.com.ua.ru/RPC2: 302 Found>
    

    Следует ставить закрытие указателя в блок finally, либо (предпочтительнее) использовать with:

    with f=urlopen(link):
      do_something
    
  4. Иван Сагалаев

    Дотошный какой :-)

    На самом деле, f все таки закроется. При непойманном Exception'е функция завершиться, и f рано или поздно попадет под нож сборщика мусора и при уничтожении закроет сокет (если я ничего не путаю). Да, тут нельзя будет рассчитывать на то, что это произойдет точно после ошибки, но это и не нужно: когда закроется, тогда закроется.

    А у finally и with есть два больших "но": первый в Питоне 2.4 заставить делать еще один try..except вокруг, что выглядит уродливо, а второго в 2.4 просто нет.

    Кстати, в 2.5 Питоне блок с with выглядел бы немного не так:

    with urlopen(server_url) as f:
      f.info() # ...
    
  5. dobrych

    Кстати, пинговалку лучше наверно запускать через сигналы (http://www.djangoproject.com/documentation/db-api/#what-happens-when-you-save) на post_save...

  6. Mourner

    Ну вообще главным объектом критики в подобных штуках всегда были не технические моменты, а невероятные кол-ва спама, которые через него посылают.

    Вот сделаю блог про виагру и оставлю в посте пару сотен линков, пропингую, а как появятся беки - уберу. Как этот момент решается?

  7. Александр

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

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

  8. koder

    Можно попробовать превентивно ограничить количество обрабатываемых ссылок в одном посте до N(8, например).

    f.close() на самом деле можно вообще убрать ;).
    Как раз ровно в этом месте(при выходе из функции или по заходе в след цикл) он и умрет т.к. счетчик ссылок обнулится.

    BeautifulSoup ОЧЕНЬ основательный тормоз. Может лучше поиск ссылок через регулярки сделать? Что-нить типа <(a|link)[^>]*?href="(?P[^"]*?).

  9. Иван Сагалаев

    BeautifulSoup ОЧЕНЬ основательный тормоз. Может лучше поиск ссылок через регулярки сделать?

    Ох... Вот это классический пример переоптимизации. Есть некая "общепринятая точка зрения", что BeautifulSoup — медленный. Поэтому что, давайте теперь изничтожать его изо всех сил?

    Посмотрите на код. В данном конкретном месте парсинг вызывается ровно один раз. При этом тот же процесс делает запрос в БД, делает обратный парсинг urlconf'ов, а также (примечая слона в лавке) вытягивает 500-килобайтные куски по HTTP на каждую ссылку. И каким будет выигрыш от ускорения парсинга? Подозреваю, что незаметным.

    Зато проблемы от наколеночного парсинга HTML регулряками, когда то лишнее что-то попадет, то нужное порежется, обычно бывают куда более реальными.

  10. koder

    Есть некая “общепринятая точка зрения”, что
    BeautifulSoup — медленный

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

    К тому-же код без BeautifulSoup будет меньше
    по размеру )).

    for i in re.iterfind(expr,html):
        url = i.group('Url')
        .....
    

    Зато проблемы от наколеночного парсинга HTML
    регулряками, когда то лишнее что-то попадет,
    то нужное порежется, обычно бывают куда более
    реальными.

    Это возможно, но конкретно эта приведенная регулярка мне служит "верой и правдой" уже года два.

  11. mojo

    привет.
    а search_link найдет "многострочный" элемент?

    <link
       rel="pingback"
       href="blah"
    />
    
  12. Иван Сагалаев

    Не найдет. Но это намеренно :-).

    Спецификация pingback'а диктует точный досимвольный вид этой ссылки (точнее, два точных варианта). То есть это более жесткое требование, чем у HTML или XHTML. Этим как раз достигается то, что формат во-первых своместим с (X)HTML, а во-вторых не требует от реализатора использовать полный HTML-парсер, если он не хочет.

    http://www.hixie.ch/specs/pingback/pingback#TOC2.2 (там в конце главки, зеленым текстом).

  13. Vladimir Rusinov

    Честно говоря, не понимаю, зачем оно вообще нужно?

  14. Maximbo

    Честно говоря, не понимаю, зачем оно вообще нужно?

    На данный момент оно нужно для связи блогосферы и форумосферы. Pingback — это клей для кусков информации в WWW. А вообще, Иван прямо призывает встраивать сию полезную интерфейсную фитчу и в другие системы ;)

  15. Maximbo

    Как-то слишком жёстко pingback приклеен к форуму. Может быть, лучше выделить его в отдельный модуль, используя для связки систему сигналов Django (как подметил dobrych) ?

    Кстати, как вообще будет реализована система подключаемых расширений?

    Я это делаю через файл signals.py в корне приложения, в котором объявляется owner (приложение) и названия сигналов, через signal_name = object()

    В результате приложению, использующему ресурсы другого, достаточно импортировать нужные сигналы и имя их отправителя.

    Тот же signals.py могут использовать и части самого app, для упрощения логики взаимодействия.

  16. Иван Сагалаев

    Я просто не думал, что такой простой код стоит как-то отдельно оформлять. Если его выдирать отдельно, придется много переделывать. Например непонятно, как понимать, что такое "внешняя ссылка". Тут-то я в курсе, что все это относится к приложению форума, а если pingback будет отдельным, то ему надо как-то это сообщать. Настроек будет больше, чем кода :-)

    Кстати, как вообще будет реализована система подключаемых расширений?

    Я ничего такого не планировал :-). Я пишу маленький форум такой, как мне нравится. Методика его расширения — скачать код и поправить :-)

  17. Michael Samoylov
  18. Иван Сагалаев

    Кстати, Михаил, я смотрел в нее перед тем, как свою делать. Не взял, правда, но подтвердил несколько догадок, спасибо :-)

  19. [...] Маниакальный Веблог » Реализация Pingback-клиента Pingback — вещь, и он заслуживает того, чтобы быть распространенным протоколом межсайтового уведомления, а не прозябать, как сейчас, реализова (tags: pingback python sagalaev) [...]

  20. Jungle

    вот ещё одна реализация пинга http://www.imalm.com/blog/2007/feb/10/using-django-signals-ping-sites-update/

  21. Иван Сагалаев

    Только это не pingback :-). Это нотификация поисковиков/агрегаторов, а не других блогов о ссылках на них.

Добавить комментарий