ParseHTMLTables
Find tables inside HTML and extract the contents into objects using the following logic: - If table has 2 columns and has no header row, treat the first column as key and second as value and create a table of key/value - If table has a header row, create a table of objects where attribute names are the headers - If table does not have a header row, create table of objects where attribute names are cell1, cell2, cell3...
python · Filters And Transformers
Source
import copy from collections.abc import Generator from typing import Any import demistomock as demisto # noqa: F401 from bs4 import BeautifulSoup, NavigableString, Tag from CommonServerPython import * # noqa: F401 TITLE_THRESHOLD = 4 class Table: def __init__(self, title: str): self.__title = title self.__headers: list[str] = [] self.__rows: list[tuple[list[str], list[str]]] = [] self.__rowspan_labels: list[tuple[int, str]] = [] def __set_rowspan_labels(self, columns: list[Tag] | None): if not columns or not any(col.attrs.get("rowspan") for col in columns): return rowspan_labels: list[tuple[int, str]] = [] for col in columns: try: rowspan = int(col.attrs.get("rowspan") or 1) except Exception: rowspan = 1 rowspan = max(1, rowspan) try: colspan = int(col.attrs.get("colspan") or 1) except Exception: colspan = 1 colspan = max(1, colspan) rowspan_labels += [(rowspan, col.text.strip())] * colspan self.__rowspan_labels = rowspan_labels def get_title(self) -> str: return self.__title def set_header_labels(self, headers: list[Tag]): self.__headers = [header.text.strip() for header in headers] def get_header_labels(self) -> list[str]: return self.__headers def add_row(self, columns: list[Tag], labels: list[Tag] | None = None): """ Add a row with cells and labels. :param columns: List of data cells of the row. :param labels: List of header cells of the row. """ rowspan_labels = self.__rowspan_labels # Normalize labels if labels and any(label.attrs.get("rowspan") for label in labels): self.__set_rowspan_labels(labels) normalized_labels = [] if labels: for _i, (count, label) in enumerate(rowspan_labels): if count >= 2: normalized_labels.append(label) for label in labels: try: colspan = int(label.attrs.get("colspan") or 1) except Exception: colspan = 1 normalized_labels += [label.text.strip()] * max(1, colspan) # Normalize columns if any(col.attrs.get("rowspan") for col in columns): self.__set_rowspan_labels(columns) normalized_columns = [] for i, (count, label) in enumerate(rowspan_labels): if count >= 2: normalized_columns.append(label) rowspan_labels[i] = count - 1, label for col in columns: try: colspan = int(col.attrs.get("colspan") or 1) except Exception: colspan = 1 normalized_columns += [col.text.strip()] * max(1, colspan) self.__rows.append((normalized_labels, normalized_columns)) def get_rows(self) -> list[tuple[list[str], list[str]]]: return self.__rows def make_pretty_table_rows(self, default_header_line: str | None = None) -> Any: """ Format a table :param default_header_line: Which table line handles as header by default, 'first_column' or 'first_row' :return: The table formatted in JSON structure. """ rows: list[str | dict[str, Any]] = [] temp_row: dict[str, Any] = {} tbl_rows = self.__rows headers = self.__headers if ( default_header_line and default_header_line != "none" and (not headers and not any(labels for labels, cols in tbl_rows)) ): if default_header_line in ("first_column", "first_row"): # The first column or row is considered as header if default_header_line == "first_column": # transpose tbl_rows = [([], list(cols)) for cols in zip(*[cols for labels, cols in tbl_rows])] labels, headers = tbl_rows[0] tbl_rows = tbl_rows[1:] else: raise ValueError(f"Unknown default header line: {default_header_line}") for labels, cols in tbl_rows: labels = labels[-1:] headers = labels + headers[len(labels) : len(headers) - len(labels)] if not cols: continue elif len(cols) == 1: if len(headers) >= 1: # If there 1 header and 1 column, treat as key-value key = headers[0] vals = temp_row.get(key) if vals is None: temp_row[key] = cols[0] elif type(vals) is list: temp_row[key] = vals + [cols[0]] else: temp_row[key] = [vals, cols[0]] else: if temp_row: rows.append(temp_row) temp_row = {} # Single value in a table - just create an array of strings rows.append(cols[0]) elif len(cols) == 2 and len(headers) == 0: # If there are 2 columns and no headers, treat as key-value key = cols[0] vals = temp_row.get(key) if vals is None: temp_row[key] = cols[1] elif type(vals) is list: temp_row[key] = vals + [cols[1]] else: temp_row[key] = [vals, cols[1]] else: if temp_row: rows.append(temp_row) temp_row = {} rows.append({headers[i] if i < len(headers) else "cell" + str(i): col for i, col in enumerate(cols)}) if temp_row: rows.append(temp_row) if len(rows) == 1 and type(rows[0]) is dict: return rows[0] return rows def find_table_title( base: BeautifulSoup | Tag | NavigableString | None, node: BeautifulSoup | Tag | NavigableString ) -> str | None: """ Search for a table title from a node. :param base: The top node of the tree. :param node: The node from which searching starts. :return: A title found. """ title = "" orig = node prev = node.previous_element while prev and node is not base: node = prev if isinstance(node, Tag) and node.name in ("h1", "h2", "h3", "h4", "h5", "h6"): title = " ".join(node.text.strip().split()) break prev = node.previous_element if not title or title.count(" ") >= TITLE_THRESHOLD: message = "" node = orig prev = node.previous_element while prev and node is not base: node = prev if isinstance(node, NavigableString): message = (str(node) if message else str(node).rstrip()) + message if message.lstrip() and any(c in message for c in ("\n", "\r")): break prev = node.previous_element message = " ".join(message.strip().split()) title = title if title and message.count(" ") >= title.count(" ") else message return title def list_columns(node: BeautifulSoup | Tag | NavigableString, name: str) -> list[Tag]: """ List columns of the row. :param node: The node which contains columns of the row. :param name: The name of the tag of columns. :return: The list of columns. """ vals = [] ancestor = node name_list = ["table", "td", "th", name] node = node.find(name_list) while node and is_descendant(ancestor, node): if node.name in name_list: if node.name == name: tnode = copy.copy(node) for t in tnode.find_all("table"): t.decompose() vals.append(tnode) node = node.find_next_sibling(True) else: node = node.find_next(name_list) return vals def is_descendant( ancestor: BeautifulSoup | Tag | NavigableString | None, node: BeautifulSoup | Tag | NavigableString | None ) -> bool: """ Check if a node is descendant in the tree. :param ancestor: The ancestor node. :param node: The node to be checked. :return: True - node is descendant, False - node is not descendant. """ return ancestor is not None and node is not None and any(ancestor is p for p in node.parents) def parse_table( base: BeautifulSoup | Tag | NavigableString | None, table_node: BeautifulSoup | Tag | NavigableString ) -> Generator[Table, None, None]: """ Parse a HTML table and enumerate tables found in the table. :param base: The top node of the HTML tree. :param table_node: The table node to parse. :return: Tables found. """ table = Table(title=find_table_title(base, table_node) or "No Title") has_nested_tables = False node = table_node.find(["table", "tr"]) while node and is_descendant(table_node, node): if node.name == "tr": ths = list_columns(node, "th") tds = list_columns(node, "td") if tds: table.add_row(columns=tds, labels=ths) if ths and not table.get_header_labels(): table.set_header_labels(ths) node = node.find_next(["table", "tr"]) elif node.name == "table": has_nested_tables = True yield from parse_table(base, node) base = node.previous_element node = node.find_next_sibling(True) else: node = node.find_next(["table", "tr"]) # Not to make a table if tr only has tables has_table = True if has_nested_tables: rows = table.get_rows() if len(rows) == 1: labels, cols = rows[0] if len(cols) == 1 and not cols[0]: has_table = False if has_table: yield table def parse_tables(node: BeautifulSoup | Tag | NavigableString) -> Generator[Table, None, None]: """ Parse HTML tables and enumerate them. :param node: The node from which searching starts. :return: Tables found. """ base = None node = node.find("table") while node: yield from parse_table(base, node) base = node.next_sibling while node: next = node.find_next_sibling(True) if next: if next.name == "table": break next = next.find_next("table") if next: break node = node.parent node = next def main(): args = demisto.args() html = args.get("value") or "" overwriting_title = args.get("title") filter_indexes = argToList(args.get("filter_indexes")) filter_titles = argToList(args.get("filter_titles")) default_header_line = args.get("default_header_line") or "none" tables = [] try: soup = BeautifulSoup(html, "html.parser") index = -1 for table in parse_tables(soup): rows = table.make_pretty_table_rows(default_header_line) if not rows: continue index = index + 1 if filter_indexes and index not in filter_indexes and str(index) not in filter_indexes: continue original_title = table.get_title() if filter_titles and original_title not in filter_titles: continue tables.append({overwriting_title or original_title: rows}) except Exception as err: # Don't return an error by return_error() as this is transformer. raise DemistoException(str(err)) return_results(tables) if __name__ in ("__main__", "__builtin__", "builtins"): main()
README
Find tables inside HTML and extract the contents into objects using the following logic:
- If table has 2 columns and has no header row, treat the first column as key and second as value and create a table of key/value
- If table has a header row, create a table of objects where attribute names are the headers
- If table does not have a header row, create table of objects where attribute names are cell1, cell2, cell3…
Script Data
| Name | Description |
|---|---|
| Script Type | python3 |
| Tags | transformer, general |
Inputs
| Argument Name | Description |
|---|---|
| value | The HTML to extract tables from |
| title | The title for tables |
| filter_indexes | Extract only the tables with given indexes - 0 based |
| filter_titles | Extract only the tables with given titles |
Outputs
There are no outputs for this script.