[plain text] [download]
  1. <?php
  2. /**
  3. * ReFilter: a dodgy RSS filter built with dodgy regular expressions.
  4. *
  5. * It processes a string of filters such as
  6. *       
  7. *       (in:link:slashdot AND -has:enclosure) OR "taco singing"
  8. *
  9. * Which will filter out all RSS items except those which
  10. *  1. have the text "slashdot" in the <link> element
  11. *  AND
  12. *  2. do not have an <enclosure> element
  13. *  OR
  14. *  3. have any element containing the text "taco singing".
  15. *
  16. * See documentation at http://re.rephrase.net/filter/ for more
  17. * information.
  18. *
  19. * LICENSE
  20. * Do whatever, just keep attribution and tell me if you
  21. * use it for anything cool. (That seems unlikely, though,
  22. * since it's messy as all hell and hardly efficient. :)
  23. * Contact me if you need it under a real license.
  24. *
  25. * ReFilter
  26. * version 0.82, 2007-03-10
  27. * copyright 2005 Sam Angove <sam@rephrase.net>
  28. */
  29.  
  30. class ReFilter {
  31.         var $_filters;
  32.         var $_filterstring;
  33.         var $_item_filters;
  34.         var $_logic;
  35.         var $_quoted;
  36.         var $_seq = array();
  37.        
  38.         function ReFilter($filterstring = '', $rss = '') {
  39.                
  40.                 if ($filterstring) {
  41.                         $this->set_filter($filterstring);
  42.                         if ($rss) return $this->filter_rss($rss);
  43.                 }
  44.         }
  45.        
  46.         function set_filter($filterstring) {
  47.                 $this->_filterstring = $filterstring;
  48.                 $this->_process_filter_string($this->_filterstring);   
  49.         }
  50.        
  51.         /*
  52.         * Filtering functions. Traverse a feed and remove items matching or not matching
  53.         * a set of filters.
  54.         */
  55.         function filter_rss($xml) {
  56.                 if (count($this->_filters)) {
  57.                         $xml = preg_replace_callback("#<(item|entry)( .*?)?>(.*?)</\\1>#si", array($this, '_filter_item'), $xml);
  58.                         $title = htmlentities($this->_filterstring);
  59.                         $xml = preg_replace("#<title>(.*?)</title>#", "<title>$1 | Filtered: $title</title>", $xml, 1);
  60.                        
  61.                         // For RSS 0.9whatever feeds that have an <rdf:Seq>, remove
  62.                         // items from it as well.
  63.                         if (count($this->_seq) && stristr($xml, 'rdf:Seq')) {
  64.                                 foreach ($this->_seq as $li) {
  65.                                         $xml = str_replace("<rdf:li rdf:resource=\"$li\" />", '', $xml);
  66.                                 }
  67.                         }
  68.                        
  69.                 }
  70.                 return $xml;
  71.         }
  72.        
  73.         function _filter_item($matches) {
  74.                 $this->_item_filters = $this->_filters;
  75.                 preg_match_all("#<([a-zA-Z0-9:]*)(.*?)(>(.*?)</\\1>|/>)#s", $matches[3], $ematches, PREG_SET_ORDER);
  76.                
  77.                 foreach ($ematches as $element) {
  78.                         // change (e.g.) "dc:date" to "dcdate"
  79.                         $tag = strtolower( str_replace(':', '', $element[1]) );
  80.                        
  81.                         $attributes = $this->_get_attributes($element[2]);
  82.                         //$attributes = $this->_get_attributes($element[2]);
  83.                
  84.                         $content = $element[4];
  85.                         $this->_filter_element($tag, $attributes, $content);
  86.                 }
  87.  
  88.                 $filter_out = false;
  89.                 if ( !$this->_test_filter(count($this->_filters)-1) ) $filter_out = true;
  90.                
  91.                 if (!$filter_out) {
  92.                         return $matches[0];
  93.                 } else {
  94.                         // If items have rdf:about attributes and are filtered out, we
  95.                         // probably need to remove them from the <rdf:Seq> as well.
  96.                         $itat = $this->_get_attributes($matches[2]);
  97.                         if (isset($itat['rdf:about'])) $this->_seq[] = $itat['rdf:about'];
  98.                 }
  99.         }
  100.        
  101.        
  102.         function _filter_element($element, $attributes, $content) {
  103.                 foreach ($this->_item_filters as $id => $filter) {
  104.                         if ($filter['mode'] == 'sub') {
  105.                                 continue;
  106.                         } elseif ($filter['mode'] == 'has') {
  107.                                 if ($filter['element'] == '_') {
  108.                                         if ($filter['search'] == $element) $this->_item_filters[$id]['match'] = true;   
  109.                                 } elseif ($filter['element'] == $element) {
  110.                                         if (isset($attributes[$filter['search']])) {
  111.                                                 $this->_item_filters[$id]['match'] = true;     
  112.                                         }
  113.                                 }
  114.        
  115.                         } elseif ($filter['element'] == $element || $filter['element'] == '_') {
  116.                                 if ($filter['attribute']) {
  117.                                         if (isset($attributes[$filter['attribute']])) {
  118.                                                 $result = $this->_filter($filter['mode'], $attributes[$filter['attribute']], $filter['search']);
  119.                                                 if ($result) $this->_item_filters[$id]['match'] = true;
  120.                                         }
  121.                                 } else {
  122.                                         $result = $this->_filter($filter['mode'], $content, $filter['search']);
  123.                                         if ($result) $this->_item_filters[$id]['match'] = true;
  124.                                 }
  125.                         }
  126.                 }
  127.         }
  128.        
  129.         function _filter($mode = 'in', $haystack, $needle) {
  130.                 if (!$haystack || !$needle) return false;
  131.                 switch($mode) {
  132.                         case 'start':
  133.                                 return (substr($haystack, 0, strlen($needle)) == $needle);
  134.                         break;
  135.                         case 'end':
  136.                                 $length = strlen($needle);
  137.                                 return (substr($haystack, strlen($haystack)-$length, $length) == $needle);
  138.                         break;
  139.                         case 'in':
  140.                         default:
  141.                                 return (stristr($haystack, $needle));
  142.                         break;
  143.                 }
  144.         }
  145.  
  146.         /*
  147.         * Filter string Processing functions
  148.         *
  149.         * Extract something useful from a string like "(d AND (e OR f) && (g OR h))".
  150.         * The filters array contains search terms; logical relations have mode 'sub' and
  151.         * reference other filters.
  152.         *
  153.         * E.g., from "(a AND b)" (simplified):
  154.         *       [0] => Array (
  155.         *              [search] => a
  156.         *       )
  157.         *       [1] => Array (
  158.         *              [search] => b
  159.         *       )
  160.         *       [2] => Array (
  161.         *              [mode] => sub
  162.         *              [logic] => and
  163.         *              [0] => 0
  164.         *              [1] => 1
  165.         *       )
  166.         */
  167.         function _process_filter_string($str) {
  168.                 // Before doing anything, replace "strings with spaces" with
  169.                 // md5sums of themselves -- sub 'em back in later. Chance of
  170.                 // collision is negligible (read: I don't care), and it makes
  171.                 // everything much, much simpler.
  172.                 preg_match_all("#-*([a-z0-9:]+:)*(\"[^\"]+\")#i", $str, $matches, PREG_SET_ORDER);
  173.                 foreach ($matches as $match) {
  174.                         $whole = $match[0];
  175.                         $key = md5($whole);
  176.                         $this->_quoted[$key] = $whole;
  177.                         $str = str_replace($whole, $key, $str)
  178.                 }
  179.  
  180.                 // if parentheses are mismatched, try to balance them
  181.                 $open = substr_count($str, '(');
  182.                 $close = substr_count($str, ')');
  183.                 if ($open > $close) $str .= str_repeat(')', $open-$close);
  184.                 elseif ($open < $close) $str = str_repeat('(', $close-$open) . $str;
  185.                
  186.                 // if there are no parentheses, but spaces, disambiguate
  187.                 if (!$open) $str = $this->_disambiguate($str);
  188.                
  189.                 // recursive disambiguation
  190.                 while (strstr($str, '(')) {
  191.                         //echo "\n$str\n";
  192.                         $str = preg_replace_callback("#-*\(([^()]*)\)#", array(&$this, '_process_filter_string_callback'), $str);       
  193.                         if (!strstr($str, '(')) {
  194.                                 $str = $this->_disambiguate($str);
  195.                                 //if (strstr($str, ' ')) $str = $this->_disambiguate($str);
  196.                                 // no spaces either: single filter
  197.                                 //else $this->_process_filter($str);
  198.                         }
  199.                 }
  200.         }
  201.        
  202.         function _process_filter_string_callback($matches) {
  203.                 $positive = $this->_is_positive($matches[0]);
  204.                 return $this->_disambiguate($matches[1], $positive);
  205.         }
  206.        
  207.         // Resolve double/triple/etc. negatives.
  208.         //
  209.         function _is_positive($str) {
  210.                 return ((strlen($str) - strlen(ltrim($str, '-'))) % 2 == 0) ? true : false;
  211.         }
  212.        
  213.  
  214.         // Adds extra parentheses to disambiguate expressions -- e.g.
  215.         // turn "a AND b AND c" into "(a AND b) AND c".
  216.         function _disambiguate($ambiguous, $positive = true) {
  217.                 $terms = preg_split("#[ ]*( |AND|OR|[|&]{1,2})[ ]*(?!\))#", $ambiguous, -1, PREG_SPLIT_DELIM_CAPTURE);
  218.                
  219.                 if (count($terms) == 1)
  220.                         return $positive ? '~'.$this->_process_filter($ambiguous) : '~'.$this->_process_filter("-$ambiguous");
  221.                         //return $positive ? $ambiguous : "-$ambiguous";
  222.                
  223.                 if (count($terms) == 3) {
  224.                         $one = $this->_process_filter($terms[0]);
  225.                         $two = $this->_process_filter($terms[2]);
  226.                         $key = count($this->_filters);
  227.                         switch($terms[1]) {
  228.                                 case 'OR':
  229.                                 case '|':
  230.                                 case '||':
  231.                                         $logic = 'or';
  232.                                 break;
  233.                                 case ' ':
  234.                                 case 'AND':
  235.                                 case '&':
  236.                                 case '&&':
  237.                                 default:
  238.                                         $logic = 'and';
  239.                                 break;
  240.                         }
  241.                         $this->_filters[$key] = array('mode' => 'sub', 'logic' => $logic, 'positive' => $positive, $one, $two);
  242.                         // Use something nobody's going to be entering to mark off references
  243.                         // to the logic array. Should ~ be changed for \x00 or something?
  244.                         return '~' . $key;
  245.                 }
  246.                
  247.                 // If a longer string disambiguate with added parentheses.
  248.                 $last = count($terms);
  249.                 $out = '';
  250.                 foreach ($terms as $index => $term) {
  251.                         if ($index % 2 != 0) {
  252.                                 $out .= " $term ";
  253.                         } elseif ($index == $last-1 && !$popen) {
  254.                                 $out .= $term;
  255.                         } elseif (!$popen) {
  256.                                 $out .= "($term";       
  257.                                 $popen = true;
  258.                         } else {
  259.                                 $out .= "$term)";
  260.                                 $popen = false;
  261.                         }
  262.                 }
  263.                 return "($out)";
  264.         }
  265.        
  266.  
  267.         function _process_filter($filterstring) {
  268.                 // Is the filter logic?
  269.                 if (preg_match('#-*~([0-9]+)#', $filterstring, $filter)) {
  270.                         $positive = $this->_is_positive($filter[0]);
  271.                         $a_pos = $this->_filters[$filter[1]]['positive'];
  272.                         $this->_filters[$filter[1]]['positive'] = $positive ? $a_pos : !$a_pos;
  273.                         return $filter[1];
  274.                 } else {
  275.                         // Sub in the quoted strings we removed earlier.
  276.                         if ($this->_quoted) {
  277.                                 foreach ($this->_quoted as $key => $val) {
  278.                                         $filterstring = str_replace($key, $val, $filterstring)
  279.                                 }
  280.                         }
  281.                         $reserved = array('in', 'start', 'end', 'has');
  282.                         preg_match("#-*([a-z0-9:]+:)*(\"[^\"]+\"|[^ ]+)#i", $filterstring, $filter);
  283.                                
  284.                         //positive or negative filter
  285.                         $positive = $this->_is_positive($filter[0]);
  286.                        
  287.                         // search term (e.g.: "chicken")
  288.                         $search = strtolower( trim($filter[2], '"') );
  289.                        
  290.                         // search in what? (e.g.: "in:title")
  291.                         $select = explode(':', trim($filter[1], ':'));
  292.                        
  293.                         if (!in_array($select[0], $reserved)) {
  294.                                 $mode = 'in';
  295.                         } else {
  296.                                 $mode = $select[0];
  297.                                 array_shift($select);
  298.                         }
  299.                        
  300.                         // if searching in particular element or element attribute           
  301.                         if (isset($select[0])) $element = $select[0];
  302.                         if (isset($select[1])) $attribute = $select[1];
  303.                                                        
  304.                         $key = ($element == '') ? '_' : $element;
  305.                        
  306.                         $this->_filters[] = array('element' => $key, 'positive' => $positive,
  307.                                                                 'mode' => $mode, 'search' => $search,
  308.                                                                 'attribute' => $attribute);
  309.                 }
  310.                 return count($this->_filters)-1;                                               
  311.         }
  312.        
  313.         function _test_logic($rule) {
  314.                 $logic = $rule['logic'];
  315.                 $return = false;
  316.                 if ($logic == 'and') {
  317.                         if ($this->_test_filter($rule[0]) && $this->_test_filter($rule[1])) $return = true;
  318.                 } elseif ($logic == 'or') {
  319.                         if ($this->_test_filter($rule[0]) || $this->_test_filter($rule[1])) $return = true;
  320.                 }
  321.                 return ($rule['positive']) ? $return : !$return;
  322.         }
  323.        
  324.        
  325.         function _test_filter($id) {
  326.                 $filter = $this->_item_filters[$id];
  327.                 if ($filter['mode'] == 'sub') {
  328.                         return $this->_test_logic($filter);
  329.                 } else {
  330.                         return $filter['positive'] ? $filter['match'] : !$filter['match'];
  331.                 }
  332.         }
  333.        
  334.         // From 'a="b" c="d"' to Array{'a'=>'b', 'c'=>'d'};
  335.         //
  336.         function _get_attributes($attrstring) {
  337.                 $attributes = array();
  338.                 preg_match_all('#[\s]*[a-zA-Z0-9:\-]+[\s]*=[\s]*(["\']).*?\\1[\s]*#is', $attrstring, $matches, PREG_PATTERN_ORDER);
  339.                 if ($matches) {
  340.                         foreach ($matches[0] as $attribute) {
  341.                                 $attribute = strtolower( trim($attribute) );
  342.                                 $brute = explode('=', $attribute, 2);
  343.                                 $attributes[$brute[0]] = trim($brute[1], '"');
  344.                         }
  345.                 }       
  346.                 return $attributes;
  347.         }
  348. }
  349.  
  350. ?>