html.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. """
  2. Comparing two html documents.
  3. """
  4. from __future__ import unicode_literals
  5. import re
  6. from django.utils.encoding import force_text
  7. from django.utils.html_parser import HTMLParser, HTMLParseError
  8. from django.utils import six
  9. from django.utils.encoding import python_2_unicode_compatible
  10. WHITESPACE = re.compile('\s+')
  11. def normalize_whitespace(string):
  12. return WHITESPACE.sub(' ', string)
  13. @python_2_unicode_compatible
  14. class Element(object):
  15. def __init__(self, name, attributes):
  16. self.name = name
  17. self.attributes = sorted(attributes)
  18. self.children = []
  19. def append(self, element):
  20. if isinstance(element, six.string_types):
  21. element = force_text(element)
  22. element = normalize_whitespace(element)
  23. if self.children:
  24. if isinstance(self.children[-1], six.string_types):
  25. self.children[-1] += element
  26. self.children[-1] = normalize_whitespace(self.children[-1])
  27. return
  28. elif self.children:
  29. # removing last children if it is only whitespace
  30. # this can result in incorrect dom representations since
  31. # whitespace between inline tags like <span> is significant
  32. if isinstance(self.children[-1], six.string_types):
  33. if self.children[-1].isspace():
  34. self.children.pop()
  35. if element:
  36. self.children.append(element)
  37. def finalize(self):
  38. def rstrip_last_element(children):
  39. if children:
  40. if isinstance(children[-1], six.string_types):
  41. children[-1] = children[-1].rstrip()
  42. if not children[-1]:
  43. children.pop()
  44. children = rstrip_last_element(children)
  45. return children
  46. rstrip_last_element(self.children)
  47. for i, child in enumerate(self.children):
  48. if isinstance(child, six.string_types):
  49. self.children[i] = child.strip()
  50. elif hasattr(child, 'finalize'):
  51. child.finalize()
  52. def __eq__(self, element):
  53. if not hasattr(element, 'name'):
  54. return False
  55. if hasattr(element, 'name') and self.name != element.name:
  56. return False
  57. if len(self.attributes) != len(element.attributes):
  58. return False
  59. if self.attributes != element.attributes:
  60. # attributes without a value is same as attribute with value that
  61. # equals the attributes name:
  62. # <input checked> == <input checked="checked">
  63. for i in range(len(self.attributes)):
  64. attr, value = self.attributes[i]
  65. other_attr, other_value = element.attributes[i]
  66. if value is None:
  67. value = attr
  68. if other_value is None:
  69. other_value = other_attr
  70. if attr != other_attr or value != other_value:
  71. return False
  72. if self.children != element.children:
  73. return False
  74. return True
  75. def __hash__(self):
  76. return hash((self.name,) + tuple(a for a in self.attributes))
  77. def __ne__(self, element):
  78. return not self.__eq__(element)
  79. def _count(self, element, count=True):
  80. if not isinstance(element, six.string_types):
  81. if self == element:
  82. return 1
  83. i = 0
  84. for child in self.children:
  85. # child is text content and element is also text content, then
  86. # make a simple "text" in "text"
  87. if isinstance(child, six.string_types):
  88. if isinstance(element, six.string_types):
  89. if count:
  90. i += child.count(element)
  91. elif element in child:
  92. return 1
  93. else:
  94. i += child._count(element, count=count)
  95. if not count and i:
  96. return i
  97. return i
  98. def __contains__(self, element):
  99. return self._count(element, count=False) > 0
  100. def count(self, element):
  101. return self._count(element, count=True)
  102. def __getitem__(self, key):
  103. return self.children[key]
  104. def __str__(self):
  105. output = '<%s' % self.name
  106. for key, value in self.attributes:
  107. if value:
  108. output += ' %s="%s"' % (key, value)
  109. else:
  110. output += ' %s' % key
  111. if self.children:
  112. output += '>\n'
  113. output += ''.join(six.text_type(c) for c in self.children)
  114. output += '\n</%s>' % self.name
  115. else:
  116. output += ' />'
  117. return output
  118. def __repr__(self):
  119. return six.text_type(self)
  120. @python_2_unicode_compatible
  121. class RootElement(Element):
  122. def __init__(self):
  123. super(RootElement, self).__init__(None, ())
  124. def __str__(self):
  125. return ''.join(six.text_type(c) for c in self.children)
  126. class Parser(HTMLParser):
  127. SELF_CLOSING_TAGS = ('br', 'hr', 'input', 'img', 'meta', 'spacer',
  128. 'link', 'frame', 'base', 'col')
  129. def __init__(self):
  130. HTMLParser.__init__(self)
  131. self.root = RootElement()
  132. self.open_tags = []
  133. self.element_positions = {}
  134. def error(self, msg):
  135. raise HTMLParseError(msg, self.getpos())
  136. def format_position(self, position=None, element=None):
  137. if not position and element:
  138. position = self.element_positions[element]
  139. if position is None:
  140. position = self.getpos()
  141. if hasattr(position, 'lineno'):
  142. position = position.lineno, position.offset
  143. return 'Line %d, Column %d' % position
  144. @property
  145. def current(self):
  146. if self.open_tags:
  147. return self.open_tags[-1]
  148. else:
  149. return self.root
  150. def handle_startendtag(self, tag, attrs):
  151. self.handle_starttag(tag, attrs)
  152. if tag not in self.SELF_CLOSING_TAGS:
  153. self.handle_endtag(tag)
  154. def handle_starttag(self, tag, attrs):
  155. # Special case handling of 'class' attribute, so that comparisons of DOM
  156. # instances are not sensitive to ordering of classes.
  157. attrs = [
  158. (name, " ".join(sorted(value.split(" "))))
  159. if name == "class"
  160. else (name, value)
  161. for name, value in attrs
  162. ]
  163. element = Element(tag, attrs)
  164. self.current.append(element)
  165. if tag not in self.SELF_CLOSING_TAGS:
  166. self.open_tags.append(element)
  167. self.element_positions[element] = self.getpos()
  168. def handle_endtag(self, tag):
  169. if not self.open_tags:
  170. self.error("Unexpected end tag `%s` (%s)" % (
  171. tag, self.format_position()))
  172. element = self.open_tags.pop()
  173. while element.name != tag:
  174. if not self.open_tags:
  175. self.error("Unexpected end tag `%s` (%s)" % (
  176. tag, self.format_position()))
  177. element = self.open_tags.pop()
  178. def handle_data(self, data):
  179. self.current.append(data)
  180. def handle_charref(self, name):
  181. self.current.append('&%s;' % name)
  182. def handle_entityref(self, name):
  183. self.current.append('&%s;' % name)
  184. def parse_html(html):
  185. """
  186. Takes a string that contains *valid* HTML and turns it into a Python object
  187. structure that can be easily compared against other HTML on semantic
  188. equivalence. Syntactical differences like which quotation is used on
  189. arguments will be ignored.
  190. """
  191. parser = Parser()
  192. parser.feed(html)
  193. parser.close()
  194. document = parser.root
  195. document.finalize()
  196. # Removing ROOT element if it's not necessary
  197. if len(document.children) == 1:
  198. if not isinstance(document.children[0], six.string_types):
  199. document = document.children[0]
  200. return document