FB3-Convert/lib/FB3/Euristica.pm
package FB3::Euristica;
use strict;
use Data::Dumper;
use utf8;
use WWW::Mechanize::PhantomJS;
use FB3::Convert;
use File::Copy;
sub new {
my $class = shift;
my $X = {};
my %Args = @_;
$X->{'verbose'} = $Args{'verbose'};
$X->{'ContentDir'} = $Args{'ContentDir'};
$X->{'SourceDir'} = $Args{'SourceDir'};
$X->{'DestinationDir'} = $Args{'DestinationDir'};
$X->{'DebugPath'} = $Args{'DebugPath'} || undef;
$X->{'DebugPrefix'} = $Args{'DebugPrefix'} || undef;
$X->{'unzipped'} = $Args{'unzipped'} || undef;
if ($X->{'DebugPath'}) {
mkdir $X->{'DebugPath'} or FB3::Convert::Error($X, $X->{'DebugPath'}." : $!") unless -d $X->{'DebugPath'};
FB3::Convert::Msg($X, "Create euristica debug at $X->{'DebugPath'}\n");
}
my $PHJS_bin = $Args{'phjs'} || undef;
my $mech = WWW::Mechanize::PhantomJS->new('launch_exe'=>$PHJS_bin);
$X->{'MECH'} = $mech;
return bless $X, $class;
}
sub CalculateLinks {
my $X = shift;
my %Args = @_;
my $Files = $Args{'files'} || [];
my $PHJS = $X->{'MECH'};
my %Links;
foreach my $File (@$Files) {
$PHJS->get_local($File) or FB3::Convert::Error($X, "Can't open file for phantomjs ".$File." : ".$!);
my $Links = $PHJS->eval_in_page(<<'Links', 'Foobar/1.0');
(function(arguments){
var TEXT_NODE = 3;
var nodesBody = Array.prototype.slice.call(document.body.childNodes);
var Ret = Array();
NodeProc(nodesBody);
function NodeProc(nodes) {
nodes.forEach(function(node) {
if (node.nodeType == TEXT_NODE) return;
var childs = Array.prototype.slice.call(node.childNodes);
NodeProc(childs);
if (node.nodeName.toLowerCase() == 'a' && node.href.match(/^file:\/\//)) {
Ret.push(node.href);
}
});
}
return Ret;
})(arguments);
Links
foreach my $Link ( @$Links ) {
$Link =~ s/^file:\/\///;
my ($LinkFile,$Anchor) = split /\#/, $Link;
$Links{$LinkFile}->{$Anchor} = 1 if $Anchor;
}
}
$X->{'LocalLinks'} = \%Links;
return \%Links;
}
sub ParseFile {
my $X = shift;
my %Args = @_;
FB3::Convert::Error($X, "Can't find file for parse or not defined 'file' param ".$Args{'file'}) if !defined $Args{'file'} || !-f $Args{'file'};
$X->{'SrcFile'} = $Args{'file'};
my $PHJS = $X->{'MECH'};
$PHJS->get_local($Args{'file'}) or FB3::Convert::Error($X, "Can't open file for phantomjs : ".$Args{'file'});
$X->{'ContentBefore'} = $PHJS->content( format => 'html' );
if ($X->{'ContentBefore'} =~ /<parsererror/ && $X->{'ContentBefore'} =~ /following errors:/) {
my $Err = $X->{'ContentBefore'};
$Err =~ s/.*(<parsererror.*<\/parsererror>).*/$1/g;
FB3::Convert::Error($X, "Can't parse string. skip. : ".$Err );
}
my $Debug = $PHJS->eval_in_page(<<'JS', "Foobar/1.0", $X->{'LocalLinks'});
(function(arguments){
// Node types
var ELEMENT_NODE = 1;
var TEXT_NODE = 3;
var LocalLinks = arguments[1] || {};
var FILENAME = window.location.pathname;
var RET = Array();
//настройки
var IsShortLength = 100; // меньше скольки символов текст считать коротким
var TooMuchFontSize = 4; // на сколько px нужно быть увеличенным текстом от document.body, чтобы тебя посчитали "большим" (см. BALLS_FOR_BIG_FONT)
var TooMuchMargin = 6; // на сколько px нужно иметь отступ, чтобы тебя посчитали "отбитым текстом"
var TooMuchBR = 1; // сумма <br>, считаемая отбивкой
var BALLS_FOR_TITLE = 10; // сколько баллов набрать, чтобы определить как title
//кол-во баллов за события
var BALLS_LINK = 3; // на кандидата ссылаются из любого файла в книге
var BALLS_FOR_SHORT = 3; // текст в кандидате короткий
var BALLS_FOR_MAGICK_WORDS = 7; // за магические слова
var BALLS_FOR_CENTER = 3; // за центрирование текста
var BALLS_FOR_BIG_FONT = 3; // за увеличенный текст (см. TooMuchFontSize)
var BALLS_FOR_MARGIN = 3; // за отступы
var BlockLevel = {
'address':1,
'article':1,
'aside':1,
'blockquote':1,
'canvas':1,
'dd':1,
'div':1,
'dl':1,
'dt':1,
'fieldset':1,
'figcaption':1,
'figure':1,
'footer':1,
'form':1,
'header':1,
//'hr', //бесполезно
'main':1,
'nav':1,
'noscript':1,
'ol':1,
'ul':1,
'li':1,
'output':1,
'p':1,
'pre':1,
'section':1,
'table':1,
'tfoot':1,
//'video', //слишком
//↓↓↓ формально не блок-левел ↓↓↓
'th':1,
'tr':1,
'td':1
};
String.prototype.HaveIn = function(words) {
var str = this;
str = str.trim();
str = str.replace(/[\s\n\r]+/g,' ');
var sp = str.split(' ');
var H = {}; words.forEach(function(v){H[v]=1;});
var find = 0;
ret = sp.forEach( function(v){ if (v in H) {find=1;return;}} );
return find;
};
String.prototype.digCut = function() { //откусывает буквы, оставляя число
return parseInt(this.replace(new RegExp('/[^\d]+/', 'g'), ''));
};
Array.prototype.digMax = function() { //возвращает максимальное число в массиве
var Max = 0;
this.forEach(function(v){if(parseInt(v)>Max) Max = parseInt(v);});
return Max;
};
Element.prototype.setTagName=function(strTN) {
if (this.tagName.toUpperCase() == strTN.toUpperCase()) return;
var newNode = document.createElement(strTN);
newNode.innerHTML = this.innerHTML;
for (var i = 0, atts = this.attributes, n = atts.length; i < n; i++){
newNode.setAttribute(atts[i].nodeName, atts[i].nodeValue);
}
this.parentElement.replaceChild(newNode,this);
}
var FirstTextLength = 0;
FindCandidateNode = 0;
var Balls = 3; //по умолчанию баллы за то, что кандидат вверху (может быть сброшен в контексте анализа)
//бежим по нодам в body
nodes = Array.prototype.slice.call(document.body.childNodes);
var BreakException = {};
try {
nodes.forEach(
function(currNode, currIndex) {
var NodeName = currNode.nodeName.toLowerCase();
var Changed = 0;
if (currNode.nodeType == TEXT_NODE) { //Текстовая нода
if (!FindCandidateNode) FirstTextLength += currNode.nodeValue.trim().length;
if (FirstTextLength > 3) Balls = 0; //в начале какой-то значимый "голый текст", +3 в баллы не светит (<=3 символа будем считать мусором)
RET.push('[INDEX: ' + currIndex + ']' + currNode.nodeValue.trim() + "[FirstTextLength:"+ FirstTextLength +"]");
} else { // Видимо element-нода
Calc = ParseCandidate(currNode);
var Cand = Calc['CALC'];
if (!FindCandidateNode && Cand['TextLength'] >= 0 && Cand['TextLength'] <= 3) {
FirstTextLength += Cand['TextLength']; //какой-то малозначимый текст в блоке, будем считать, что это мусор в начале
} else if (Cand['TextLength'] > 3) {
//наткнулись на ноду с текстом, хватит перебирать "голый текст" в начале
FindCandidateNode++; //
if (currNode.id) {
var ID = currNode.id;
if (ID != null && LocalLinks[FILENAME] != null && ID in LocalLinks[FILENAME]) {
Calc['BALLS'] += BALLS_LINK;
}
}
if (
!(NodeName in BlockLevel) || // (!!!) подумать - нужно оно, или мы все элемент-ноды считаем
NodeName.match(/^h\d+$/) // <h*> надо пропустить
) return;
//смотрим, что насчитали по текущему блоку
Calc['BALLS'] += Balls; //Сложим баллы по ноде
Balls = 0; //следующему кандидату баллы за начало страницы уже не достанутся
if (Calc['BALLS'] >= BALLS_FOR_TITLE) { // По всей видимости детектировали заголовок
currNode.setTagName("h6");
Changed = 1;
}
}
RET.push(
{
"DEBUG": '[INDEX:' + currIndex + ']' + currNode.outerHTML + '[DUMP:' + JSON.stringify(Calc) + ']',
"BALLS": Calc['BALLS'],
"CHANGED": Changed
}
);
if (FindCandidateNode>=2) throw BreakException; //хватит перебирать
}
}
);
} catch (e) {
if (e !== BreakException) throw e;
}
return RET;
function ParseCandidate(candidate) {
Calc = CalcNodeRecursive(candidate); //калькуляция текущей ноды
TopNext = TopNextNodes(candidate); //калькуляция ближайших нод снизу, влияющих на отступ
//подсчет баллов для установления вероятности заголовка
var Cballs = 0;
if (Calc['TextLength'] > 0 && Calc['TextLength'] < IsShortLength) Cballs += BALLS_FOR_SHORT;
if (Calc['MagicWords'] > 0) Cballs += BALLS_FOR_MAGICK_WORDS;
if (Calc['HaveCenter'] > 0) Cballs += BALLS_FOR_CENTER;
//увеличенный шрифт?
var BodyStyle = simpleStyles(document.body);
if ('font-size' in BodyStyle) { //вообще так не бывает
var MainFsize = BodyStyle['font-size'].digCut();
if (Calc['TextSize'].digMax() - MainFsize >= TooMuchFontSize) Cballs += BALLS_FOR_BIG_FONT;
}
//отбивка?
var BRSumm=0;
var MarginSumm = 0;
if (Calc['HaveBRbottom'] > 0) {
BRSumm += Calc['HaveBRbottom']; //нашли отбивку br по низу
}
//стили отбивки по низу
//по умолчанию в блоках маргин по величине шрифта - это норма и выставляется в DOM автоматически, если не указаны стили
// в <span> и пр - Top|Bottom - всегда нули, не зависимо от style='margin|padding'
if ( Calc['Bottom'].digMax() > Calc['TextSize'].digMax() ) {
MarginSumm += (Calc['Bottom'].digMax() - Calc['TextSize'].digMax());
}
//смотрим соседей снизу
TopNext['DATA'].forEach(
function(v) {
if ( v['HaveBRtop'] > 0 ) BRSumm += v['HaveBRtop']; //нашли отбивку br соседа по верху
if ( v['Top'].digMax() > v['TextSize'].digMax() ) { //стили отбивки нижнего соседа по верху
MarginSumm += (v['Top'].digMax() - v['TextSize'].digMax());
}
}
);
if (BRSumm >= TooMuchBR || MarginSumm >= TooMuchMargin) Cballs += BALLS_FOR_MARGIN; //нашли достаточную отбивку
return {
'CALC': Calc,
'NEXT': TopNext,
'BALLS': Cballs
};
}
function CalcNodeRecursive(node) {
var DBG;
var nodes = Array.prototype.slice.call(node.childNodes,0);
if (!nodes.length) nodes = [node];
//DBG = nodes.length;
var TL = 0; //длина текста
var MW = 0; //наличие волшебных слов
var TS = Array(); //размеры текста в ноде
var MB = Array(); //отступы снизу margin|padding в ноде
var MT = Array(); //отступы вверху margin|padding в ноде
var BRB = 0; //имеет <br/> в отбивку снизу
var BRT = 0; //имеет <br/> в отбивку сверху
var CNT = 0; //имеет текст, выровненный по цетру относительно родительского блок-левел элемента
nodes.forEach(
function(currNode, currIndex) {
if (!currNode) return;
if (currNode.nodeType == TEXT_NODE) {
val = currNode.nodeValue;
TL += val.trim().length;
if ( val.toLowerCase().HaveIn(Array('глава','часть','chapter','part')) ) {
MW += 1;
}
var styles = simpleStyles(currNode.parentNode);
var block;
var PN = currNode.parentNode.nodeName.toLowerCase();
if (PN in BlockLevel) block = 1; //для остальных нет эффекта отступов
if (block && 'text-align' in styles && styles['text-align'].toLowerCase() == 'center') CNT = 1;
if (styles['font-size']) TS.push(styles['font-size'].digCut());
var bl = 0;
if (styles['margin-bottom'] && block) bl += styles['margin-bottom'].digCut();
if (styles['padding-bottom'] && block) bl += styles['padding-bottom'].digCut();
MB.push(bl);
var tl = 0;
if (styles['margin-top'] && block) tl += styles['margin-top'].digCut();
if (styles['padding-top'] && block) tl += styles['padding-top'].digCut();
MT.push(tl);
if (val.trim().length > 0) BRB = 0; //это уже не отбивка <br> снизу, а межстрочный (голый текст за br)
} else if (currNode.nodeType == ELEMENT_NODE) {
var r = {};
if (currNode.childNodes
&& currNode.innerHTML.trim() != '' // почему-то <tag></tag> повергает в бесконечную рекурсию, будто у него есть чайлдноды
) {
r = CalcNodeRecursive(currNode);
}
if (currNode.nodeName.toLowerCase() == 'br') {
BRB++;
if (!( TL > 0 || ("TextLength" in r && r['TextLength']==0) )) BRT++; //br вверху, перед ним нет голого текста или ноды с текстом
} else if ("TextLength" in r && r['TextLength'] > 0) {
BRB = 0; //и это тоже не отбивка снизу (нода за br, имеющая текст)
}
if ("HaveCenter" in r) MW += r['HaveCenter'];
if ("MagicWords" in r) MW += r['MagicWords'];
// push - избыточно, но полезно для отладки
if ("TextLength" in r) {
TL += r['TextLength'];
for (var i=0;i<r['TextSize'].length;i++) {
TS.push(r['TextSize'][i]);
}
}
if ("Bottom" in r) {
for (var i=0;i<r['Bottom'].length;i++) {
MB.push(r['Bottom'][i]);
}
}
if ("Top" in r) {
for (var i=0;i<r['Top'].length;i++) {
MT.push(r['Top'][i]);
}
}
}
}
);
return {
'TextLength': TL,
'MagicWords': MW,
'TextSize': TS,
'Bottom': MB,
'Top': MT,
'HaveBRbottom': BRB,
'HaveBRtop': BRT,
'HaveCenter': CNT,
'DEBUG': DBG
}
}
function TopNextNodes(node) {
var el = node.nextSibling;
var DATA = Array();
var i = 1;
while (el) {
var Calculate = CalcNodeRecursive(el);
DATA.push(Calculate);
if (Calculate['TextLength'] > 0 ) break; //встретили текст, хватит месить
el = el.nextSibling;
i++;
}
return {'DATA': DATA};
}
function simpleStyles(node) {
var style = window.getComputedStyle(node);
var styleMap = {};
for (var i = 0; i < style.length; i++) {
var prop = style[i];
var value = style.getPropertyValue(prop);
styleMap[prop] = value;
}
return styleMap;
}
})(arguments);
JS
my $Changed = 0;
foreach (@$Debug) {
$Changed = 1 if ref $_ eq 'HASH' && $_->{'CHANGED'};
}
my $CONTENT = $PHJS->content( format => 'html' );
#Дебаг измененных эвристикой файлов
if ($X->{'DebugPath'}
##&& $Changed #всех или измененные
) {
my $SrcFile = $X->{'SrcFile'};
my $FND = $SrcFile;
$FND =~ s/.*?([^\/]+)$/$1/g;
$FND = ($X->{'DebugPrefix'} ? "[".$X->{'DebugPrefix'}."]_" : "").$FND;
#исходник
File::Copy::copy($SrcFile, $X->{'DebugPath'}.'/'.$FND.'.src') or FB3::Convert::Error($X, "Can't copy file $SrcFile : $!");
#после прочитки в DOM
open my $Fb,">:utf8",$X->{'DebugPath'}.'/'.$FND.".before";
print $Fb $X->{'ContentBefore'};
close $Fb;
#после изменения + debug
open my $Fc,">:utf8",$X->{'DebugPath'}.'/'.$FND.".debug";
print $Fc "DEBUG\n";
print $Fc Data::Dumper::Dumper($Debug);
print $Fc "\n============\n";
print $Fc "RESULT\n";
print $Fc $CONTENT;
close $Fc;
}
#//Дебаг измененных эвристикой файлов
return {
'CONTENT' => $CONTENT,
'DBG' => $Debug,
'CHANGED' => $Changed,
}
}
1;