diff --git a/scrapy/contrib/ibl/__init__.py b/scrapy/contrib/ibl/__init__.py new file mode 100644 index 000000000..3179c6e63 --- /dev/null +++ b/scrapy/contrib/ibl/__init__.py @@ -0,0 +1,10 @@ +""" +This contrib implements an automatic extraction library based on an Instance +Based Learning (IBL) algorithm, as described in the following papers: + +A hierarchical approach to wrapper induction + http://portal.acm.org/citation.cfm?id=301191 + +Extracting web data using instance based learning + http://portal.acm.org/citation.cfm?id=1265174 +""" diff --git a/scrapy/contrib/ibl/descriptor.py b/scrapy/contrib/ibl/descriptor.py new file mode 100644 index 000000000..9780ee0cf --- /dev/null +++ b/scrapy/contrib/ibl/descriptor.py @@ -0,0 +1,51 @@ +""" +Extended types for IBL extraction +""" +from itertools import chain + +from scrapy.contrib.ibl.extractors import text + +class FieldDescriptor(object): + """description of a scraped attribute""" + __slots__ = ('name', 'description', 'extractor', 'required', 'allow_markup') + + def __init__(self, name, description, extractor=text, required=False, + allow_markup=False): + self.name = name + self.description = description + self.extractor = extractor + self.required = required + self.allow_markup = allow_markup + + def __str__(self): + return "FieldDescriptor(%s)" % self.name + +class ItemDescriptor(object): + """Simple auto scraping item descriptor. + + This used to describe type-specific operations and may be overridden where + necessary. + """ + + def __init__(self, name, description, attribute_descriptors): + self.name = name + self.attribute_map = dict((d.name, d) for d in attribute_descriptors) + self._required_attributes = [d.name for d in attribute_descriptors \ + if d.required] + + def validated(self, data): + """Only return the items in the data that are valid""" + return [d for d in data if self._item_validates(d)] + + def _item_validates(self, item): + """simply checks that all mandatory attributes are present""" + variant_attrs = set(chain(* + [v.keys() for v in item.get('variants', [])])) + return all([(name in item or name in variant_attrs) \ + for name in self._required_attributes]) + + def get_required_attributes(self): + return self._required_attributes + + def __str__(self): + return "ItemDescriptor(%s)" % self.name diff --git a/scrapy/contrib/ibl/extraction/__init__.py b/scrapy/contrib/ibl/extraction/__init__.py new file mode 100644 index 000000000..4d5cf6559 --- /dev/null +++ b/scrapy/contrib/ibl/extraction/__init__.py @@ -0,0 +1,126 @@ +""" +IBL module + +This contains an extraction algorithm based on the paper Extracting Web Data +Using Instance-Based Learning by Yanhong Zhai and Bing Liu. + +It defines the InstanceBasedLearningExtractor class, which implements this +extraction algorithm. + +Main departures from the original algorithm: + * there is no limit in prefix or suffix size + * we have "attribute adaptors" that allow generic post processing and may + affect the extraction process. For example, a price field may require a + numeric value to be present. + * tags can be inserted to extract regions not wrapped by html tags. These + regions are then identified using the longest unique character prefix and + suffix. +""" +from operator import itemgetter +from .regionextract import build_extraction_tree +from .pageparsing import parse_template, parse_extraction_page +from .pageobjects import TokenDict, AnnotationText +from .similarity import common_prefix + +class InstanceBasedLearningExtractor(object): + """Implementation of the instance based learning algorithm to + extract data from web pages. + """ + + def __init__(self, templates, type_descriptor=None, trace=False): + """Initialise this extractor + + templates should contain a sequence of strings, each containing + annotated html that will be used as templates for extraction. + + Tags surrounding areas to be extracted must contain a + 'data-scrapy-annotate' attribute and the value must be the name + of the attribute. If the tag was inserted and was not present in the + original page, the data-scrapy-generated attribute must be present. + + type_descriptor may contain a type descriptor describing the item + to be extracted. + + if trace is true, the returned extracted data will have a 'trace' + property that contains a trace of the extraction execution. + """ + self.token_dict = TokenDict() + parsed_plus_templates = [(parse_template(self.token_dict, t), t) for t in templates] + parsed_plus_epages = [(p, parse_extraction_page(self.token_dict, t)) for p, t \ + in parsed_plus_templates if _annotation_count(p)] + parsed_templates = map(itemgetter(0), parsed_plus_epages) + + # calculate common text prefixes for annotations of same field across all templates + extracted_text = {} + extraction_pages = map(itemgetter(1), parsed_plus_epages) + for i, parsed in enumerate(parsed_templates): + for annot in parsed.annotations: + if annot.match_common_prefix: + field = annot.surrounds_attribute + if field is not None: + descriptor = type_descriptor.attribute_map.get(field) if type_descriptor else None + allow_markup = descriptor.allow_markup if descriptor else False + start, end = annot.start_index, annot.end_index + if allow_markup: + text = extraction_pages[i].html_between_tokens(start, end) + else: + text = extraction_pages[i].text_between_tokens(start, end) + extracted_text.setdefault(field, []).append(text) + common_prefixes = {} + for field, data in extracted_text.iteritems(): + if len(data) > 1: + cprefix = common_prefix(*data) + if cprefix: + common_prefixes[field] = "".join(cprefix).strip() + # now apply common prefixes to annotations + for i, parsed in enumerate(parsed_templates): + for annot in parsed.annotations: + for field, prefix in common_prefixes.iteritems(): + if annot.surrounds_attribute == field and not annot.annotation_text: + annot.annotation_text = AnnotationText(prefix) + + # templates with more attributes are considered first + sorted_templates = sorted(parsed_templates, key=_annotation_count, reverse=True) + self.extraction_trees = [build_extraction_tree(t, type_descriptor, + trace) for t in sorted_templates] + self.validated = type_descriptor.validated if type_descriptor else \ + self._filter_not_none + + def extract(self, html, pref_template_id=None, useone=False): + """extract data from an html page + + If pref_template_url is specified, the template with that url will be + used first. + if useone is True and no data was extracted, no additional template will + be tried. If False and no data was extracted, try with rest of item templates + """ + extraction_page = parse_extraction_page(self.token_dict, html) + if pref_template_id is not None: + if useone: + extraction_trees = [x for x in self.extraction_trees if x.template.id == pref_template_id] + else: + extraction_trees = sorted(self.extraction_trees, + key=lambda x: x.template.id != pref_template_id) + else: + extraction_trees = self.extraction_trees + + for extraction_tree in extraction_trees: + extracted = extraction_tree.extract(extraction_page) + correctly_extracted = self.validated(extracted) + extra_required = extraction_tree.template.extra_required_attrs + correctly_extracted = [c for c in correctly_extracted if \ + extra_required.intersection(c.keys()) == extra_required ] + if len(correctly_extracted) > 0: + return correctly_extracted, extraction_tree.template.id + return None, None + + def __str__(self): + return "InstanceBasedLearningExtractor[\n%s\n]" % \ + (',\n'.join(map(str, self.extraction_trees))) + + @staticmethod + def _filter_not_none(items): + return [d for d in items if d is not None] + +def _annotation_count(template): + return len(template.annotations) diff --git a/scrapy/contrib/ibl/extraction/pageobjects.py b/scrapy/contrib/ibl/extraction/pageobjects.py new file mode 100644 index 000000000..c57cdfa75 --- /dev/null +++ b/scrapy/contrib/ibl/extraction/pageobjects.py @@ -0,0 +1,230 @@ +""" +Page objects + +This module contains objects representing pages and parts of pages (e.g. tokens +and annotations) used in the instance based learning algorithm. +""" +from numpy import array, ndarray + +from scrapy.contrib.ibl.htmlpage import HtmlTagType + +class TokenType(object): + """constants for token types""" + WORD = 0 + OPEN_TAG = HtmlTagType.OPEN_TAG + CLOSE_TAG = HtmlTagType.CLOSE_TAG + NON_PAIRED_TAG = HtmlTagType.UNPAIRED_TAG + +class TokenDict(object): + """Mapping from parse tokens to integers + + >>> d = TokenDict() + >>> d.tokenid('i') + 0 + >>> d.tokenid('b') + 1 + >>> d.tokenid('i') + 0 + + Tokens can be searched for by id + >>> d.find_token(1) + 'b' + + The lower 24 bits store the token reference and the higher bits the type. + """ + + def __init__(self): + self.token_ids = {} + + def tokenid(self, token, token_type=TokenType.WORD): + """create an integer id from the token and token type passed""" + tid = self.token_ids.setdefault(token, len(self.token_ids)) + return tid | (token_type << 24) + + @staticmethod + def token_type(token): + """extract the token type from the token id passed""" + return token >> 24 + + def find_token(self, tid): + """Search for a tag with the given ID + + This is O(N) and is only intended for debugging + """ + tid &= 0xFFFFFF + if tid >= len(self.token_ids) or tid < 0: + raise ValueError("tag id %s out of range" % tid) + + for (token, token_id) in self.token_ids.items(): + if token_id == tid: + return token + assert False, "token dictionary is corrupt" + + def token_string(self, tid): + """create a string representation of a token + + This is O(N). + """ + templates = ["%s", "<%s>", "%s>", "<%s/>"] + return templates[tid >> 24] % self.find_token(tid) + +class Page(object): + """Basic representation of a page. This consists of a reference to a + dictionary of tokens and an array of raw token ids + """ + + __slots__ = ('token_dict', 'page_tokens') + + def __init__(self, token_dict, page_tokens): + self.token_dict = token_dict + # use a numpy array becuase we can index/slice easily and efficiently + if not isinstance(page_tokens, ndarray): + page_tokens = array(page_tokens) + self.page_tokens = page_tokens + +class TemplatePage(Page): + __slots__ = ('annotations', 'id', 'ignored_regions', 'extra_required_attrs') + + def __init__(self, token_dict, page_tokens, annotations, template_id=None, \ + ignored_regions=None, extra_required=None): + Page.__init__(self, token_dict, page_tokens) + # ensure order is the same as start tag order in the original page + annotations = sorted(annotations, key=lambda x: x.end_index, reverse=True) + self.annotations = sorted(annotations, key=lambda x: x.start_index) + self.id = template_id + self.ignored_regions = ignored_regions or [] + self.extra_required_attrs = set(extra_required or []) + + def __str__(self): + summary = [] + for index, token in enumerate(self.page_tokens): + text = "%s: %s" % (index, self.token_dict.find_token(token)) + summary.append(text) + return "TemplatePage\n============\nTokens: (index, token)\n%s\nAnnotations: %s\n" % \ + ('\n'.join(summary), '\n'.join(map(str, self.annotations))) + +class ExtractionPage(Page): + """Parsed data belonging to a web page upon which we wish to perform + extraction. + """ + __slots__ = ('text', + 'token_start_indexes', # index in text of the start of a token + 'token_follow_indexes', # index in text of data following token + 'tag_attributes' # a map from token index to tag attributes + ) + + def __init__(self, text, token_dict, page_tokens, token_start_indexes, + token_follow_indexes, tag_attributes): + Page.__init__(self, token_dict, page_tokens) + self.text = text + self.token_start_indexes = token_start_indexes + self.token_follow_indexes = token_follow_indexes + self.tag_attributes = tag_attributes + + def token_html(self, token_index): + """The raw html for a page token at the given index in the page_tokens + list + """ + text_start = self.token_start_indexes[token_index] + text_end = self.token_follow_indexes[token_index] + return self.text[text_start:text_end] + + def html_between_tokens(self, start_token_index, end_token_index): + """The raw html between the tokens at the specified indexes in the + page_tokens list + + This assumes start_token_index <= end_token_index + """ + text_start = self.token_follow_indexes[start_token_index] + text_end = self.token_start_indexes[end_token_index] + return self.text[text_start:text_end] + + def text_between_tokens(self, start_token_index, end_token_index, + tag_replacement=u' '): + """The text between the the tokens at the specified indexes in the + page_tokens list. Tags are replaced by tag_replacement (default one space + character) + """ + return tag_replacement.join([self.text[ + self.token_follow_indexes[i]:self.token_start_indexes[i+1]] \ + for i in xrange(start_token_index, end_token_index)]) + + def tag_attribute(self, token_index, attribute): + """The value of a tag attribute. The tag is identified by its + corresponding token index. + + If the tag or attribute is not present, None is returned + """ + return self.tag_attributes.get(token_index, {}).get(attribute) + + def __str__(self): + summary = [] + for (token, start, follow) in zip(self.page_tokens, self.token_start_indexes, + self.token_follow_indexes): + text = "%s %s-%s (%s)" % (self.token_dict.find_token(token), start, follow, + self.text[start:follow]) + summary.append(text) + return "ExtractionPage\n==============\nTokens: %s\n\nRaw text: %s\n\n" \ + "Tag attributes: %s\n" % ('\n'.join(summary), self.text, + self.tag_attributes) + +class AnnotationText(object): + __slots__ = ('start_text', 'follow_text') + + def __init__(self, start_text=None, follow_text=None): + self.start_text = start_text + self.follow_text = follow_text + + def __str__(self): + return "AnnotationText(%s..%s)" % \ + (repr(self.start_text), repr(self.follow_text)) + +class AnnotationTag(object): + """A tag that annotates part of the document + + It has the following properties: + start_index - index of the token for the opening tag + end_index - index of the token for the closing tag + surrounds_attribute - the attribute name surrounded by this tag + tag_attributes - list of (tag attribute, extracted attribute) tuples + for each item to be extracted from a tag attribute + annotation_text - text prefix and suffix for the attribute to be extracted + match_common_prefix - use this annotation for calculating across-template prefixes + """ + __slots__ = ('surrounds_attribute', 'start_index', 'end_index', + 'tag_attributes', 'annotation_text', 'variant_id', + 'surrounds_variant','match_common_prefix') + + def __init__(self, start_index, end_index, surrounds_attribute=None, + annotation_text=None, tag_attributes=None, variant_id=None, + surrounds_variant=None, match_common_prefix=False): + self.start_index = start_index + self.end_index = end_index + self.surrounds_attribute = surrounds_attribute + self.annotation_text = annotation_text + self.tag_attributes = tag_attributes or [] + self.variant_id = variant_id + self.surrounds_variant = surrounds_variant + self.match_common_prefix = match_common_prefix + + def __str__(self): + return "AnnotationTag(%s)" % ", ".join( + ["%s=%s" % (s, getattr(self, s)) \ + for s in self.__slots__ if getattr(self, s)]) + + def __repr__(self): + return str(self) + +class LabelledRegion(object): + __slots__ = ('start_index', 'end_index') + + def __init__(self, start, end): + self.start_index = start + self.end_index = end + + def __str__(self): + return "LabelledRegion (%s, %s)" % (self.start_index, self.end_index) + + def __repr__(self): + return str(self) + diff --git a/scrapy/contrib/ibl/extraction/pageparsing.py b/scrapy/contrib/ibl/extraction/pageparsing.py new file mode 100644 index 000000000..486862ae5 --- /dev/null +++ b/scrapy/contrib/ibl/extraction/pageparsing.py @@ -0,0 +1,318 @@ +""" +Page parsing + +Parsing of web pages for extraction task. +""" +from collections import defaultdict +from numpy import array + +from scrapy.utils.py26 import json + +from scrapy.contrib.ibl.htmlpage import HtmlTagType, HtmlTag, HtmlPage +from scrapy.contrib.ibl.extraction.pageobjects import (AnnotationTag, + TemplatePage, ExtractionPage, AnnotationText, TokenDict) + +def parse_strings(template_html, extraction_html): + """Create a template and extraction page from raw strings + + this is useful for testing purposes + """ + t = TokenDict() + template_page = HtmlPage(body=template_html) + extraction_page = HtmlPage(body=extraction_html) + return (parse_template(t, template_page), + parse_extraction_page(t, extraction_page)) + +def parse_template(token_dict, template_html): + """Create an TemplatePage object by parsing the annotated html""" + parser = TemplatePageParser(token_dict) + parser.feed(template_html) + return parser.to_template() + +def parse_extraction_page(token_dict, page_html): + """Create an ExtractionPage object by parsing the html""" + parser = ExtractionPageParser(token_dict) + parser.feed(page_html) + return parser.to_extraction_page() + +class InstanceLearningParser(object): + """Base parser for instance based learning algorithm + + This does not require correct HTML and the parsing method should not alter + the original tag order. It is important that parsing results do not vary. + """ + def __init__(self, token_dict): + self.token_dict = token_dict + self.token_list = [] + + def _add_token(self, token, token_type, start, end): + tid = self.token_dict.tokenid(token, token_type) + self.token_list.append(tid) + + def feed(self, html_page): + self.html_page = html_page + self.previous_element_class = None + for data in html_page.parsed_body: + if isinstance(data, HtmlTag): + self._add_token(data.tag, data.tag_type, data.start, data.end) + self.handle_tag(data) + else: + self.handle_data(data) + self.previous_element_class = data.__class__ + + def handle_data(self, html_data_fragment): + pass + + def handle_tag(self, html_tag): + pass + +_END_UNPAIREDTAG_TAGS = ["form", "div", "p", "table", "tr", "td"] + +class TemplatePageParser(InstanceLearningParser): + """Template parsing for instance based learning algorithm""" + + def __init__(self, token_dict): + InstanceLearningParser.__init__(self, token_dict) + self.annotations = [] + self.ignored_regions = [] + self.extra_required_attrs = [] + self.ignored_tag_stacks = defaultdict(list) + # tag names that have not been completed + self.labelled_tag_stacks = defaultdict(list) + self.replacement_stacks = defaultdict(list) + self.unpairedtag_stack = [] + self.variant_stack = [] + self.prev_data = None + self.last_text_region = None + self.next_tag_index = 0 + + def handle_tag(self, html_tag): + if self.last_text_region: + self._process_text('') + + if html_tag.tag_type == HtmlTagType.OPEN_TAG: + self._handle_open_tag(html_tag) + elif html_tag.tag_type == HtmlTagType.CLOSE_TAG: + self._handle_close_tag(html_tag) + else: + # the tag is not paired, it can contain only attribute annotations + self._handle_unpaired_tag(html_tag) + + @staticmethod + def _read_template_annotation(html_tag): + template_attr = html_tag.attributes.get('data-scrapy-annotate') + if template_attr is None: + return None + unescaped = template_attr.replace('"', '"') + return json.loads(unescaped) + + @staticmethod + def _read_bool_template_attribute(html_tag, attribute): + return html_tag.attributes.get("data-scrapy-" + attribute) == "true" + + def _close_unpaired_tag(self): + self.unpairedtag_stack[0].end_index = self.next_tag_index + self.unpairedtag_stack = [] + + def _handle_unpaired_tag(self, html_tag): + if self._read_bool_template_attribute(html_tag, "ignore") and html_tag.tag == "img": + self.ignored_regions.append((self.next_tag_index, self.next_tag_index + 1)) + elif self._read_bool_template_attribute(html_tag, "ignore-beneath") and html_tag.tag == "img": + self.ignored_regions.append((self.next_tag_index, None)) + jannotation = self._read_template_annotation(html_tag) + if jannotation: + if self.unpairedtag_stack: + self._close_unpaired_tag() + + annotation = AnnotationTag(self.next_tag_index, self.next_tag_index + 1) + attribute_annotations = jannotation.get('annotations', {}).items() + for extract_attribute, tag_value in attribute_annotations: + if extract_attribute == 'content': + annotation.surrounds_attribute = tag_value + self.unpairedtag_stack.append(annotation) + else: + annotation.tag_attributes.append((extract_attribute, tag_value)) + self.annotations.append(annotation) + if jannotation.get('common_prefix', False): + annotation.match_common_prefix = True + + self.extra_required_attrs.extend(jannotation.get('required', [])) + + self.next_tag_index += 1 + + def _handle_open_tag(self, html_tag): + if self._read_bool_template_attribute(html_tag, "ignore"): + if html_tag.tag == "img": + self.ignored_regions.append((self.next_tag_index, self.next_tag_index + 1)) + else: + self.ignored_regions.append((self.next_tag_index, None)) + self.ignored_tag_stacks[html_tag.tag].append(html_tag) + + elif self.ignored_tag_stacks.get(html_tag.tag): + self.ignored_tag_stacks[html_tag.tag].append(None) + if self._read_bool_template_attribute(html_tag, "ignore-beneath"): + self.ignored_regions.append((self.next_tag_index, None)) + + replacement = html_tag.attributes.pop("data-scrapy-replacement", None) + if replacement: + self.token_list.pop() + self._add_token(replacement, html_tag.tag_type, html_tag.start, html_tag.end) + self.replacement_stacks[html_tag.tag].append(replacement) + elif html_tag.tag in self.replacement_stacks: + self.replacement_stacks[html_tag.tag].append(None) + + if self.unpairedtag_stack: + if html_tag.tag in _END_UNPAIREDTAG_TAGS: + self._close_unpaired_tag() + else: + self.unpairedtag_stack.append(html_tag.tag) + + # can't be a p inside another p. Also, an open p element closes + # a previous open p element. + if html_tag.tag == "p" and html_tag.tag in self.labelled_tag_stacks: + annotation = self.labelled_tag_stacks.pop(html_tag.tag)[0] + annotation.end_index = self.next_tag_index + self.annotations.append(annotation) + + jannotation = self._read_template_annotation(html_tag) + if not jannotation: + if html_tag.tag in self.labelled_tag_stacks: + # add this tag to the stack to match correct end tag + self.labelled_tag_stacks[html_tag.tag].append(None) + self.next_tag_index += 1 + return + + annotation = AnnotationTag(self.next_tag_index, None) + if jannotation.get('generated', False): + self.token_list.pop() + annotation.start_index -= 1 + if self.previous_element_class == HtmlTag: + annotation.annotation_text = AnnotationText('') + else: + annotation.annotation_text = AnnotationText(self.prev_data) + if self._read_bool_template_attribute(html_tag, "ignore") \ + or self._read_bool_template_attribute(html_tag, "ignore-beneath"): + ignored = self.ignored_regions.pop() + self.ignored_regions.append((ignored[0]-1, ignored[1])) + + if jannotation.get('common_prefix', False): + annotation.match_common_prefix = True + + self.extra_required_attrs.extend(jannotation.get('required', [])) + + variant_id = jannotation.get('variant', 0) + if variant_id > 0: + self.variant_stack.append(variant_id) + annotation.surrounds_variant = variant_id + attribute_annotations = jannotation.get('annotations', {}).items() + for extract_attribute, tag_value in attribute_annotations: + if extract_attribute == 'content': + annotation.surrounds_attribute = tag_value + else: + annotation.tag_attributes.append((extract_attribute, tag_value)) + + if annotation.annotation_text is None: + self.next_tag_index += 1 + if self.variant_stack: + variant_id = self.variant_stack[-1] + if variant_id == '0': + variant_id = None + annotation.variant_id = variant_id + + # look for a closing tag if the content is important + if annotation.surrounds_attribute or annotation.surrounds_variant: + self.labelled_tag_stacks[html_tag.tag].append(annotation) + else: + annotation.end_index = annotation.start_index + 1 + self.annotations.append(annotation) + + def _handle_close_tag(self, html_tag): + + if self.unpairedtag_stack: + if html_tag.tag == self.unpairedtag_stack[-1]: + self.unpairedtag_stack.pop() + else: + self._close_unpaired_tag() + ignored_tags = self.ignored_tag_stacks.get(html_tag.tag) + if ignored_tags is not None: + tag = ignored_tags.pop() + if isinstance(tag, HtmlTag): + for i in range(-1, -len(self.ignored_regions) - 1, -1): + if self.ignored_regions[i][1] is None: + self.ignored_regions[i] = (self.ignored_regions[i][0], self.next_tag_index) + break + if len(ignored_tags) == 0: + del self.ignored_tag_stacks[html_tag.tag] + + if html_tag.tag in self.replacement_stacks: + replacement = self.replacement_stacks[html_tag.tag].pop() + if replacement: + self.token_list.pop() + self._add_token(replacement, html_tag.tag_type, html_tag.start, html_tag.end) + if len(self.replacement_stacks[html_tag.tag]) == 0: + del self.replacement_stacks[html_tag.tag] + + labelled_tags = self.labelled_tag_stacks.get(html_tag.tag) + if labelled_tags is None: + self.next_tag_index += 1 + return + annotation = labelled_tags.pop() + if annotation is None: + self.next_tag_index += 1 + else: + annotation.end_index = self.next_tag_index + self.annotations.append(annotation) + if annotation.annotation_text is not None: + self.token_list.pop() + self.last_text_region = annotation + else: + self.next_tag_index += 1 + if len(labelled_tags) == 0: + del self.labelled_tag_stacks[html_tag.tag] + if annotation.surrounds_variant and self.variant_stack: + prev = self.variant_stack.pop() + if prev != annotation.surrounds_variant: + raise ValueError("unbalanced variant annotation tags") + + def handle_data(self, html_data_fragment): + fragment_text = self.html_page.fragment_data(html_data_fragment) + self._process_text(fragment_text) + + def _process_text(self, text): + if self.last_text_region is not None: + self.last_text_region.annotation_text.follow_text = text + self.last_text_region = None + self.prev_data = text + + def to_template(self): + """create a TemplatePage from the data fed to this parser""" + return TemplatePage(self.token_dict, self.token_list, self.annotations, + self.html_page.page_id, self.ignored_regions, self.extra_required_attrs) + +class ExtractionPageParser(InstanceLearningParser): + """Parse an HTML page for extraction using the instance based learning + algorithm + + This needs to extract the tokens in a similar way to LabelledPageParser, + it needs to also maintain a mapping from token index to the original content + so that once regions are identified, the original content can be extracted. + """ + def __init__(self, token_dict): + InstanceLearningParser.__init__(self, token_dict) + self.page_data = [] + self.token_start_index = [] + self.token_follow_index = [] + self.tag_attrs = {} + + def _add_token(self, token, token_type, start, end): + InstanceLearningParser._add_token(self, token, token_type, start, end) + self.token_start_index.append(start) + self.token_follow_index.append(end) + + def handle_tag(self, html_tag): + if html_tag.attributes: + self.tag_attrs[len(self.token_list) - 1] = html_tag.attributes + + def to_extraction_page(self): + return ExtractionPage(self.html_page.body, self.token_dict, array(self.token_list), + self.token_start_index, self.token_follow_index, self.tag_attrs) diff --git a/scrapy/contrib/ibl/extraction/regionextract.py b/scrapy/contrib/ibl/extraction/regionextract.py new file mode 100644 index 000000000..9b5aab166 --- /dev/null +++ b/scrapy/contrib/ibl/extraction/regionextract.py @@ -0,0 +1,651 @@ +""" +Region Extract + +Custom extraction for regions in a document +""" +import operator +import copy +import pprint +import cStringIO +from itertools import groupby + +import nltk +from numpy import array + +from scrapy.contrib.ibl.descriptor import FieldDescriptor +from scrapy.contrib.ibl.extraction.similarity import (similar_region, + longest_unique_subsequence, common_prefix) +from scrapy.contrib.ibl.extraction.pageobjects import AnnotationTag, LabelledRegion + +def build_extraction_tree(template, type_descriptor, trace=True): + """Build a tree of region extractors corresponding to the + template + """ + attribute_map = type_descriptor.attribute_map if type_descriptor else None + extractors = BasicTypeExtractor.create(template.annotations, attribute_map) + if trace: + extractors = TraceExtractor.apply(template, extractors) + for cls in (RepeatedDataExtractor, AdjacentVariantExtractor, RepeatedDataExtractor, + RecordExtractor): + extractors = cls.apply(template, extractors) + if trace: + extractors = TraceExtractor.apply(template, extractors) + + return TemplatePageExtractor(template, extractors) + +_ID = lambda x: x +_DEFAULT_DESCRIPTOR = FieldDescriptor('none', None) + +def _labelled(obj): + """ + Returns labelled element of the object (extractor or labelled region) + """ + if hasattr(obj, "annotation"): + return obj.annotation + return obj + +def _compose(f, g): + """given unary functions f and g, return a function that computes f(g(x)) + """ + def _exec(x): + ret = g(x) + return f(ret) if ret is not None else None + return _exec + +class BasicTypeExtractor(object): + """The BasicTypeExtractor extracts single attributes corresponding to + annotations. + + For example: + >>> from scrapy.contrib.ibl.extraction.pageparsing import parse_strings + >>> template, page = parse_strings( \ + u'
y
', \ + u'description
') + >>> basic_extractors = map(BasicTypeExtractor, template.annotations) + >>> ex = RecordExtractor.apply(template, basic_extractors)[0] + >>> ex.extract(page) + [{u'description': [u'description'], u'name': [u'name']}] + """ + + def __init__(self, extractors, template_tokens): + """Construct a RecordExtractor for the given annotations and their + corresponding region extractors + """ + self.extractors = extractors + self.template_tokens = template_tokens + self.template_ignored_regions = [] + start_index = min(e.annotation.start_index for e in extractors) + end_index = max(e.annotation.end_index for e in extractors) + self.annotation = AnnotationTag(start_index, end_index) + + def extract(self, page, start_index=0, end_index=None, ignored_regions=None): + """extract data from an extraction page + + The region in the page to be extracted from may be specified using + start_index and end_index + """ + ignored_regions = [LabelledRegion(*i) for i in (ignored_regions or [])] + region_elements = sorted(self.extractors + ignored_regions, key=lambda x: _labelled(x).start_index) + _, _, attributes = self._doextract(page, region_elements, start_index, + end_index) + # collect variant data, maintaining the order of variants + variant_ids = []; variants = {}; items = [] + for k, v in attributes: + if isinstance(k, int): + if k in variants: + variants[k] += v + else: + variant_ids.append(k) + variants[k] = v + else: + items.append((k, v)) + + variant_records = [('variants', _attrs2dict(variants[vid])) \ + for vid in variant_ids] + items += variant_records + return [_attrs2dict(items)] + + def _doextract(self, page, region_elements, start_index, end_index, nested_regions=None, ignored_regions=None): + """Carry out extraction of records using the given annotations + in the page tokens bounded by start_index and end_index + """ + # reorder extractors leaving nested ones for the end and separating + # ignore regions + nested_regions = nested_regions or [] + ignored_regions = ignored_regions or [] + first_region, following_regions = region_elements[0], region_elements[1:] + while following_regions and _labelled(following_regions[0]).start_index \ + < _labelled(first_region).end_index: + region = following_regions.pop(0) + labelled = _labelled(region) + if isinstance(labelled, AnnotationTag) or (nested_regions and \ + _labelled(nested_regions[-1]).start_index < labelled.start_index \ + < _labelled(nested_regions[-1]).end_index): + nested_regions.append(region) + else: + ignored_regions.append(region) + extracted_data = [] + # end_index is inclusive, but similar_region treats it as exclusive + end_region = None if end_index is None else end_index + 1 + labelled = _labelled(first_region) + score, pindex, sindex = \ + similar_region(page.page_tokens, self.template_tokens, + labelled, start_index, end_region) + if score > 0: + if isinstance(labelled, AnnotationTag): + similar_ignored_regions = [] + start = pindex + for i in ignored_regions: + s, p, e = similar_region(page.page_tokens, self.template_tokens, \ + i, start, sindex) + if s > 0: + similar_ignored_regions.append(LabelledRegion(*(p, e))) + start = e or start + extracted_data = first_region.extract(page, pindex, sindex, similar_ignored_regions) + if extracted_data: + if first_region.annotation.variant_id: + extracted_data = [(first_region.annotation.variant_id, extracted_data)] + + if nested_regions: + _, _, nested_data = self._doextract(page, nested_regions, pindex, sindex) + extracted_data += nested_data + if following_regions: + _, _, following_data = self._doextract(page, following_regions, sindex or start_index, end_region) + extracted_data += following_data + + elif following_regions: + end_index, _, following_data = self._doextract(page, following_regions, start_index, end_region) + if end_index is not None: + pindex, sindex, extracted_data = self._doextract(page, [first_region], start_index, end_index - 1, nested_regions, ignored_regions) + extracted_data += following_data + elif nested_regions: + _, _, nested_data = self._doextract(page, nested_regions, start_index, end_region) + extracted_data += nested_data + return pindex, sindex, extracted_data + + @classmethod + def apply(cls, template, extractors): + return [cls(extractors, template.page_tokens)] + + def extracted_item(self): + return [self.__class__.__name__] + \ + sorted(e.extracted_item() for e in self.extractors) + + def __repr__(self): + return str(self) + + def __str__(self): + stream = cStringIO.StringIO() + pprint.pprint(self.extractors, stream) + stream.seek(0) + template_data = stream.read() + if template_data: + return "%s[\n%s\n]" % (self.__class__.__name__, template_data) + return "%s[none]" % (self.__class__.__name__) + +class AdjacentVariantExtractor(RecordExtractor): + """Extractor for variants + + This simply extends the RecordExtractor to output data in a "variants" + attribute. + + The "apply" method will only apply to variants whose items are all adjacent and + it will appear as one record so that it can be handled by the RepeatedDataExtractor. + """ + + def extract(self, page, start_index=0, end_index=None, ignored_regions=None): + records = RecordExtractor.extract(self, page, start_index, end_index, ignored_regions) + return [('variants', r['variants'][0]) for r in records if r] + + @classmethod + def apply(cls, template, extractors): + adjacent_variants = set([]) + variantf = lambda x: x.annotation.variant_id + for vid, egroup in groupby(extractors, variantf): + if not vid: + continue + if vid in adjacent_variants: + adjacent_variants.remove(vid) + elif len(list(egroup)) > 1: + adjacent_variants.add(vid) + + new_extractors = [] + for variant, group_seq in groupby(extractors, variantf): + group_seq = list(group_seq) + if variant in adjacent_variants: + record_extractor = AdjacentVariantExtractor(group_seq, template.page_tokens) + new_extractors.append(record_extractor) + else: + new_extractors += group_seq + return new_extractors + + def __repr__(self): + return str(self) + +class TraceExtractor(object): + """Extractor that wraps other extractors and prints an execution + trace of the extraction process to aid debugging + """ + + def __init__(self, traced, template): + self.traced = traced + self.annotation = traced.annotation + tstart = traced.annotation.start_index + tend = traced.annotation.end_index + self.tprefix = " ".join([template.token_dict.token_string(t) + for t in template.page_tokens[tstart-4:tstart+1]]) + self.tsuffix = " ".join([template.token_dict.token_string(t) + for t in template.page_tokens[tend:tend+5]]) + + def summarize_trace(self, page, start, end, ret): + text_start = page.token_follow_indexes[start] + text_end = page.token_start_indexes[end or -1] + page_snippet = "(...%s)%s(%s...)" % ( + page.text[text_start-50:text_start].replace('\n', ' '), + page.text[text_start:text_end], + page.text[text_end:text_end+50].replace('\n', ' ')) + pre_summary = "\nstart %s page[%s:%s]\n" % (self.traced.__class__.__name__, start, end) + post_summary = """ +%s page[%s:%s] + +html +%s + +annotation +...%s +%s +%s... + +extracted +%s + """ % (self.traced.__class__.__name__, start, end, page_snippet, + self.tprefix, self.annotation, self.tsuffix, [r for r in ret if 'trace' not in r]) + return pre_summary, post_summary + + def extract(self, page, start, end, ignored_regions): + ret = self.traced.extract(page, start, end, ignored_regions) + if not ret: + return [] + + # handle records by inserting a trace and combining with variant traces + if len(ret) == 1 and isinstance(ret[0], dict): + item = ret[0] + trace = item.pop('trace', []) + variants = item.get('variants', ()) + for variant in variants: + trace += variant.pop('trace', []) + pre_summary, post_summary = self.summarize_trace(page, start, end, ret) + item['trace'] = [pre_summary] + trace + [post_summary] + return ret + + pre_summary, post_summary = self.summarize_trace(page, start, end, ret) + return [('trace', pre_summary)] + ret + [('trace', post_summary)] + + @staticmethod + def apply(template, extractors): + output = [] + for extractor in extractors: + if not isinstance(extractor, TraceExtractor): + extractor = TraceExtractor(extractor, template) + output.append(extractor) + return output + + def extracted_item(self): + return self.traced.extracted_item() + + def __repr__(self): + return "Trace(%s)" % repr(self.traced) + +class TemplatePageExtractor(object): + """Top level extractor for a template page""" + + def __init__(self, template, extractors): + # fixme: handle multiple items per page + self.extractor = extractors[0] + self.template = template + + def extract(self, page, start_index=0, end_index=None): + return self.extractor.extract(page, start_index, end_index, self.template.ignored_regions) + + def __repr__(self): + return repr(self.extractor) + + def __str__(self): + return str(self.extractor) + +_tokenize = nltk.tokenize.WordPunctTokenizer().tokenize + +class TextRegionDataExtractor(object): + """Data Extractor for extracting text fragments from within a larger + body of text. It extracts based on the longest unique prefix and suffix. + + for example: + >>> extractor = TextRegionDataExtractor('designed by ', '.') + >>> extractor.extract("by Marc Newson.") + 'Marc Newson' + + Both prefix and suffix are optional: + >>> extractor = TextRegionDataExtractor('designed by ') + >>> extractor.extract("by Marc Newson.") + 'Marc Newson.' + >>> extractor = TextRegionDataExtractor(suffix='.') + >>> extractor.extract("by Marc Newson.") + 'by Marc Newson' + + It requires a minimum match of at least one word or punctuation character: + >>> extractor = TextRegionDataExtractor('designed by') + >>> extractor.extract("y Marc Newson.") is None + True + """ + def __init__(self, prefix=None, suffix=None): + self.prefix = (prefix or '')[::-1] + self.suffix = suffix or '' + self.minprefix = self.minmatch(self.prefix) + self.minsuffix = self.minmatch(self.suffix) + + @staticmethod + def minmatch(matchstring): + """the minimum number of characters that should match in order + to consider it a match for that string. + + This uses the last word of punctuation character + """ + tokens = _tokenize(matchstring or '') + return len(tokens[0]) if tokens else 0 + + def extract(self, text): + """attempt to extract a substring from the text""" + pref_index = 0 + if self.minprefix > 0: + rev_idx, plen = longest_unique_subsequence(text[::-1], self.prefix) + if plen < self.minprefix: + return None + pref_index = -rev_idx + if self.minsuffix == 0: + return text[pref_index:] + sidx, slen = longest_unique_subsequence(text[pref_index:], self.suffix) + if slen < self.minsuffix: + return None + return text[pref_index:pref_index + sidx] + +class TextPrefixRegionDataExtractor(object): + """ + Data extractor for extracting text fragment from within a + larger body of text, based on a fixed prefix. + >>> extractor = TextPrefixRegionDataExtractor("£s;") + >>> extractor.extract("£s; 17.00") + ' 17.00' + >>> extractor.extract("€ 17.00") is None + True + >>> extractor.extract("$ 17.00") is None + True + >>> extractor.extract(" £s; 17.00 ") + ' 17.00 ' + """ + def __init__(self, prefix): + self.prefix = prefix + def extract(self, text): + text = text.lstrip() + # attempt to extract a substring from the text + if text.startswith(self.prefix): + return text.replace(self.prefix, '') + diff --git a/scrapy/contrib/ibl/extraction/similarity.py b/scrapy/contrib/ibl/extraction/similarity.py new file mode 100644 index 000000000..9bb8e24c3 --- /dev/null +++ b/scrapy/contrib/ibl/extraction/similarity.py @@ -0,0 +1,136 @@ +""" +Similarity calculation for Instance based extraction algorithm. +""" +from itertools import izip, count +from operator import itemgetter +from heapq import nlargest + +def common_prefix_length(a, b): + """Calculate the length of the common prefix in both sequences passed. + + For example, the common prefix in this example is [1, 3] + >>> common_prefix_length([1, 3, 4], [1, 3, 5, 1]) + 2 + + If there is no common prefix, 0 is returned + >>> common_prefix_length([1], []) + 0 + """ + i = -1 + for i, x, y in izip(count(), a, b): + if x != y: + return i + return i + 1 + +def common_prefix(*sequences): + """determine the common prefix of all sequences passed + + For example: + >>> common_prefix('abcdef', 'abc', 'abac') + ['a', 'b'] + """ + prefix = [] + for sample in izip(*sequences): + first = sample[0] + if all(x == first for x in sample[1:]): + prefix.append(first) + else: + break + return prefix + +def longest_unique_subsequence(to_search, subsequence, range_start=0, + range_end=None): + """Find the longest unique subsequence of items in a list or array. This + searches the to_search list or array looking for the longest overlapping + match with subsequence. If the largest match is unique (there is no other + match of equivalent length), the index and length of match is returned. If + there is no match, (None, None) is returned. + + Please see section 3.2 of Extracting Web Data Using Instance-Based + Learning by Yanhong Zhai and Bing Liu + + For example, the longest match occurs at index 2 and has length 3 + >>> to_search = [6, 3, 2, 4, 3, 2, 5] + >>> longest_unique_subsequence(to_search, [2, 4, 3]) + (2, 3) + + When there are two equally long subsequences, it does not generate a match + >>> longest_unique_subsequence(to_search, [3, 2]) + (None, None) + + range_start and range_end specify a range in which the match must begin + >>> longest_unique_subsequence(to_search, [3, 2], 3) + (4, 2) + >>> longest_unique_subsequence(to_search, [3, 2], 0, 2) + (1, 2) + """ + startval = subsequence[0] + if range_end is None: + range_end = len(to_search) + + # the comparison to startval ensures only matches of length >= 1 and + # reduces the number of calls to the common_length function + matches = ((i, common_prefix_length(to_search[i:], subsequence)) \ + for i in xrange(range_start, range_end) if startval == to_search[i]) + best2 = nlargest(2, matches, key=itemgetter(1)) + # if there is a single unique best match, return that + if len(best2) == 1 or len(best2) == 2 and best2[0][1] != best2[1][1]: + return best2[0] + return None, None + +def similar_region(extracted_tokens, template_tokens, labelled_region, + range_start=0, range_end=None): + """Given a labelled section in a template, identify a similar region + in the extracted tokens. + + The start and end index of the similar region in the extracted tokens + is returned. + + This will return a tuple containing: + (match score, start index, end index) + where match score is the sum of the length of the matching prefix and + suffix. If there is no unique match, (0, None, None) will be returned. + + start_index and end_index specify a range in which the match must begin + """ + data_length = len(extracted_tokens) + if range_end is None: + range_end = data_length + # calculate the prefix score by finding a longest subsequence in + # reverse order + reverse_prefix = template_tokens[labelled_region.start_index::-1] + reverse_tokens = extracted_tokens[::-1] + (rpi, pscore) = longest_unique_subsequence(reverse_tokens, reverse_prefix, + data_length - range_end, data_length - range_start) + + # None means nothing exracted. Index 0 means there cannot be a suffix. + if not rpi: + return 0, None, None + + # convert to an index from the start instead of in reverse + prefix_index = len(extracted_tokens) - rpi - 1 + + if labelled_region.end_index is None: + return pscore, prefix_index, None + + suffix = template_tokens[labelled_region.end_index:] + + # if it's not a paired tag, use the best match between prefix & suffix + if labelled_region.start_index == labelled_region.end_index: + (match_index, sscore) = longest_unique_subsequence(extracted_tokens, + suffix, prefix_index, range_end) + if match_index == prefix_index: + return (pscore + sscore, prefix_index, match_index) + elif pscore > sscore: + return pscore, prefix_index, prefix_index + elif sscore > pscore: + return sscore, match_index, match_index + return 0, None, None + + # calculate the suffix match on the tokens following the prefix. We could + # consider the whole page and require a good match. + (match_index, sscore) = longest_unique_subsequence(extracted_tokens, + suffix, prefix_index + 1, range_end) + if match_index is None: + return 0, None, None + return (pscore + sscore, prefix_index, match_index) diff --git a/scrapy/contrib/ibl/extractors.py b/scrapy/contrib/ibl/extractors.py new file mode 100644 index 000000000..9c460df10 --- /dev/null +++ b/scrapy/contrib/ibl/extractors.py @@ -0,0 +1,156 @@ +""" +Extractors for attributes +""" +import re +import urlparse + +from scrapy.utils.markup import remove_entities +from scrapy.utils.url import safe_url_string + +#FIXME: the use of "." needs to be localized +_NUMERIC_ENTITIES = re.compile("([0-9]+)(?:;|\s)", re.U) +_PRICE_NUMBER_RE = re.compile('(?:^|[^a-zA-Z0-9])(\d+(?:\.\d+)?)(?:$|[^a-zA-Z0-9])') +_NUMBER_RE = re.compile('(\d+(?:\.\d+)?)') + +_IMAGES = ( + 'mng', 'pct', 'bmp', 'gif', 'jpg', 'jpeg', 'png', 'pst', 'psp', 'tif', + 'tiff', 'ai', 'drw', 'dxf', 'eps', 'ps', 'svg', +) + +_IMAGES_TYPES = '|'.join(_IMAGES) +_CSS_IMAGERE = re.compile("background(?:-image)?\s*:\s*url\((.*?)\)", re.I) +_BASE_PATH_RE = "/?(?:[^/]+/)*(?:.+%s)" +_IMAGE_PATH_RE = re.compile(_BASE_PATH_RE % '\.(?:%s)' % _IMAGES_TYPES, re.I) +_GENERIC_PATH_RE = re.compile(_BASE_PATH_RE % '', re.I) + +def text(txt): + stripped = txt.strip() if txt else None + if stripped: + return stripped + +def contains_any_numbers(txt): + """text that must contain at least one number + >>> contains_any_numbers('foo') + >>> contains_any_numbers('$67 at 15% discount') + '$67 at 15% discount' + """ + if _NUMBER_RE.search(txt) is not None: + return txt + +def contains_prices(txt): + """text must contain a number that is not joined to text""" + if _PRICE_NUMBER_RE.findall(txt) is not None: + return txt + +def contains_numbers(txt, count=1): + """Must contain a certain amount of numbers + + >>> contains_numbers('foo', 2) + >>> contains_numbers('this 1 has 2 numbers', 2) + 'this 1 has 2 numbers' + """ + numbers = _NUMBER_RE.findall(txt) + if len(numbers) == count: + return txt + +def extract_number(txt): + """Extract a numeric value. + + This will fail if more than one numeric value is present. + + >>> extract_number(' 45.3') + '45.3' + >>> extract_number(' 45.3, 7') + + It will handle unescaped entities: + >>> extract_number(u'£129.99') + u'129.99' + """ + txt = _NUMERIC_ENTITIES.sub(lambda m: unichr(int(m.groups()[0])), txt) + numbers = _NUMBER_RE.findall(txt) + if len(numbers) == 1: + return numbers[0] + +def url(txt): + """convert text to a url + + this is quite conservative, since relative urls are supported + """ + txt = txt.strip("\t\r\n '\"") + if txt: + return txt + +def image_url(txt): + """convert text to a url + + this is quite conservative, since relative urls are supported + Example: + + >>> image_url('') + + >>> image_url(' ') + + >>> image_url(' \\n\\n ') + + >>> image_url('foo-bar.jpg') + ['foo-bar.jpg'] + >>> image_url('/images/main_logo12.gif') + ['/images/main_logo12.gif'] + >>> image_url("http://www.image.com/image.jpg") + ['http://www.image.com/image.jpg'] + >>> image_url("http://www.domain.com/path1/path2/path3/image.jpg") + ['http://www.domain.com/path1/path2/path3/image.jpg'] + >>> image_url("/path1/path2/path3/image.jpg") + ['/path1/path2/path3/image.jpg'] + >>> image_url("path1/path2/image.jpg") + ['path1/path2/image.jpg'] + >>> image_url("background-image : url(http://www.site.com/path1/path2/image.jpg)") + ['http://www.site.com/path1/path2/image.jpg'] + >>> image_url("background-image : url('http://www.site.com/path1/path2/image.jpg')") + ['http://www.site.com/path1/path2/image.jpg'] + >>> image_url('background-image : url("http://www.site.com/path1/path2/image.jpg")') + ['http://www.site.com/path1/path2/image.jpg'] + >>> image_url("background : url(http://www.site.com/path1/path2/image.jpg)") + ['http://www.site.com/path1/path2/image.jpg'] + >>> image_url("background : url('http://www.site.com/path1/path2/image.jpg')") + ['http://www.site.com/path1/path2/image.jpg'] + >>> image_url('background : url("http://www.site.com/path1/path2/image.jpg")') + ['http://www.site.com/path1/path2/image.jpg'] + >>> image_url('/getimage.php?image=totalgardens/outbbq2_400.jpg&type=prod&resizeto=350') + ['/getimage.php?image=totalgardens/outbbq2_400.jpg&type=prod&resizeto=350'] + >>> image_url('http://www.site.com/getimage.php?image=totalgardens/outbbq2_400.jpg&type=prod&resizeto=350') + ['http://www.site.com/getimage.php?image=totalgardens/outbbq2_400.jpg&type=prod&resizeto=350'] + >>> image_url('http://s7d4.scene7.com/is/image/Kohler/jaa03267?hei=425&wid=457&op_usm=2,1,2,1&qlt=80') + ['http://s7d4.scene7.com/is/image/Kohler/jaa03267?hei=425&wid=457&op_usm=2,1,2,1&qlt=80'] + >>> image_url('../image.aspx?thumb=true&boxSize=175&img=Unknoportrait[1].jpg') + ['../image.aspx?thumb=true&boxSize=175&img=Unknoportrait%5B1%5D.jpg'] + >>> image_url('http://www.sundancecatalog.com/mgen/catalog/test.ms?args=%2245932|MERIDIAN+PENDANT|.jpg%22&is=336,336,0xffffff') + ['http://www.sundancecatalog.com/mgen/catalog/test.ms?args=%2245932|MERIDIAN+PENDANT|.jpg%22&is=336,336,0xffffff'] + >>> image_url('http://www.site.com/image.php') + ['http://www.site.com/image.php'] + >>> image_url('background-image:URL(http://s7d5.scene7.com/is/image/wasserstrom/165133?wid=227&hei=227&defaultImage=noimage_wasserstrom)') + ['http://s7d5.scene7.com/is/image/wasserstrom/165133?wid=227&hei=227&defaultImage=noimage_wasserstrom'] + + """ + txt = url(txt) + imgurl = None + if txt: + # check if the text is style content + m = _CSS_IMAGERE.search(txt) + txt = m.groups()[0] if m else txt + parsed = urlparse.urlparse(txt) + path = None + m = _IMAGE_PATH_RE.search(parsed.path) + if m: + path = m.group() + elif parsed.query: + m = _GENERIC_PATH_RE.search(parsed.path) + if m: + path = m.group() + if path is not None: + parsed = list(parsed) + parsed[2] = path + imgurl = urlparse.urlunparse(parsed) + if not imgurl: + imgurl = txt + return [safe_url_string(remove_entities(url(imgurl)))] if imgurl else None diff --git a/scrapy/contrib/ibl/htmlpage.py b/scrapy/contrib/ibl/htmlpage.py new file mode 100644 index 000000000..daeef4053 --- /dev/null +++ b/scrapy/contrib/ibl/htmlpage.py @@ -0,0 +1,212 @@ +""" +htmlpage + +Container object for representing html pages in the IBL system. This +encapsulates page related information and prevents parsing multiple times. +""" +import re +import hashlib + +from scrapy.utils.python import str_to_unicode + +def create_page_from_jsonpage(jsonpage, body_key): + """Create an HtmlPage object from a dict object conforming to the schema + for a page + + `body_key` is the key where the body is stored and can be either 'body' + (original page with annotations - if any) or 'original_body' (original + page, always). Classification typically uses 'original_body' to avoid + confusing the classifier with annotated pages, while extraction uses 'body' + to pass the annotated pages. + """ + url = jsonpage['url'] + headers = jsonpage.get('headers') + body = str_to_unicode(jsonpage[body_key]) + page_id = jsonpage.get('page_id') + return HtmlPage(url, headers, body, page_id) + +class HtmlPage(object): + def __init__(self, url=None, headers=None, body=None, page_id=None): + assert isinstance(body, unicode), "unicode expected, got: %s" % type(body).__name__ + self.headers = headers or {} + self.body = body + self.url = url or u'' + if page_id is None and url: + self.page_id = hashlib.sha1(url).hexdigest() + else: + self.page_id = page_id + + + def _set_body(self, body): + self._body = body + self.parsed_body = list(parse_html(body)) + + body = property(lambda x: x._body, _set_body) + + def fragment_data(self, data_fragment): + return self.body[data_fragment.start:data_fragment.end] + +class HtmlTagType(object): + OPEN_TAG = 1 + CLOSE_TAG = 2 + UNPAIRED_TAG = 3 + +class HtmlDataFragment(object): + __slots__ = ('start', 'end') + + def __init__(self, start, end): + self.start = start + self.end = end + + def __str__(self): + return "introduction
+
+
+This is such a nice item
Everybody likes it.
+
click here for other items
+ +""" + +EXTRACT_PAGE1 = u""" + +introduction
+
+wonderful product
+xx
+description
+this is not the description
+""" + +# test inferring repeated elements +ANNOTATED_PAGE4 = u""" +description
+| colour 1 | +price 1 | +
| colour 2 | +price 2 | +
description
+| colour 1 | +price 1 | +
| colour 2 | +price 2 | +
| colour 3 | +price 3 | +
description
+name 1
+name 2
+""" +EXTRACT_PAGE6 = u""" +description
+name 1
+name 2
+""" + +# test repeating variants at the table column level +ANNOTATED_PAGE7 = u""" +| colour 1 | +colour 2 | +
| price 1 | +price 2 | +
| colour 1 | +colour 2 | +colour 3 | +
| price 1 | +price 2 | +price 3 | +
XXXX XXXX xxxxx
+ +13
+A very nice product for all intelligent people
+ +ID 15
+(VAT exc.)
+A very nice product for all intelligent people
+ +ID 16
+(VAT exc.)| SKU | L345 | +
| Size | 10cmx20cm | +
| Price | £s;99.00 | +
| SKU | S220 | +
| Size | 20cmx20cm | +
| Price | £s;85.00 | +
| Offer | From $2500.00 | +
| Description | Electrorheological Cyborgs | +
| Series: | T2000 | +
| SKU | K80 | +
| Size | 50cm | +
| Price | &euros;85.00 | +
+
+SL342
+
+
+Nice product for ladies
+
+£s;85.00
+
+
+SL342
+
+Nice product for ladies
+
+£s;85.00
+
A very nice product for all intelligent people
+ +ID 15
+(VAT exc.) +A very nice product for all intelligent people
+ +ID 15
+(VAT exc.) ++
+
+
+
+
+
+
+
Description +90.00 +
+ +""" + +EXTRACT_PAGE15 = u""" + +Description +80.00 +
+ +""" + +ANNOTATED_PAGE16 = u""" + ++name
++80.00
+product name
+90.00
+ +""" + +ANNOTATED_PAGE17 = u""" + + ++This product is excelent. Buy it! +
+ +
+
+Product b +
+ + +""" + +EXTRACT_PAGE17 = u""" + + ++This product is excelent. Buy it! +
+ +
+
+Product B +
+ + +""" + +ANNOTATED_PAGE18 = u""" + +Product name
+60.00
+
+description
+Product name
+60.00
+
+description
+Range
+from 20.00
+
+
+
+
"))
+ self.assertEqual(parsed[0].attributes, {'src': 'http://images.play.com/banners/SAM550a.jpg', \
+ 'align': 'left', 'hspace': '5', '/': None})
diff --git a/scrapy/tests/test_contrib_ibl/test_htmlpage_data.py b/scrapy/tests/test_contrib_ibl/test_htmlpage_data.py
new file mode 100644
index 000000000..2eac24f26
--- /dev/null
+++ b/scrapy/tests/test_contrib_ibl/test_htmlpage_data.py
@@ -0,0 +1,227 @@
+PAGE = u"""
+
+
+
+introduction
+
+
+This is such a nice item
Everybody likes it.
+
Power: 50W
+ +click here for other items
+| Height | +32.00 | +
+""" + +PARSED = [ +{'start': 0, 'end': 1}, +{'attributes': {'type': 'text/css', 'id': 'scrapy-style'}, 'tag': 'style', 'end': 42, 'start': 1, 'tag_type': 1}, +{'start': 42, 'end': 129}, +{'attributes': {}, 'tag': 'style', 'end': 137, 'start': 129, 'tag_type': 2}, +{'start': 137, 'end': 138}, +{'attributes': {}, 'tag': 'body', 'end': 144, 'start': 138, 'tag_type': 1}, +{'start': 144, 'end': 145}, +{'attributes': {'class': 'scrapy-selected', 'id': 'header'}, 'tag': 'div', 'end': 186, 'start': 145, 'tag_type': 1}, +{'start': 186, 'end': 187}, +{'attributes': {'src': 'company_logo.jpg', 'style': 'margin-left: 68px; padding-top:5px;', 'width': '530', 'alt': 'Logo', 'height': '105'}, 'tag': 'img', 'end': 295, 'start': 187, 'tag_type': 1}, +{'start': 295, 'end': 296}, +{'attributes': {'id': 'vertrule'}, 'tag': 'div', 'end': 315, 'start': 296, 'tag_type': 1}, +{'start': 315, 'end': 316}, +{'attributes': {}, 'tag': 'h1', 'end': 320, 'start': 316, 'tag_type': 1}, +{'start': 320, 'end': 330}, +{'attributes': {'data-scrapy-annotate': '{"variant": "0", "generated": true, "annotations": {"content": "title"}}'}, 'tag': 'ins', 'end': 491, 'start': 330, 'tag_type': 1}, +{'start': 491, 'end': 501}, +{'attributes': {}, 'tag': 'ins', 'end': 507, 'start': 501, 'tag_type': 2}, +{'attributes': {}, 'tag': 'h1', 'end': 512, 'start': 507, 'tag_type': 2}, +{'start': 512, 'end': 513}, +{'attributes': {}, 'tag': 'p', 'end': 516, 'start': 513, 'tag_type': 1}, +{'start': 516, 'end': 528}, +{'attributes': {}, 'tag': 'p', 'end': 532, 'start': 528, 'tag_type': 2}, +{'start': 532, 'end': 533}, +{'attributes': {}, 'tag': 'div', 'end': 538, 'start': 533, 'tag_type': 1}, +{'start': 538, 'end': 539}, +{'attributes': {'classid': None, 'src': '/upload/img.jpg', 'data-scrapy-annotate': '{"variant": "0", "annotations": {"image_url": "src"}}'}, 'tag': 'img', 'end': 709, 'start': 539, 'tag_type': 1}, +{'start': 709, 'end': 710}, +{'attributes': {'classid': None, 'data-scrapy-annotate': '{"variant": "0", "annotations": {"content": "description"}}'}, 'tag': 'p', 'end': 858, 'start': 710, 'tag_type': 1}, +{'start': 858, 'end': 883}, +{'attributes': {}, 'tag': 'br', 'end': 888, 'start': 883, 'tag_type': 3}, +{'start': 888, 'end': 909}, +{'attributes': {}, 'tag': 'p', 'end': 913, 'start': 909, 'tag_type': 2}, +{'start': 913, 'end': 914}, +{'attributes': {}, 'tag': 'br', 'end': 918, 'start': 914, 'tag_type': 1}, +{'attributes': {}, 'tag': 'br', 'end': 923, 'start': 918, 'tag_type': 2}, +{'start': 923, 'end': 924}, +{'attributes': {}, 'tag': 'div', 'end': 930, 'start': 924, 'tag_type': 2}, +{'start': 930, 'end': 931}, +{'attributes': {'data-scrapy-annotate': '{"variant": "0", "annotations": {"content": "features"}}', 'class': None}, 'tag': 'p', 'end': 1074, 'start': 931, 'tag_type': 1}, +{'start': 1074, 'end': 1084}, +{'attributes': {}, 'tag': 'p', 'end': 1088, 'start': 1084, 'tag_type': 2}, +{'start': 1088, 'end': 1109}, +{'attributes': {'data-scrapy-replacement': 'select', 'class': 'product'}, 'tag': 'ul', 'end': 1162, 'start': 1109, 'tag_type': 1}, +{'start': 1162, 'end': 1163}, +{'attributes': {'data-scrapy-replacement': 'option'}, 'tag': 'li', 'end': 1200, 'start': 1163, 'tag_type': 1}, +{'start': 1200, 'end': 1205}, +{'attributes': {}, 'tag': 'li', 'end': 1210, 'start': 1205, 'tag_type': 2}, +{'start': 1210, 'end': 1211}, +{'attributes': {'data-scrapy-replacement': 'option'}, 'tag': 'li', 'end': 1248, 'start': 1211, 'tag_type': 1}, +{'start': 1248, 'end': 1251}, +{'attributes': {}, 'tag': 'li', 'end': 1256, 'start': 1251, 'tag_type': 2}, +{'start': 1256, 'end': 1257}, +{'attributes': {}, 'tag': 'ul', 'end': 1262, 'start': 1257, 'tag_type': 2}, +{'start': 1262, 'end': 1263}, +{'attributes': {}, 'tag': 'p', 'end': 1266, 'start': 1263, 'tag_type': 1}, +{'start': 1266, 'end': 1292}, +{'attributes': {}, 'tag': 'p', 'end': 1296, 'start': 1292, 'tag_type': 2}, +{'start': 1296, 'end': 1297}, +{'attributes': {}, 'tag': 'h3', 'end': 1301, 'start': 1297, 'tag_type': 1}, +{'start': 1301, 'end': 1312}, +{'attributes': {}, 'tag': 'h3', 'end': 1317, 'start': 1312, 'tag_type': 2}, +{'start': 1317, 'end': 1318}, +{'attributes': {'cellpadding': '0', 'width': '420', 'cellspacing': '0', 'class': 'rulet'}, 'tag': 'table', 'end': 1383, 'start': 1318, 'tag_type': 1}, +{'attributes': {}, 'tag': 'tbody', 'end': 1390, 'start': 1383, 'tag_type': 1}, +{'start': 1390, 'end': 1391}, +{'attributes': {}, 'tag': 'tr', 'end': 1395, 'start': 1391, 'tag_type': 1}, +{'attributes': {}, 'tag': 'td', 'end': 1399, 'start': 1395, 'tag_type': 1}, +{'start': 1399, 'end': 1405}, +{'attributes': {}, 'tag': 'td', 'end': 1410, 'start': 1405, 'tag_type': 2}, +{'start': 1410, 'end': 1411}, +{'attributes': {}, 'tag': 'td', 'end': 1415, 'start': 1411, 'tag_type': 1}, +{'attributes': {'data-scrapy-annotate': '{"variant": "0", "generated": true, "annotations": {"content": "price"}}'}, 'tag': 'ins', 'end': 1576, 'start': 1415, 'tag_type': 1}, +{'start': 1576, 'end': 1581}, +{'attributes': {}, 'tag': 'ins', 'end': 1587, 'start': 1581, 'tag_type': 2}, +{'attributes': {}, 'tag': 'td', 'end': 1592, 'start': 1587, 'tag_type': 2}, +{'start': 1592, 'end': 1593}, +{'attributes': {}, 'tag': 'tr', 'end': 1598, 'start': 1593, 'tag_type': 2}, +{'attributes': {}, 'tag': 'tbody', 'end': 1605, 'start': 1598, 'tag_type': 1}, +{'attributes': {}, 'tag': 'table', 'end': 1613, 'start': 1605, 'tag_type': 2}, +{'start': 1613, 'end': 1614}, +{'attributes': {'style': 'my style', 'onmouseover': 'xxx', 'class': None}, 'tag': 'p', 'end': 1659, 'start': 1614, 'tag_type': 1}, +{'start': 1659, 'end': 1660}, +] + +# for testing parsing of some invalid html code (but still managed by browsers) +PAGE2 = u""" + +
++Hello world! + +
+ + +""" + +PARSED2 = [ + {'end': 1, 'start': 0}, + {'attributes': {}, 'end': 7, 'start': 1, 'tag': u'html', 'tag_type': 1}, + {'end': 8, 'start': 7}, + {'attributes': {}, 'end': 14, 'start': 8, 'tag': u'body', 'tag_type': 1}, + {'end': 15, 'start': 14}, + {'attributes': {u'style': u'"margin:', u'0pt"': None, u'class': u'"MsoNormal"', u'0cm': None}, 'end': 80, 'start': 15, 'tag': u'p', 'tag_type': 2}, + {'attributes': {u'lang': u'"EN-GB"'}, 'end': 107, 'start': 80, 'tag': u'span', 'tag_type': 1}, + {'end': 121, 'start': 107}, + {'attributes': {}, 'end': 128, 'start': 121, 'tag': u'span', 'tag_type': 2}, + {'end': 129, 'start': 128}, + {'attributes': {}, 'end': 133, 'start': 129, 'tag': u'p', 'tag_type': 2}, + {'end': 134, 'start': 133}, + {'attributes': {}, 'end': 141, 'start': 134, 'tag': u'body', 'tag_type': 2}, + {'end': 142, 'start': 141}, + {'attributes': {}, 'end': 149, 'start': 142, 'tag': u'html', 'tag_type': 2}, + {'end': 150, 'start': 149}, +] + +# for testing tags inside comments +PAGE3 = u"""Did i say hello??
""" + +PARSED3 = [ + {'attributes': {}, 'end': 6, 'start': 0, 'tag': u'html', 'tag_type': 1}, + {'attributes': {}, 'end': 12, 'start': 6, 'tag': u'body', 'tag_type': 1}, + {'attributes': {}, 'end': 16, 'start': 12, 'tag': u'h1', 'tag_type': 1}, + {'end': 26, 'start': 16}, + {'attributes': {}, 'end': 31, 'start': 26, 'tag': u'h1', 'tag_type': 2}, + {'attributes': {}, 'end': 34, 'start': 31, 'tag': u'p', 'tag_type': 1}, + {'end': 51, 'start': 34}, + {'attributes': {}, 'end': 55, 'start': 51, 'tag': u'p', 'tag_type': 2}, + {'end': 70, 'start': 55}, + {'attributes': {u'type': u'text/javascript'}, 'end': 101, 'start': 70, 'tag': u'script', 'tag_type': 1}, + {'end': 124, 'start': 101}, + {'attributes': {}, 'end': 133, 'start': 124, 'tag': u'script', 'tag_type': 2}, + {'attributes': {}, 'end': 140, 'start': 133, 'tag': u'body', 'tag_type': 2}, + {'attributes': {}, 'end': 147, 'start': 140, 'tag': u'html', 'tag_type': 2} +] + +# for testing tags inside scripts +PAGE4 = u"""this is a test
+""" + +LABELLED_PAGE1 = u""" + +some stuff
+
+This is such a nice item
+Everybody likes it.
+
+Old fashioned product +
+For exigent individuals +
click here for other items
+ +""" + +BROKEN_PAGE = u""" +html parser cannot parse this
+""" + +LABELLED_PAGE2 = u""" + +A very nice product for all intelligent people
+ + 13
+
+
+
+
+
+"""
+
+LABELLED_PAGE3 = u"""
+
+A very nice product for all intelligent people
+ + 13
| Description 1 | +Description 2 | +Description 3 | +Description 4 | +
A very nice product for all intelligent people
+ + 13
| Description 1 | +Description 2 | +Description 3 | ++Price \xa310.00 | +
+65.00pounds
+Description: +Text B
+Text C + +""" + +LABELLED_PAGE7 = u""" + +', '
', '']) + + # open/closing tag handling + openp = lambda x: pp.token_dict.token_type(x) == TokenType.OPEN_TAG + self.assertEqual(_tags(pp, openp), ['', '']) + closep = lambda x: pp.token_dict.token_type(x) == TokenType.CLOSE_TAG + self.assertEqual(_tags(pp, closep), ['
', '']) + + def _validate_annotation(self, parser, lable_region, name, start_tag, end_tag): + assert lable_region.surrounds_attribute == name + start_token = parser.token_list[lable_region.start_index] + assert parser.token_dict.token_string(start_token) == start_tag + end_token = parser.token_list[lable_region.end_index] + assert parser.token_dict.token_string(end_token) == end_tag + + def test_template_parsing(self): + lp = _parse_page(TemplatePageParser, LABELLED_PAGE1) + self.assertEqual(len(lp.annotations), 5) + self._validate_annotation(lp, lp.annotations[0], + 'name', '', '
') + self.assertEqual(lp.annotations[1].match_common_prefix, False) + self._validate_annotation(lp, lp.annotations[2], + 'price', '', '') + self.assertEqual(lp.annotations[2].match_common_prefix, True) + self._validate_annotation(lp, lp.annotations[3], + 'short_description', '
', '
') + self.assertEqual(lp.annotations[3].match_common_prefix, False) + self._validate_annotation(lp, lp.annotations[4], + 'short_description', '
', '
') + self.assertEqual(lp.annotations[4].match_common_prefix, False) + + # all tags were closed + self.assertEqual(len(lp.labelled_tag_stacks), 0) + + def test_extraction_page_parsing(self): + epp = _parse_page(ExtractionPageParser, SIMPLE_PAGE) + ep = epp.to_extraction_page() + assert len(ep.page_tokens) == 4 + assert ep.token_html(0) == '' + assert ep.token_html(1) == '
' + + assert ep.html_between_tokens(1, 2) == 'this is a test' + assert ep.html_between_tokens(1, 3) == 'this is a test
' + + def test_invalid_html(self): + p = _parse_page(InstanceLearningParser, BROKEN_PAGE) + assert p + + def test_ignore_region(self): + """Test ignored regions""" + p = _parse_page(TemplatePageParser, LABELLED_PAGE2) + self.assertEqual(p.ignored_regions, [(7,12),(15,17),(19,26),(21,22),(27,28),(28,29),(29,None),(30,None)]) + self.assertEqual(len(p.ignored_tag_stacks), 0) + + def test_ignore_regions2(self): + """Test ignore-beneath regions""" + p = _parse_page(TemplatePageParser, LABELLED_PAGE3) + self.assertEqual(p.ignored_regions, [(7,12),(15,17),(22,None)]) + self.assertEqual(len(p.ignored_tag_stacks), 0) + + def test_ignore_regions3(self): + """Test ignore-beneath with annotation inside region""" + p = _parse_page(TemplatePageParser, LABELLED_PAGE4) + self.assertEqual(p.ignored_regions, [(15,17),(22,None)]) + self.assertEqual(len(p.ignored_tag_stacks), 0) + + def test_replacement(self): + """Test parsing of replacement tags""" + p = _parse_page(TemplatePageParser, LABELLED_PAGE5) + self.assertEqual(_tags(p, bool), ['', '', '', '', '']) + + def test_partial(self): + """Test partial annotation parsing""" + p = _parse_page(TemplatePageParser, LABELLED_PAGE6) + text = p.annotations[0].annotation_text + self.assertEqual(text.start_text, '') + self.assertEqual(text.follow_text, 'pounds') + text = p.annotations[1].annotation_text + self.assertEqual(text.start_text, "Description: ") + self.assertEqual(text.follow_text, '') + + def test_ignored_partial(self): + """Test ignored region declared on partial annotation""" + p = _parse_page(TemplatePageParser, LABELLED_PAGE7) + self.assertEqual(p.ignored_regions, [(2, 3)]) + + def test_extra_required(self): + """Test parsing of extra required attributes""" + p = _parse_page(TemplatePageParser, LABELLED_PAGE8) + self.assertEqual(p.extra_required_attrs, ["description"]) + + def test_site_pages(self): + """ + Tests from real pages. More reliable and easy to build for more complicated structures + """ + samples_file = open(os.path.join(path, "samples_pageparsing.json.gz"), "r") + samples = [] + for line in GzipFile(fileobj=StringIO(samples_file.read())).readlines(): + samples.append(json.loads(line)) + for sample in samples: + source = sample["annotated"] + annotations = sample["annotations"] + template = HtmlPage(body=str_to_unicode(source)) + parser = TemplatePageParser(TokenDict()) + parser.feed(template) + for annotation in parser.annotations: + test_annotation = annotations.pop(0) + for s in annotation.__slots__: + if s == "tag_attributes": + for pair in getattr(annotation, s): + self.assertEqual(list(pair), test_annotation[s].pop(0)) + else: + self.assertEqual(getattr(annotation, s), test_annotation[s]) + self.assertEqual(annotations, [])