From cbc0957a153ec7dbded6aac2e0b6a96961a37033 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 19:40:44 +0100 Subject: [PATCH 01/25] improve description --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 582f696..0021130 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # MonitorTrackingCoverage -**MonitorTrackingCoverage** is a Python script that allows you to compare to different states of tracking setups for a given set of web sites. +**MonitorTrackingCoverage** is a Python script that allows you to compare to different states of tracking setups for web analytics for a given set of urls. -This scripts reads all tracked variables from a given URL and puts the result in JSON format to a file. +## Use Case ## -If you run the script at a later time, it will read all tracked variables and compare them to the original variables you read from the URL. The script will document all changes and put the result in JSON format to a file. It will also create an overview containing all scanned URL and put it into an Excel-file. +Imagine you have implemented **Adobe Data Collection** (vulgo: Adobe Launch) and you changed a data element or a rule. Before you want to publish the changes to production, you want to check, if the changes somehow harm the current setup, e.g. if the changes will change how dimensions will be tracked. This script records tracked dimensions from two different development environments to help you to find any issues like that. + +It will read all tracked variables/dimensions from a given URL and a defined environment and puts the result to a JSON file. + +After that it will read all variables/dimensions for another environment but the same URL and compare the tracked variables/dimensions to the previous result. You will receive a JSOn file and an Excel sheet with all changes and an output to the screen. All files are placed in the subfolder **./results**. From e78092ef74d96c1f84dcc8d195268065abd4a3e7 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 19:40:54 +0100 Subject: [PATCH 02/25] remove unused main function --- comparator.py | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/comparator.py b/comparator.py index d50ee57..4b408a8 100755 --- a/comparator.py +++ b/comparator.py @@ -256,25 +256,4 @@ def check_json(self, obj_test, obj_mapping) -> dict: tested_variable_result["message"] = "Test was successful." tested_variable_result["error"] = 0 - return obj_result - - - - - -if __name__ == '__main__': - - with open("test.json") as jfile: - original = json.load(jfile) - - with open("test_copy.json") as jfile: - test = json.load(jfile) - - comparator = Comparator(original) - result = comparator.check_json(test) - - print("##################### Comparator #####################") - print("\tTested: " + str(comparator.get_tested()) + " | Succeed: " + str(comparator.get_succeed()) + " | Failed: " + str(comparator.get_failed()) + "\n") - print("######################################################") - - print(str(result) + "\n") + return obj_result \ No newline at end of file From e174d4cf9c1dc712a9f0f2b42609cf6071b46d0f Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 19:44:36 +0100 Subject: [PATCH 03/25] refactoring --- comparator.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/comparator.py b/comparator.py index 4b408a8..26b0f72 100755 --- a/comparator.py +++ b/comparator.py @@ -155,12 +155,12 @@ def check_json_format(json: any, orginial: bool = False): # Checks the passed JSON object for the correct format and # then if the passed values match what is expected from # the original JSON. - def check_json(self, obj_test, obj_mapping) -> dict: + def check_json(self, dict_before, dict_after) -> dict: - if self.check_json_format(obj_test) != True: + if self.check_json_format(dict_before) != True: raise error("The test was not processed because the format of the test JSON is not passed as expected.") - obj_result = obj_test.copy() + obj_result = dict_before.copy() self.succeed = 0 self.failed = 0 @@ -168,7 +168,7 @@ def check_json(self, obj_test, obj_mapping) -> dict: # loop through the pages for original_page in self.obj_original: - if original_page not in obj_test: + if original_page not in dict_before: raise error("Execution stopped! Page '" + str(original_page) + "' was not found in the JSON object.") # or use continue for ignore the missing pages @@ -179,7 +179,7 @@ def check_json(self, obj_test, obj_mapping) -> dict: original_variable_def = original_variables[original_variable] # check if the variable exists in the JSON - if original_variable not in obj_test[original_page][self.keyword]: + if original_variable not in dict_before[original_page][self.keyword]: self.failed += 1 @@ -189,8 +189,8 @@ def check_json(self, obj_test, obj_mapping) -> dict: "error": 1 } - if original_variable in obj_mapping: - obj_result[original_page][self.keyword][original_variable]['variable_mapping'] = obj_mapping[original_variable] + if original_variable in dict_after: + obj_result[original_page][self.keyword][original_variable]['variable_mapping'] = dict_after[original_variable] else: obj_result[original_page][self.keyword][original_variable]['variable_mapping'] = '-' @@ -198,12 +198,12 @@ def check_json(self, obj_test, obj_mapping) -> dict: tested_variable_result = obj_result[original_page][self.keyword][original_variable] - if original_variable in obj_mapping: - tested_variable_result['variable_mapping'] = obj_mapping[original_variable] + if original_variable in dict_after: + tested_variable_result['variable_mapping'] = dict_after[original_variable] else: tested_variable_result['variable_mapping'] = '-' - tested_variable = obj_test[original_page][self.keyword][original_variable] + tested_variable = dict_before[original_page][self.keyword][original_variable] # if dict, then check the entries in the dictionary if type(tested_variable) is not dict: From 35822c5eab681306e26256bf98d147b1f979da01 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 19:45:04 +0100 Subject: [PATCH 04/25] remove len check, does not make sense here --- comparator.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/comparator.py b/comparator.py index 26b0f72..400b035 100755 --- a/comparator.py +++ b/comparator.py @@ -102,9 +102,6 @@ def check_json_format(json: any, orginial: bool = False): if type(pages) is not dict: raise error("[FormatCheck] JSON is not defined as dictionary. " + str(type(pages))) - if len(pages) <= 0: - raise error("[FormatCheck] No elements available in the JSON. " + str(len(pages))) - for page in pages: if type(pages[page]) is not dict: raise error("[FormatCheck] Page '" + str(page) + "' is not defined as dictionary. " + str(type(pages[page]))) From 927bf7a8baca82b2a5f6b5a66c3d9b1ff218b4f4 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 19:56:57 +0100 Subject: [PATCH 05/25] removed type check, because it's not required, general cleanup --- comparator.py | 113 ++++++++------------------------------------------ 1 file changed, 17 insertions(+), 96 deletions(-) diff --git a/comparator.py b/comparator.py index 400b035..382b831 100755 --- a/comparator.py +++ b/comparator.py @@ -27,8 +27,8 @@ class Comparator(): - keyword = "variables" - obj_original: dict = {} + target_key = "variables" + pages: dict = {} defined: bool = False @@ -36,29 +36,15 @@ class Comparator(): failed: int = 0 # The initialization of the class requires a dictionary. - def __init__(self, obj_original: dict) -> None: + def __init__(self, pages: dict) -> None: - self.define_original(obj_original) + self.define_original(pages) # The method passes the dictionary and checks if it # contains the required variables and the correct format. - def define_original(self, obj_original: dict) -> None: + def define_original(self, pages: dict) -> None: - if self.check_json_format(obj_original) == True: - - self.obj_original = obj_original - self.defined = True - - else: - - self.obj_original = None - self.defined = False - raise error("The JSON object could not be read in because the format is not passed as expected.") - - # Returns a value of type boolean if the comparator object - # is initialized - def is_defined(self) -> bool: - return self.defined + self.pages = pages # Returns the number of tested pages from the last test # run (call of the function check_json()). @@ -75,11 +61,6 @@ def get_succeed(self) -> int: def get_failed(self) -> int: return self.failed - # Static method which returns the type of an passed object. - @staticmethod - def check_type(obj) -> type: - return type(obj) - # Static method which passes a value of type boolean if # the object was defined correct. @staticmethod @@ -92,91 +73,31 @@ def check_defined(obj) -> bool: return False - # The method checks that the given format of the JSON file - # is correct and returns a boolean. - @staticmethod - def check_json_format(json: any, orginial: bool = False): - - pages = json - - if type(pages) is not dict: - raise error("[FormatCheck] JSON is not defined as dictionary. " + str(type(pages))) - - for page in pages: - if type(pages[page]) is not dict: - raise error("[FormatCheck] Page '" + str(page) + "' is not defined as dictionary. " + str(type(pages[page]))) - - - if "variables" not in pages[page]: - raise error("[FormatCheck] There are no variable definitions for the page '" + str(page) + "'.") - - - variables = pages[page][Comparator.keyword] - for variable in variables: - - if type(pages[page][Comparator.keyword][variable]) is not dict: - raise error("[FormatCheck] Variable '" + str(variable) + "' in page '" + str(page) + "' is not defined as dictionary. " + str(type(pages[page][variable]))) - - - if "value" not in pages[page][Comparator.keyword][variable]: - raise error("[FormatCheck] Value in variable '" + str(variable) + "' in page '" + str(page) + "' is not defined.") - - _value = pages[page][Comparator.keyword][variable]["value"] - if type(_value) is not list: - raise error("[FormatCheck] The type of value in variable '" + str(variable) + "' in page '" + str(page) + "' is not a list.") - - if "type" not in pages[page][Comparator.keyword][variable]: - raise error("[FormatCheck] Type in variable '" + str(variable) + "' in page '" + str(page) + "' is not defined.") - - - _type = pages[page][Comparator.keyword][variable]["type"] - if _type != "int" and _type != "float" and _type != "str" and _type != "*": - raise error("[FormatCheck] Value for type in variable '" + str(variable) + "' in page '" + str(page) + "' is not invalid. " + str(_type)) - - - if "length" not in pages[page]["variables"][variable]: - raise error("[FormatCheck] Length in variable '" + str(variable) + "' in page '" + str(page) + "' is not defined.") - - - if "required" not in pages[page]["variables"][variable]: - raise error("[FormatCheck] Required in variable '" + str(variable) + "' in page '" + str(page) + "' is not defined.") - - - _required = pages[page]["variables"][variable]["required"] - if _required is not True and _required is not False: - raise error("[FormatCheck] Value for required in variable '" + str(variable) + "' in page '" + str(page) + "' is not invalid. " + str(_required)) - - - return True - # Checks the passed JSON object for the correct format and # then if the passed values match what is expected from # the original JSON. - def check_json(self, dict_before, dict_after) -> dict: + def check_json(self, pages_before, pages_after) -> dict: - if self.check_json_format(dict_before) != True: - raise error("The test was not processed because the format of the test JSON is not passed as expected.") - - obj_result = dict_before.copy() + obj_result = pages_before.copy() self.succeed = 0 self.failed = 0 # loop through the pages - for original_page in self.obj_original: + for original_page in self.pages: - if original_page not in dict_before: + if original_page not in pages_before: raise error("Execution stopped! Page '" + str(original_page) + "' was not found in the JSON object.") # or use continue for ignore the missing pages - original_variables = self.obj_original[original_page][self.keyword] + original_variables = self.pages[original_page][self.keyword] # loop through the adobe analytics variables for original_variable in original_variables: original_variable_def = original_variables[original_variable] # check if the variable exists in the JSON - if original_variable not in dict_before[original_page][self.keyword]: + if original_variable not in pages_before[original_page][self.keyword]: self.failed += 1 @@ -186,8 +107,8 @@ def check_json(self, dict_before, dict_after) -> dict: "error": 1 } - if original_variable in dict_after: - obj_result[original_page][self.keyword][original_variable]['variable_mapping'] = dict_after[original_variable] + if original_variable in pages_after: + obj_result[original_page][self.keyword][original_variable]['variable_mapping'] = pages_after[original_variable] else: obj_result[original_page][self.keyword][original_variable]['variable_mapping'] = '-' @@ -195,12 +116,12 @@ def check_json(self, dict_before, dict_after) -> dict: tested_variable_result = obj_result[original_page][self.keyword][original_variable] - if original_variable in dict_after: - tested_variable_result['variable_mapping'] = dict_after[original_variable] + if original_variable in pages_after: + tested_variable_result['variable_mapping'] = pages_after[original_variable] else: tested_variable_result['variable_mapping'] = '-' - tested_variable = dict_before[original_page][self.keyword][original_variable] + tested_variable = pages_before[original_page][self.keyword][original_variable] # if dict, then check the entries in the dictionary if type(tested_variable) is not dict: From fe2f33d82271ebcef7785dd307342db5c0fa18c3 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 19:58:02 +0100 Subject: [PATCH 06/25] refactoring --- comparator.py | 22 +++++----------------- run.py | 4 ++-- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/comparator.py b/comparator.py index 382b831..794d2c1 100755 --- a/comparator.py +++ b/comparator.py @@ -25,7 +25,7 @@ # } # -class Comparator(): +class Compare(): target_key = "variables" pages: dict = {} @@ -47,36 +47,24 @@ def define_original(self, pages: dict) -> None: self.pages = pages # Returns the number of tested pages from the last test - # run (call of the function check_json()). + # run (call of the function compare()). def get_tested(self) -> int: return (self.succeed + self.failed) # Returns the number of successfully tested pages from - # the last test run (call to check_json() function). + # the last test run (call to compare() function). def get_succeed(self) -> int: return self.succeed # Returns the number of failed tested pages from the last - # test run (call to check_json() function). + # test run (call to compare() function). def get_failed(self) -> int: return self.failed - # Static method which passes a value of type boolean if - # the object was defined correct. - @staticmethod - def check_defined(obj) -> bool: - if type(obj) is str and len(obj) > 0: - return True - - if (type(obj) is int or type(obj) is float) and obj != -1: - return True - - return False - # Checks the passed JSON object for the correct format and # then if the passed values match what is expected from # the original JSON. - def check_json(self, pages_before, pages_after) -> dict: + def compare(self, pages_before, pages_after) -> dict: obj_result = pages_before.copy() diff --git a/run.py b/run.py index de7ea5d..a6267dc 100755 --- a/run.py +++ b/run.py @@ -132,10 +132,10 @@ def __init__(self, if focus is not None: self.original = {focus: self.original[focus]} - comparator = Comparator(self.original) + compare = Compare(self.original) # TODO: add stop_on_error flag to stop on every error, makes it easier to fix errors - self.result = comparator.check_json(self.result, self.var_mapping) + self.result = compare.compare(self.result, self.var_mapping) # don't create the excel output when focus page is defined if focus is None: From 617ebbd06c28e1d4f60cc46b3c603e72638c3ca3 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 19:59:52 +0100 Subject: [PATCH 07/25] clean up: remove stats (failed, success) as it's not required --- comparator.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/comparator.py b/comparator.py index 794d2c1..0fc5238 100755 --- a/comparator.py +++ b/comparator.py @@ -2,7 +2,6 @@ # License Apache-2.0 from email import message -import json from os import error from importlib_metadata import abc @@ -29,38 +28,11 @@ class Compare(): target_key = "variables" pages: dict = {} - - defined: bool = False - - succeed: int = 0 - failed: int = 0 - # The initialization of the class requires a dictionary. def __init__(self, pages: dict) -> None: - self.define_original(pages) - - # The method passes the dictionary and checks if it - # contains the required variables and the correct format. - def define_original(self, pages: dict) -> None: - self.pages = pages - # Returns the number of tested pages from the last test - # run (call of the function compare()). - def get_tested(self) -> int: - return (self.succeed + self.failed) - - # Returns the number of successfully tested pages from - # the last test run (call to compare() function). - def get_succeed(self) -> int: - return self.succeed - - # Returns the number of failed tested pages from the last - # test run (call to compare() function). - def get_failed(self) -> int: - return self.failed - # Checks the passed JSON object for the correct format and # then if the passed values match what is expected from # the original JSON. From 7e568548d7f5c28860a523a0b8a6b51f9b9e4384 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 20:01:59 +0100 Subject: [PATCH 08/25] refactoring cleanup --- comparator.py => compare.py | 3 --- run.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) rename comparator.py => compare.py (98%) diff --git a/comparator.py b/compare.py similarity index 98% rename from comparator.py rename to compare.py index 0fc5238..bf5cb27 100755 --- a/comparator.py +++ b/compare.py @@ -1,11 +1,8 @@ # Copyright 2023 DB Systel GmbH # License Apache-2.0 -from email import message from os import error -from importlib_metadata import abc - # This class parses JSON objects and compares variables in the predefined format. # # { diff --git a/run.py b/run.py index a6267dc..c8b9d2a 100755 --- a/run.py +++ b/run.py @@ -20,7 +20,7 @@ import pandas as pd -from comparator import Comparator +from compare import Compare # to read available width in terminal output from os import get_terminal_size From be332a97c6a7f901b373d6d6c5be85d88bedc900 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 21:26:54 +0100 Subject: [PATCH 09/25] clean up, refactroing --- compare.py | 145 +++++++++++++++++++++++------------------------------ run.py | 2 +- 2 files changed, 65 insertions(+), 82 deletions(-) diff --git a/compare.py b/compare.py index bf5cb27..aee81e8 100755 --- a/compare.py +++ b/compare.py @@ -23,112 +23,95 @@ class Compare(): - target_key = "variables" - pages: dict = {} + def compare(self, + pages_before: dict = None, + pages_after: dict = None, + var_mapping: dict = None) -> dict: - def __init__(self, pages: dict) -> None: + """ + Compare two sets of pages before and after a change, and generate a report. - self.pages = pages + This method takes two dictionaries representing pages before and after a change, + and an optional variable mapping dictionary for variable name translations. - # Checks the passed JSON object for the correct format and - # then if the passed values match what is expected from - # the original JSON. - def compare(self, pages_before, pages_after) -> dict: + Parameters: + - pages_before (dict): A dictionary representing pages before the change. + - pages_after (dict): A dictionary representing pages after the change. + - var_mapping (dict, optional): A dictionary mapping variable names for translation. - obj_result = pages_before.copy() + Returns: + - result (dict): A dictionary containing the comparison report with details of the changes. - self.succeed = 0 - self.failed = 0 - - # loop through the pages - for original_page in self.pages: + Example: + ``` + before = {'page1': {'var1': 10, 'var2': 20}, 'page2': {'var1': 5, 'var3': 15}} + after = {'page1': {'var1': 12, 'var2': 20}, 'page3': {'var4': 8}} + + comparer = PageComparer() + comparison_result = comparer.compare(pages_before=before, pages_after=after) + ``` + """ - if original_page not in pages_before: - raise error("Execution stopped! Page '" + str(original_page) + "' was not found in the JSON object.") - # or use continue for ignore the missing pages - - original_variables = self.pages[original_page][self.keyword] - # loop through the adobe analytics variables - for original_variable in original_variables: + # loop through all pages from state "before" + for page_before in pages_before: + + # check if state "after" contains current page + if page_before not in pages_after: + pages_before[page_before] = { + "message": f"Page `{page_before}`not found.", + "error": 1 + } + + # loop through all variables from state "before" + for var_before in pages_before[page_before]["variables"]: - original_variable_def = original_variables[original_variable] - - # check if the variable exists in the JSON - if original_variable not in pages_before[original_page][self.keyword]: + # if exists, get variable mapping ("readable name") + if var_before in var_mapping: + pages_before[page_before]["variables"][var_before]['variable_mapping'] = var_mapping[var_before] - self.failed += 1 - - obj_result[original_page][self.keyword][original_variable] = { - "value": [""], - "message": "Test failed. Variable was not found in the list of variables.", - "error": 1 - } - - if original_variable in pages_after: - obj_result[original_page][self.keyword][original_variable]['variable_mapping'] = pages_after[original_variable] - else: - obj_result[original_page][self.keyword][original_variable]['variable_mapping'] = '-' + # check if the variable exists in state "after" + if var_before not in pages_after[page_before]["variables"]: + + pages_before[page_before]["variables"][var_before]['message'] = f"Variable `{var_before}` was not found." + pages_before[page_before]["variables"][var_before]['error'] = 1 continue - - tested_variable_result = obj_result[original_page][self.keyword][original_variable] - - if original_variable in pages_after: - tested_variable_result['variable_mapping'] = pages_after[original_variable] - else: - tested_variable_result['variable_mapping'] = '-' - tested_variable = pages_before[original_page][self.keyword][original_variable] + tested_variable = pages_before[page_before]["variables"][var_before] # if dict, then check the entries in the dictionary if type(tested_variable) is not dict: - tested_variable_result["message"] = "Test failed. A dictionary was expected as a value for the key '" + str(original_variable) + "'." - tested_variable_result["error"] = 1 + pages_before[page_before]["variables"][var_before]["message"] = "Test failed. A dictionary was expected as a value for the key '" + str(original_variable) + "'." + pages_before[page_before]["variables"][var_before]["error"] = 1 continue - # check if value is required - if original_variable_def["required"] == False: - self.succeed += 1 - tested_variable_result["message"] = "Test was successful." - tested_variable_result["error"] = 0 + # check if variable is mandatory/required + if pages_before[page_before]["variables"][var_before]["required"] == False: + pages_before[page_before]["variables"][var_before]["message"] = "Not required." + pages_before[page_before]["variables"][var_before]["error"] = 0 continue # check if the variable type is defined and matches - if original_variable_def["type"] != "*": - if original_variable_def["type"] != tested_variable["type"]: - self.failed += 1 - tested_variable_result["message"] = "Test failed. The type of the variable does not match the expected type." - tested_variable_result["error"] = 1 + if pages_before[page_before]["variables"][var_before]["type"] != "*": + if pages_before[page_before]["variables"][var_before]["type"] != pages_after[page_before]["variables"][var_before]["type"]: + pages_before[page_before]["variables"][var_before]["message"] = f'The type of the variable does not match the expected type: {pages_before[page_before]["variables"][var_before]["type"]}' + pages_before[page_before]["variables"][var_before]["error"] = 1 continue # check if the variable length is defined and matches - if original_variable_def["length"] != -1: - if original_variable_def["length"] != tested_variable["length"]: + if pages_before[page_before]["variables"][var_before]["length"] > 0: + if pages_before[page_before]["variables"][var_before]["length"] != pages_before[page_before]["variables"][var_before]["length"]: - self.failed += 1 - tested_variable_result["message"] = "Test failed. The length of the variable does not match the expected length." - tested_variable_result["error"] = 1 + pages_before[page_before]["variables"][var_before]["message"] = f'The length of the variable does not match the expected length: {pages_before[page_before]["variables"][var_before]["length"]}' + pages_before[page_before]["variables"][var_before]["error"] = 1 continue - # check if tested value is part of allowed values - # if original list of allowed values is 0, every value is ok - if type(original_variable_def["value"]) is list and len(original_variable_def["value"]) > 0: - # not necessary, it's always a list and it always contains 1 item only - # if type(tested_variable["value"]) is list: - # test_value = tested_variable["value"][0] - # else: - # test_value = tested_variable["value"] - - if tested_variable["value"][0] not in original_variable_def["value"]: - - self.failed += 1 - # TODO: add expected and actual value here - tested_variable_result["message"] = "Test failed. The value of the variable is not included in the list of expected values." - tested_variable_result["error"] = 1 + # check if tested value is an allowed value + if pages_after[page_before]["variables"][var_before]["value"][0] not in pages_before[page_before]["variables"][var_before]["value"]: + pages_before[page_before]["variables"][var_before]["message"] = f'The value of the variable is not included in the list of expected values: {pages_before[page_before]["variables"][var_before]["value"].join(", ")}' + pages_before[page_before]["variables"][var_before]["error"] = 1 continue - self.succeed += 1 - tested_variable_result["message"] = "Test was successful." - tested_variable_result["error"] = 0 + pages_before[page_before]["variables"][var_before]["error"] = 0 - return obj_result \ No newline at end of file + return pages_before \ No newline at end of file diff --git a/run.py b/run.py index c8b9d2a..57198e6 100755 --- a/run.py +++ b/run.py @@ -132,7 +132,7 @@ def __init__(self, if focus is not None: self.original = {focus: self.original[focus]} - compare = Compare(self.original) + compare = Compare() # TODO: add stop_on_error flag to stop on every error, makes it easier to fix errors self.result = compare.compare(self.result, self.var_mapping) From 50f7bca0f9a829c0dc5920b6e4b1c7952610b9de Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 21:30:08 +0100 Subject: [PATCH 10/25] clean up --- run.py | 1 - 1 file changed, 1 deletion(-) diff --git a/run.py b/run.py index 57198e6..77286b8 100755 --- a/run.py +++ b/run.py @@ -176,7 +176,6 @@ def switch_tag_container(self, request): return request - def analyse_result(self): self.df_results_analysed = pd.DataFrame() From ee2984e2afeeed9e3ad6bebe71fa9a950fdf4f75 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 21:41:24 +0100 Subject: [PATCH 11/25] improved setup and first run routine --- .gitignore | 1 + README.md | 9 +++++++++ compare.py | 2 -- requirements.txt | 4 ++++ 4 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b694934 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.venv \ No newline at end of file diff --git a/README.md b/README.md index 0021130..8a676cb 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,15 @@ This is an example of the Excel result: ## Prerequisites ## +### Python Dependencies +``` +python3 -m venv .venv +source .venv/bin/activate +pip3 install -r requirements.txt +``` + +### Chrome + This script uses the **Selenium** browser automation. It requires you to download the headless browser called "**Chrome driver**" from here: https://chromedriver.chromium.org/downloads diff --git a/compare.py b/compare.py index aee81e8..1c732fe 100755 --- a/compare.py +++ b/compare.py @@ -1,8 +1,6 @@ # Copyright 2023 DB Systel GmbH # License Apache-2.0 -from os import error - # This class parses JSON objects and compares variables in the predefined format. # # { diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..75f4f39 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +selenium==4.1.0 +selenium-wire==5.1.0 +lxml==4.6.3 +pandas==1.3.3 \ No newline at end of file From 9c112ab988be2cb2d3389ee10f09e9fe04f032db Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 21:47:14 +0100 Subject: [PATCH 12/25] clean up inline cod --- README.md | 2 +- run.py | 31 ++++++++++++++++--------------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8a676cb..781a764 100644 --- a/README.md +++ b/README.md @@ -145,4 +145,4 @@ See How [to contribute](https://github.com/dbsystel/tracking-tester/blob/main/CO This project is licensed under [Apache-2.0](https://github.com/dbsystel/tracking-tester/blob/main/LICENSE) -Copyright 2023 DB Systel GmbH +Copyright 2023 DB Systel GmbH \ No newline at end of file diff --git a/run.py b/run.py index 77286b8..c27c60a 100755 --- a/run.py +++ b/run.py @@ -1,31 +1,32 @@ +#! # Copyright 2023 DB Systel GmbH # License Apache-2.0 import time -import sys, urllib3, os -# from selenium import webdriver +from datetime import datetime # date time handling +import sys, os + +# selenium from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from seleniumwire import webdriver # wrapper to get network requests from browser and also modify LaunchRequests in real time (https://stackoverflow.com/questions/31354352/selenium-how-to-inject-execute-a-javascript-in-to-a-page-before-loading-executi) -import pickle # to save / load cookies from pathlib import Path # check if cookie dump exists from urllib.parse import urlparse, parse_qs, urldefrag # extract get parameters from url -import json # export result - -# modify requests before rendering of page https://stackoverflow.com/questions/31354352/selenium-how-to-inject-execute-a-javascript-in-to-a-page-before-loading-executi -from lxml import html -from lxml.etree import ParserError -from lxml.html import builder -import argparse +import argparse # to get runtime arguments -import pandas as pd +import pandas as pd # for export to Excel +import json # for export to JSON -from compare import Compare +from compare import Compare # small class to compare two dicts -# to read available width in terminal output -from os import get_terminal_size +from os import get_terminal_size # get available width in terminal output -from datetime import datetime +# TODO: clean up unsued libs +# modify requests before rendering of page https://stackoverflow.com/questions/31354352/selenium-how-to-inject-execute-a-javascript-in-to-a-page-before-loading-executi +# from lxml import html +# from lxml.etree import ParserError +# from lxml.html import builder +#import pickle # to save / load cookies def get_real_type(string: str) -> str: From 2711381ee1c4cd81604c10698c1f505aa1fca6ad Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 21:51:23 +0100 Subject: [PATCH 13/25] add chromedriver exec --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b694934..7b31fca 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.venv \ No newline at end of file +.venv +chromedriver From a65eb84f51d97107e0d1181127b5eb6c4a58cdb1 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 21:56:28 +0100 Subject: [PATCH 14/25] improve output --- run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run.py b/run.py index c27c60a..b9415e8 100755 --- a/run.py +++ b/run.py @@ -352,9 +352,9 @@ def parse_page(self, url) -> dict: self.driver.get(url) try: - self.driver.wait_for_request(self.adobe_analytics_host, 5) + self.driver.wait_for_request(self.adobe_analytics_host, 1) except: - print(f'Could not find tracking container on {url}, do you provided the correct container locations?') + print(f'Could not find tracking container `{self.adobe_analytics_host}`on `{url}`, do you provided the correct container locations?') sys.exit() # grace period to give the onsite script time to work From 7da22db304b1b0bb2a0f22ba320d031f206f956b Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 21:59:07 +0100 Subject: [PATCH 15/25] new feature: set cookies prior to request --- run.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/run.py b/run.py index b9415e8..02dc15b 100755 --- a/run.py +++ b/run.py @@ -274,6 +274,11 @@ def setup(self, settings, env, focus): self.var_mapping = settings['mapping'] + if "cookies" in settings: + self.cookies = settings['cookies'] + else: + self.cookies = None + # keep those for later use: automatically parse a whole website? # self.urls_parsed = [] # a plain list of all urls to parse # self.urls_to_parse_next = [] # a plain list of all urls to be parsed @@ -349,6 +354,24 @@ def parse_page(self, url) -> dict: # for cookie in cookies: # driver.add_cookie(cookie) + + # https://stackoverflow.com/a/63220249 + # this enables network tracking, this allows us to set cookies before the actual request + # otherwise we could not place cookies in the websites domain + self.driver.execute_cdp_cmd('Network.enable', {}) + + # set cookies before actual request is made + if self.cookies is not None: + for cookie in self.cookies: + self.driver.execute_cdp_cmd('Network.setCookie', { + 'name' : cookie['name'], + 'value' : cookie['value'], + 'domain' : cookie['domain'] + }) + + # this enables network tracking + self.driver.execute_cdp_cmd('Network.disable', {}) + self.driver.get(url) try: From c3430263b72800aeced190f4c27ed49f184648ad Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 21:59:37 +0100 Subject: [PATCH 16/25] add found requests to error output --- run.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/run.py b/run.py index 02dc15b..3f4c0e2 100755 --- a/run.py +++ b/run.py @@ -377,6 +377,16 @@ def parse_page(self, url) -> dict: try: self.driver.wait_for_request(self.adobe_analytics_host, 1) except: + + # Access requests via the `requests` attribute + for request in self.driver.requests: + if request.response: + print( + request.url, + request.response.status_code, + request.response.headers['Content-Type'] + ) + print(f'Could not find tracking container `{self.adobe_analytics_host}`on `{url}`, do you provided the correct container locations?') sys.exit() From fa0167a0888a3049c1487b4dbe37f3d5e2daa6b0 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 22:02:14 +0100 Subject: [PATCH 17/25] add results folder if not exists --- run.py | 3 +++ settings.json | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/run.py b/run.py index 3f4c0e2..b215934 100755 --- a/run.py +++ b/run.py @@ -91,6 +91,9 @@ def __init__(self, # this would overwrite existing result file # which could be unexpected and hence unwanted + if not os.path.exists('results'): + os.makedirs(directory) + if mode == 'init' and focus is None: self.init_driver(silent, mode) diff --git a/settings.json b/settings.json index 968408a..f0b0b6f 100644 --- a/settings.json +++ b/settings.json @@ -15,6 +15,58 @@ "v6": "eVar 6 - Some Dimension", "c11": "prop 11 - Another Dimension" } - + }, + "test": { + "adobe_launch_host" : "assets.adobedtm.com", + "adobe_analytics_host" : "dbsystelentities.d3.sc.omtrdc.net", + "container_before": "https://assets.adobedtm.com/3d2122476bde/2740bb4afc65/launch-aeea6394ddbd.min.js", + "container_after": "https://assets.adobedtm.com/3d2122476bde/2740bb4afc65/launch-42760594da97-staging.min.js", + "grace_period": 2, + "cookies": [{ + "domain": "db.jobs", + "name": "cpi_adobe-analytics", + "value": "1" + }], + "urls" : { + "Frontpage": "https://db.jobs/de-de", + "Frontpage_UTM": "https://db.jobs/de-de?utm_source=test_utm_source&utm_medium=test_utm_medium&utm_campaign=test_utm_campaign&utm_term=test_utm_term&utm_content=test_utm_content", + "SERP_leer": "https://db.jobs/service/search/de-de/5441588?qli=true&query=&qli=true&sort=score&country=Deutschland", + "SERP_nicht_leer": "https://db.jobs/service/search/de-de/5441588?qli=true&query=Manager&sort=score&itemsPerPage=20&pageNum=0&country=Deutschland&location=Bamberg", + "Job_Ad": "https://db.jobs/de-de/Suche/Leistungsmanager-in-12505454?jobId=321088", + "Content": "https://db.jobs/de-de/dein-einstieg/schuelerpraktikum", + "Event_SERP": "https://db.jobs/service/search/de-de/9639412?qli=true&query=&qli=true&sort=eventDate_tdt+asc", + "Event_Ad": "https://db.jobs/de-de/Eventsuche/Lebenslauf-Check-DB-Jobwelt-Leipzig-9971954?eventId=183616&resultCount=54" + }, + "mapping": { + "c": "Screen color depth", + "s": "Screen resolution", + "v6": "Jobs Viewed (v6, Last, Visit) (evar6)", + "c11": "Page Name", + "v11": "Page Name (v11, Last, Hit) (evar11)", + "c12": "Page Id (c12) (prop12)", + "c13": "Page Title (c13) (prop13)", + "c14": "Page URL w/o Query Strings (c14) (prop14)", + "v14": "Page URL w/o Query Strings (v14, Last, Hit) (evar14)", + "c15": "Page URL w/ Query Strings (c15) (prop15)", + "v15": "Page URL w/ Query Strings (v15, Last, Hit) (evar15)", + "c18": "Breadcrumb (c18) (prop18)", + "c27": "SUP Required Formfields (v27)", + "v27": "SUP Required Formfields (v27, Last, Visit)", + "c37": "Job Publication Date (c37) (prop37)", + "v37": "Job Publication Date (v37, Last, Visit) (evar37)", + "c51": "Launch Build Date", + "c52": "Launch Build System", + "c61": "Job Referral ID (c61) (prop61)", + "v61": "Job Referral ID (v61, Last, Visit) (evar61)", + "c62": "Job ID (c62) (prop62)", + "v62": "Job ID (v62, Last, Visit) (evar62)", + "c64": "Search Results (c64) (prop64)", + "v64": "Search Results (v64, Last, Visit) (evar64)", + "c65": "Additional Job Details (c65) (prop65)", + "v65": "Additional Job Details (v65, Last, Visit) (evar65)", + "c75": "PPV: Highest Percent / Initial Percent / Highest Pixel (c75) (prop75)", + "v75": "PPV: Highest Percent / Initial Percent / Highest Pixel (v75, Last, Hit) (evar75)", + "v127": "JobRegion (v127, Last, Visit) (evar127)" + } } } \ No newline at end of file From de198e3845b26234e7fcbbcefcebd402333b59a3 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 22:02:25 +0100 Subject: [PATCH 18/25] add results folder if not exists --- run.py | 2 +- settings.json | 72 --------------------------------------------------- 2 files changed, 1 insertion(+), 73 deletions(-) delete mode 100644 settings.json diff --git a/run.py b/run.py index b215934..5c05157 100755 --- a/run.py +++ b/run.py @@ -92,7 +92,7 @@ def __init__(self, # which could be unexpected and hence unwanted if not os.path.exists('results'): - os.makedirs(directory) + os.makedirs('results') if mode == 'init' and focus is None: diff --git a/settings.json b/settings.json deleted file mode 100644 index f0b0b6f..0000000 --- a/settings.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "example_setup": { - "adobe_launch_host" : "assets.adobedtm.com", - "adobe_analytics_host" : "customer.d3.sc.omtrdc.net", - "container_before": "https://assets.adobedtm.com/launch-ABC123.min.js", - "container_after": "https://assets.adobedtm.com/launch-ABC123-development.min.js", - "output_filename": "status_quo.json", - "grace_period": 2, - "urls" : { - "Homepage": "https://example.com", - "Searchresults": "https://example.com/search/q=keyword", - "Content": "https://example.com/somecontent" - }, - "mapping": { - "v6": "eVar 6 - Some Dimension", - "c11": "prop 11 - Another Dimension" - } - }, - "test": { - "adobe_launch_host" : "assets.adobedtm.com", - "adobe_analytics_host" : "dbsystelentities.d3.sc.omtrdc.net", - "container_before": "https://assets.adobedtm.com/3d2122476bde/2740bb4afc65/launch-aeea6394ddbd.min.js", - "container_after": "https://assets.adobedtm.com/3d2122476bde/2740bb4afc65/launch-42760594da97-staging.min.js", - "grace_period": 2, - "cookies": [{ - "domain": "db.jobs", - "name": "cpi_adobe-analytics", - "value": "1" - }], - "urls" : { - "Frontpage": "https://db.jobs/de-de", - "Frontpage_UTM": "https://db.jobs/de-de?utm_source=test_utm_source&utm_medium=test_utm_medium&utm_campaign=test_utm_campaign&utm_term=test_utm_term&utm_content=test_utm_content", - "SERP_leer": "https://db.jobs/service/search/de-de/5441588?qli=true&query=&qli=true&sort=score&country=Deutschland", - "SERP_nicht_leer": "https://db.jobs/service/search/de-de/5441588?qli=true&query=Manager&sort=score&itemsPerPage=20&pageNum=0&country=Deutschland&location=Bamberg", - "Job_Ad": "https://db.jobs/de-de/Suche/Leistungsmanager-in-12505454?jobId=321088", - "Content": "https://db.jobs/de-de/dein-einstieg/schuelerpraktikum", - "Event_SERP": "https://db.jobs/service/search/de-de/9639412?qli=true&query=&qli=true&sort=eventDate_tdt+asc", - "Event_Ad": "https://db.jobs/de-de/Eventsuche/Lebenslauf-Check-DB-Jobwelt-Leipzig-9971954?eventId=183616&resultCount=54" - }, - "mapping": { - "c": "Screen color depth", - "s": "Screen resolution", - "v6": "Jobs Viewed (v6, Last, Visit) (evar6)", - "c11": "Page Name", - "v11": "Page Name (v11, Last, Hit) (evar11)", - "c12": "Page Id (c12) (prop12)", - "c13": "Page Title (c13) (prop13)", - "c14": "Page URL w/o Query Strings (c14) (prop14)", - "v14": "Page URL w/o Query Strings (v14, Last, Hit) (evar14)", - "c15": "Page URL w/ Query Strings (c15) (prop15)", - "v15": "Page URL w/ Query Strings (v15, Last, Hit) (evar15)", - "c18": "Breadcrumb (c18) (prop18)", - "c27": "SUP Required Formfields (v27)", - "v27": "SUP Required Formfields (v27, Last, Visit)", - "c37": "Job Publication Date (c37) (prop37)", - "v37": "Job Publication Date (v37, Last, Visit) (evar37)", - "c51": "Launch Build Date", - "c52": "Launch Build System", - "c61": "Job Referral ID (c61) (prop61)", - "v61": "Job Referral ID (v61, Last, Visit) (evar61)", - "c62": "Job ID (c62) (prop62)", - "v62": "Job ID (v62, Last, Visit) (evar62)", - "c64": "Search Results (c64) (prop64)", - "v64": "Search Results (v64, Last, Visit) (evar64)", - "c65": "Additional Job Details (c65) (prop65)", - "v65": "Additional Job Details (v65, Last, Visit) (evar65)", - "c75": "PPV: Highest Percent / Initial Percent / Highest Pixel (c75) (prop75)", - "v75": "PPV: Highest Percent / Initial Percent / Highest Pixel (v75, Last, Hit) (evar75)", - "v127": "JobRegion (v127, Last, Visit) (evar127)" - } - } -} \ No newline at end of file From 556e1f55f010c5f35f18c4d751b30cf69ccba5bb Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 22:09:12 +0100 Subject: [PATCH 19/25] add example settings --- settings.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 settings.json diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..bfd81a5 --- /dev/null +++ b/settings.json @@ -0,0 +1,19 @@ +{ + "example_setup": { + "adobe_launch_host" : "assets.adobedtm.com", + "adobe_analytics_host" : "customer.d3.sc.omtrdc.net", + "container_before": "https://assets.adobedtm.com/launch-ABC123.min.js", + "container_after": "https://assets.adobedtm.com/launch-ABC123-development.min.js", + "output_filename": "status_quo.json", + "grace_period": 2, + "urls" : { + "Homepage": "https://example.com", + "Searchresults": "https://example.com/search/q=keyword", + "Content": "https://example.com/somecontent" + }, + "mapping": { + "v6": "eVar 6 - Some Dimension", + "c11": "prop 11 - Another Dimension" + } + } +} \ No newline at end of file From b740f970f815fd0ccc3947ce6daf40f9f120d5f0 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 22:11:25 +0100 Subject: [PATCH 20/25] add results to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7b31fca..1f36827 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .venv chromedriver +results/* \ No newline at end of file From 07cadf798903d0317b09873079a753e239381351 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 22:18:14 +0100 Subject: [PATCH 21/25] fix wrong join instruction --- compare.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compare.py b/compare.py index 1c732fe..de3bd09 100755 --- a/compare.py +++ b/compare.py @@ -106,7 +106,7 @@ def compare(self, # check if tested value is an allowed value if pages_after[page_before]["variables"][var_before]["value"][0] not in pages_before[page_before]["variables"][var_before]["value"]: - pages_before[page_before]["variables"][var_before]["message"] = f'The value of the variable is not included in the list of expected values: {pages_before[page_before]["variables"][var_before]["value"].join(", ")}' + pages_before[page_before]["variables"][var_before]["message"] = f'The value of the variable is not included in the list of expected values: {", ".join(pages_before[page_before]["variables"][var_before]["value"])}' pages_before[page_before]["variables"][var_before]["error"] = 1 continue From cb044cd4cc7e21b571c7c3e0f496b628e1032966 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 22:18:29 +0100 Subject: [PATCH 22/25] fix wrong argument order --- run.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/run.py b/run.py index 5c05157..da7c87c 100755 --- a/run.py +++ b/run.py @@ -139,7 +139,9 @@ def __init__(self, compare = Compare() # TODO: add stop_on_error flag to stop on every error, makes it easier to fix errors - self.result = compare.compare(self.result, self.var_mapping) + self.result = compare.compare(pages_before=self.result, + pages_after=self.original, + var_mapping=self.var_mapping) # don't create the excel output when focus page is defined if focus is None: @@ -458,6 +460,7 @@ def parse_page(self, url) -> dict: args_parser.add_argument('--mode', dest='mode', required=False, type=str, default='test', choices=['test', 'init', 'analyse'], help='init: initially read the original state, test: compare original state and current state, analyse: analyse test status and create a report') + # TODO: improve/ease file handling, we don't need original/test folders, we can just assume them from env argument args_parser.add_argument('--original', dest='original', required=True, type=str, help='filename that contains original tracked variables in JSON format') From 939cd98d45c94c51a5a33a46ed85d38cda32b602 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 22:20:48 +0100 Subject: [PATCH 23/25] set default var mapping to "-" --- compare.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compare.py b/compare.py index de3bd09..6cc5aff 100755 --- a/compare.py +++ b/compare.py @@ -66,6 +66,8 @@ def compare(self, # if exists, get variable mapping ("readable name") if var_before in var_mapping: pages_before[page_before]["variables"][var_before]['variable_mapping'] = var_mapping[var_before] + else: + pages_before[page_before]["variables"][var_before]['variable_mapping'] = "-" # check if the variable exists in state "after" if var_before not in pages_after[page_before]["variables"]: From 5f42670d6a3b909e2b32487d715742537ee112cc Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 22:22:11 +0100 Subject: [PATCH 24/25] add openpyxl to req --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 75f4f39..d5a454f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ selenium==4.1.0 selenium-wire==5.1.0 lxml==4.6.3 -pandas==1.3.3 \ No newline at end of file +pandas==1.3.3 +openpyxl=3.1.2 \ No newline at end of file From 7abf960f137f335635e25238dcb130b6fdec8069 Mon Sep 17 00:00:00 2001 From: nickyreinert Date: Tue, 23 Jan 2024 22:22:33 +0100 Subject: [PATCH 25/25] add openpyxl to req --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d5a454f..93c684c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ selenium==4.1.0 selenium-wire==5.1.0 lxml==4.6.3 pandas==1.3.3 -openpyxl=3.1.2 \ No newline at end of file +openpyxl==3.1.2 \ No newline at end of file