На чем палятся сайты, добавленные в SAPE

11 октября 2011

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

***

Решать эту задачу можно разными способами. Например, на сёрче в свое время проскакивало мнение, что поисковые системы анализируют IP и данные Whois сайтов. Если много сайтов находятся на одном IP и зарегистрированы на одного человека, предполагается, что эти сайты образуют сетку и все они пессимизируются. Сложновато, вам не кажется? Будучи лентяем, я решил попробовать алгоритм попроще.

Если вы залезете в код сапы, отвечающий за размещение ссылок на сайте, то обнаружите, что по умолчанию адрес текущей страницы определяется по REQUEST_URI. Это означает, что страницы с адресами page.php?a=123&b=456 и page.php?a=123&b=456&sdfsdf=789 считаются разными. Хотя большинство современных CMS скорее всего проигнорируют аргумент со странным именем sdfsdf и выдадут две одинаковые страницы. Вы уже чувствуете, в чем весь цимес?

Пусть есть некий сайт. Для определенности — на движке WordPress. Пусть он продает пару ссылок со страницы example.ru/bebebe/. Чтобы понять, что часть ссылок действительно проплачены, правим url в адресной строке на что-то вроде example.ru/bebebe/?ksjdhfk и жмем Enter. Если получаем ту же самую страницу, но часть ссылок (в сайдбаре или подвале) пропала, значит сайт добавлен в сапу!

***

Чтобы проверить, как много сайтов палятся описанным образом, я решил написать скрипт, делающий автоматическую проверку на наличие продажных ссылок, и прогнать через него список сайтов, гарантированно находящихся в сапе. Получить такой список несложно. Заходим в сапу, делаем поиск площадок, сохраняем полученную html страничку и выдираем из нее доменные имена:

cat ~/sape-domains.html |\
  perl -lne '@d = $_ =~ /sid="\d+">([^<]+)/gi; print join "\n", @d;' |\
  sort -u > ~/sape-domains.txt

Для подсчета ссылок на страницах сайтов я воспользовался перловым модулем HTML::LinkExtractor. Чтобы сократить число ложных срабатываний (например, на mail.ru), запрашивать страницы с абракадаброй пришлось два раза. Просто, чтобы удостовериться, что на сайте нет вывода случайных ссылок. В результате получился такой скрипт:

#!/usr/bin/env perl

# grep-sape.pl
# (c) Alexandr A Alexeev 2011 | http://eax.me/

use strict;
use HTML::LinkExtractor;
use LWP::Simple;

while(chomp(my $site = <>)) {
  eval {
    my $linksNum  = getLinksNum("http://$site/");
    my $linksNum2 = getLinksNum("http://$site/?rafoojutetiuhal");
    my $linksNum3 = getLinksNum("http://$site/?shoofitububakoe");

    my $sape = ($linksNum > $linksNum2) &&
               ($linksNum2 == $linksNum3);
    print "$site:OK:$linksNum:$linksNum2:$linksNum3:".
          ($sape ? "SAPE" : "NOSAPE")."\n";
  };

  if($@) {
    print "$site:ERROR:$@\n";
  }

}

sub getLinksNum {
  my ($url) = shift;

  my $content = get($url);
  die "Failed to download '$url'" unless defined $content;

  my $extractor = HTML::LinkExtractor->new();
  $extractor->parse(\$content);

  return scalar @{ $extractor->links() };
}

На данном этапе я решил ограничится проверкой лишь главных страниц. Чтобы прогнать сайты побыстрее, я воспользовался скриптом fork.pl из заметки Как написать своего паука:

cat sape-domains.txt | rl | ./fork.pl ./grep-sape.pl 16 | \
  tee grep-sape-rslt.txt

У меня 1133 сайтов были определены, как добавленные в сапу, и 641 — как не добавленные. Таким образом, скрипт сработал в 64% случаев. Совсем неплохо, учитывая топорность метода. Правда, остается открытым вопрос о ложных срабатываниях, но об этом — чуть ниже.

***

А насколько возрастет точность скрипта, если проверять страницы УВ2 (в терминологии сапы)? Проверять все страницы УВ2 — слишком долго, так что ограничимся лишь десятком страниц, выбранных случайным образом. Соответствующий скрипт:

#!/usr/bin/env perl

# grep-sape-l2.pl
# (c) Alexandr A Alexeev 2011 | http://eax.me/

use strict;
use List::MoreUtils qw/uniq/;
use List::Util qw/shuffle/;
use HTML::LinkExtractor;
use LWP::UserAgent;
use Data::Random;

while(chomp(my $site = <>)) {
  my $sape = 0;
  eval {
    my $internalLinks = getInternalLinks($site);
    my @checkUrls = ("http://$site/");
    push @checkUrls, grep {$_ ne ""}(shuffle @{$internalLinks})[0..9];

    for my $url(@checkUrls) {
      my $linksNum = getLinksNum($url);
      my ($linksNum2, $linksNum3) = (0, 0);
      eval {
        $linksNum2 = getLinksNum(genCheckUrl($url));
        $linksNum3 = getLinksNum(genCheckUrl($url));
      };

      if($@) {
        # страницы с неверными url вернули 404 - считаем, что sape нет
        next;
      }

      $sape |= ($linksNum > $linksNum2) &&
               ($linksNum2 == $linksNum3);
      last if($sape);
    }
  };

  if($@) {
    print "$site:ERROR:$@\n";
  } else {
    print "$site:OK:".($sape ? "SAPE" : "NOSAPE")."\n";
  }

}

sub genCheckUrl {
  my ($url) = shift;
  my $splitChar = ($url =~ m#\?#) ? "&" : "?";
  my $rand = join "", Data::Random::rand_words( size => 3);
  return $url.$splitChar.$rand;
}

sub getLinksNum {
  my ($url) = shift;
  my $content = httpGet($url);
  my $extractor = HTML::LinkExtractor->new();
  $extractor->parse(\$content);

  my $t = $extractor->links();

  return scalar grep { $_->{tag} eq "a" }
    @{ $extractor->links() };
}

sub getInternalLinks {
  my $site = shift;
  return (0, []) unless($site =~ m#^[a-z0-9\-\.]+$#i);
  my $content = httpGet("http://$site/");

  my $extractor = HTML::LinkExtractor->new();
  $extractor->parse(\$content);

  my @allLinks = map { $_->{href} }
                 grep { $_->{tag} eq "a" }
    @{ $extractor->links() };
  my @internalLinks = uniq grep {
      # ограничение на ascii в адресах для того, чтобы
      # не возиться с urlencode
      m#^https?://(?:www\.)?$site/[a-z0-9_/=\%\+\-\.\?\&]+$#i
    } @allLinks;
  return \@internalLinks;
}

sub httpGet {
  my ($url) = shift;
  my $ua = LWP::UserAgent->new(
             timeout => 10,
             max_size => 1024*512);
  my $res = $ua->get($url);
  die $res->status_line unless($res->is_success);
  return $res->decoded_content;
}

У меня точность составила 77%. Также я не поленился сделать проверку всех страниц УВ2. Скрипт стал в 3 раза медленнее и на 1.5% точнее. На мой взгляд, оно того не стоит, так что я решил остановиться на 77%.

Теперь к вопросу о ложных срабатываниях. Немного подумав, я вручную составил список из 79 сайтов, на которых почти наверняка нет сапы. В список вошли сайты типа rutracker.org, ozon.ru, pleer.ru и тп. Сапы на них почти наверняка нет, потому что эти сайты приносят прибыль за счет рекламы или продажи реальных товаров.

В итоге я получил 14% ложных срабатываний. Что-то многовато. Проверив результат вручную, я выяснил, что не все срабатывания оказались ложными. Например, на kp.ru действительно есть сапа:

Код sape на сайте kp.ru

В итоге, реальных ложных срабатываний оказалось 10%, что вполне приемлемо. Значит, скрипт показывает правильный результат примерно в 77%*0.9 = 70% случаев. Интересно, что в биржах GoGetLinks и Miralinks на данный момент в сумме находятся 20 000 сайтов, в то время, как через SAPE можно купить ссылки на 500 000 сайтов. Значит, если только я ничего не путаю, полученный скрипт применим для 96% сайтов, продающих ссылки. И это всего лишь при проверке десятка страниц.

Кстати, чтобы сайт не палился, достаточно всего лишь дописать в код сапы одну строчку. Например, если на сайте используется WordPress с включенными ЧПУ, код должен быть следующим:

    $o['charset'] = 'UTF-8';
    $o['request_uri'] = preg_replace(
      "/\?(.*)$/",'',$_SERVER['REQUEST_URI']
    );
    $sape = new SAPE_client($o);
    unset($o);

Но на практике, конечно, редкий сеошник беспокоится по поводу таких глупостей.

***

Я решил не останавливаться на достигнутом и попытался собрать список сателлитов, не скрывающих свой адрес. Через официальный API системы этого сделать нельзя, так что пришлось ковырять интерфейс, рассчитанный на людей. Ковыряется он довольно просто, так что просто приведу получившийся скрипт:

#!/usr/bin/env perl

# sape-parser.pl v 0.1
# (c) Alexandr A Alexeev 2011 | http://eax.me/

use strict;
use 5.10.0;

my %sapeQuery = (
  act => "s_order",
  show_mode => "0",
  filter_mode => "1",
  s_words => "",
  order => "",
  anchor_order => "",
  s_pages_per_site => "one",
  s_level_from => "1",
  s_level_2 => "3",
  s_price_from => "0",
  s_price_2 => "0",
  filter => "839037", # !!!
  link_id => "25487404", # !!!
  s_nogood => "0",
  s_double => "",

  s_pr_from => "0", # минимальный PR
  s_pr_2 => "0", # максимальный PR
  s_in_dmoz => "2", # находится ли в DMOZ, 2 - все равно

  s_cy_from => "0", # минимальный тИЦ
  s_cy_2 => "0", # максимальный тИЦ
  s_in_yaca => "2", # находится ли в ЯК, 2 - все равно
  s_domain_level => "0", # домены какого уровня нас интересуют
                         # если 0, значит все
  s_ext_links => "0",
  s_ext_links_forecast => "0",
  s_page_id => "",
  s_site_id => "",
  s_date_added => "0", # дата добавления
  s_flag_only_white_list => "",
  s_no_double_in_project => "",

  # находится ли сайт в индексе ПС, 2 - не важно
  s_flag_blocked_in_yandex => "2",
  s_flag_blocked_in_google => "2",

  s_self => "",
  s_only_open_url => "",
  s_words_type => "0",
  s_words_proximity => "3",
  s_top_words => "",
  s_top_words_se => "0",
  ajax_act => "get_fast_result", # do not change!
);

my $curlCmd = "curl -s --cookie cookies.dump -c cookies.dump";

sub hashToQuery {
  my $hash = shift;
  my $query = join " ",
              map { "-F '$_=$hash->{$_}'" }
              keys %{$hash};
  return $query;
}

sub sapeSearchQuery {
  my ($pn, $ps) = @_;
  my $queryBegin = hashToQuery(\%sapeQuery)." ";
  my $taskQuery = $queryBegin .
                  hashToQuery({pn => $pn, ps => $ps});
  my $searchQuery = $queryBegin .
                    hashToQuery({pn => $pn, ps => $ps,
                                 last_act => "true"});

  # запрашиваем данные
  my $data =`$curlCmd $searchQuery http://www.sape.ru/ajax_orders.php`;
  die "ERROR: curl returns $? (data)" if($?);
  return $data if($data !~ /^NO_CACHE/);

  # если не получили - добавляем задачу
  my $task = `$curlCmd $taskQuery 'http://www.sape.ru/ajax_task.php?act=add&task=search'`;
  die "ERROR: curl returns $? (task)" if($?);

  if($task !~ m#^{"error":0,"done":0#) {
    die "ERROR: unexpected responce (task): $task"
  }

  # ждем завершения задачи
  while(1) {
    warn "Waiting 2 seconds...\n";
    sleep(2);
    my $status = `$curlCmd 'http://www.sape.ru/ajax_task.php?act=status&task=search'`;
    die "ERROR: curl returns $? (status)" if($?);

    last if($status =~ m#^{"error":0,"done":1#);
    next if($status =~ m#^{"error":0,"done":0#);
    die "ERROR: unexpected responce (status): $status";
  }

  $data = `$curlCmd $searchQuery http://www.sape.ru/ajax_orders.php`;
  die "ERROR: curl returns $? (data)" if($?);
  if($data =~ /^NO_CACHE/) {
    die "ERROR: cached data expected; data:\n$data"
  }
  return $data;
}

for my $pn(1..200) {
  warn "### pn = $pn\n";
  my $data = sapeSearchQuery($pn, 0);
  my @sites = $data =~ m#class="a_visited">.*?<a\s+href="https?://(?:www\.)?([^/]+)#gi;
  warn "### scalar(\@sites) == ".scalar(@sites)."\n";
  print "$_\n" for(@sites);
}

Перед запуском нужно подправить хэш sapeQuery и получить от сапы печеньку:

curl -c cookies.dump \
  -F username=USER -F password=PASS https://auth.sape.ru/login/

Да, код очень кривой. И возможно, где-то в нем ошибка, потому что мне удалось выдрать лишь ~4000 сайтов. Но доводить до ума лень, так что выкладываю, как есть.

***

К моему удивлению, такое вот ковыряние сайтов оказалось довольно увлекательным занятием. Например, мне удалось обнаружить чью-то сетку сателлитов, построенную на собственном движке. Идея такая — заказываем несложный уникальный шаблон, прикручиваем его к своему движку, заполняем отсканированными материалами и сайт готов! При таком количестве сайтов (около 3000), наращивание пузомерок не должно быть проблемой.

Еще я взял список делегированных доменов в зоне ru (см пункт 9), перемешал их с помощью утилиты rl и оставил их прогоняться через мой скрипт. Через какое-то время было получено такое распределение сателлитов по регистраторам доменных имен:

       'DOMENUS-REG-RIPN' => 76,
       'NAUNET-REG-RIPN' => 2105,
       'REGRU-REG-RIPN' => 8534,
       'RU-CENTER-REG-RIPN' => 4738,
       'CARAVAN-REG-RIPN' => 3,
       'CENTROHOST-REG-RIPN' => 67,
       'AGAVA-REG-RIPN' => 97,
       'REGISTRATOR-REG-RIPN' => 629,
       'DEMOS-REG-RIPN' => 2,
       'NETFOX-REG-RIPN' => 109,
       'REGTIME-REG-RIPN' => 2035,
       'REGISTRANT-REG-RIPN' => 172,
       'BEELINE-REG-RIPN' => 5,
       '101DOMAIN-REG-RIPN' => 3,
       'R01-REG-RIPN' => 6965,
       'REGGI-REG-RIPN' => 149

То же распределение в процентах от числа проверенных доменов у заданного регистратора:

        TCI-REG-RIPN 0.00%
     NAUNET-REG-RIPN 6.61%
     RELCOM-REG-RIPN 0.00%
  RU-CENTER-REG-RIPN 3.32%
REGISTRATOR-REG-RIPN 2.97%
      DEMOS-REG-RIPN 6.25%
    BEELINE-REG-RIPN 1.15%
         CC-REG-RIPN 0.00%
  101DOMAIN-REG-RIPN 0.32%
        R01-REG-RIPN 6.33%
      ELVIS-REG-RIPN 0.00%
    DOMENUS-REG-RIPN 1.91%
      REGRU-REG-RIPN 5.09%
 CENTROHOST-REG-RIPN 3.64%
    CARAVAN-REG-RIPN 1.15%
      AGAVA-REG-RIPN 1.48%
     NETFOX-REG-RIPN 3.36%
        CCZ-REG-RIPN 0.00%
    REGTIME-REG-RIPN 4.05%
 REGISTRANT-REG-RIPN 2.30%
  SALENAMES-REG-RIPN 0.00%
     RTCOMM-REG-RIPN 0.00%
      REGGI-REG-RIPN 1.51%

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

Метки: , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.