From 18c0e7741db60d5bf1c4ce04e2f34cdedbac299f Mon Sep 17 00:00:00 2001 From: Raymomyar Date: Thu, 25 Nov 2021 22:36:30 -0800 Subject: [PATCH 1/4] add admin dep --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4250a45..06c6092 100755 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,5 @@ Flask-Login python-dotenv snowflake-id selenium -apscheduler \ No newline at end of file +apscheduler +flask_admin \ No newline at end of file From 16bab86046841356be970fd716356497b05c348e Mon Sep 17 00:00:00 2001 From: Raymomyar Date: Thu, 25 Nov 2021 22:46:45 -0800 Subject: [PATCH 2/4] add bs4 --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 06c6092..0c17d3d 100755 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,5 @@ python-dotenv snowflake-id selenium apscheduler -flask_admin \ No newline at end of file +flask_admin +BeautifulSoup \ No newline at end of file From 05f043e045bfde7899695afd6c47b8ec052ed472 Mon Sep 17 00:00:00 2001 From: Raymomyar Date: Fri, 26 Nov 2021 10:54:57 -0800 Subject: [PATCH 3/4] no password bugfix, +bestbuy extractor, +migration bugfix, also make all extractors use shared webdriver --- api.py | 16 +++++++++++++--- database_sample_demo.db | Bin 0 -> 61440 bytes extractors/BestBuyExtractor.py | 18 ++++++++++++++++++ extractors/ExtractorBase.py | 9 ++++++++- extractors/GoogleShoppingExtractor..py | 9 --------- extractors/Multiextractor.py | 4 ++-- extractors/all_extractors.py | 3 ++- static/js/all_products.js | 13 ++++++++++--- templates/layouts/main.html | 4 ++++ 9 files changed, 57 insertions(+), 19 deletions(-) create mode 100644 database_sample_demo.db create mode 100644 extractors/BestBuyExtractor.py delete mode 100644 extractors/GoogleShoppingExtractor..py diff --git a/api.py b/api.py index 73f2237..6370541 100644 --- a/api.py +++ b/api.py @@ -97,7 +97,11 @@ def data_fetch_loop(): for webpage in webpages: print("Processing",webpage.id) try: - price = next(filter(lambda ext: ext.is_valid_url(webpage.url),extractors)).extract_data(webpage.url) + extractor = next(filter(lambda ext: ext.is_valid_url(webpage.url),extractors)) + if extractor is None: + print("No extractor found for", webpage.url) + continue + price = extractor.extract_data(webpage.url) obj = { "webpage_id": webpage.id, "url": webpage.url, @@ -114,7 +118,7 @@ def data_fetch_loop(): is_fetching = False -job = scheduler.add_job(data_fetch_loop, 'interval', seconds=180) +job = scheduler.add_job(data_fetch_loop, 'interval', seconds=60*5) scheduler.start() @@ -131,7 +135,7 @@ def wrapper(*args, **kwargs): else: return jsonify({"error": "Not authorized"}), 401 else: - func(*args, **kwargs) + return func(*args, **kwargs) return wrapper @api.before_request @@ -145,6 +149,12 @@ def syncThreads(): db_session.commit() # when your browser wants the data for the the site item + +@api.route('/force_refetch') +def force_resync(): + data_fetch_loop() + return "Gotcha!" + @api.route('/all_webpages') def everything(): return jsonify(db_session.query(Webpage).all()) diff --git a/database_sample_demo.db b/database_sample_demo.db new file mode 100644 index 0000000000000000000000000000000000000000..a190bb427769944e28d27d4a9b27270054985134 GIT binary patch literal 61440 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 fhKfdk$@X(-S}EtvGj aULzUe IuHnof diff --git a/extractors/all_extractors.py b/extractors/all_extractors.py index 83741a7..e6c343f 100644 --- a/extractors/all_extractors.py +++ b/extractors/all_extractors.py @@ -1,5 +1,6 @@ from .Multiextractor import MultiExtractor -extractors = [MultiExtractor] +from .BestBuyExtractor import BestBuyExtractor +extractors = [MultiExtractor,BestBuyExtractor] def create_instances(): instances = [] diff --git a/static/js/all_products.js b/static/js/all_products.js index b1f037b..919b2cb 100644 --- a/static/js/all_products.js +++ b/static/js/all_products.js @@ -1,7 +1,7 @@ function migrateIfNeeded(data) { if(!data.price){ data.price = "-1"; - }else if(data.price.includes("$")){ + }else if(typeof data.price === "string" && data.price.includes("$")){ data.price = data.price.replace("$", ""); } if(typeof data.price === "string"){ @@ -66,9 +66,14 @@ docReady(() => { prodLink.href = webpageInfo.url; prodLink.innerText = webpageInfo.url; dataDiv.appendChild(prodLink); - + let priceDiv = document.createElement("div"); priceDiv.className = "product-last-price"; + if(collectedWebpageData.length == 0){ + priceDiv.innerText = "No data collected yet. "; + dataDiv.appendChild(priceDiv); + return; + } priceDiv.innerText = "Last price: " + collectedWebpageData[0].price; dataDiv.appendChild(priceDiv); @@ -77,7 +82,7 @@ docReady(() => { priceGraph.id = "price-graph-" + product.id + "-" + webpage.id; priceGraph.width = "800"; priceGraph.height = "600"; - + // maxmin let arrMax = collectedWebpageData.map(s => s.price).reduce((a,b) => Math.max(a,b)); let arrMin = collectedWebpageData.map(s => s.price).reduce((a,b) => Math.min(a,b)); @@ -137,9 +142,11 @@ docReady(() => { dataDiv.className = "product-all-data"; })); }catch(ex){ + console.log(ex); set_alert_danger("Error" + ex); } }).catch(err => { + console.log(err); set_alert_danger("Error" + err); }); }); \ No newline at end of file diff --git a/templates/layouts/main.html b/templates/layouts/main.html index c5a2ad8..b2e95d8 100644 --- a/templates/layouts/main.html +++ b/templates/layouts/main.html @@ -48,6 +48,9 @@ + @@ -57,6 +60,7 @@ From ce580048bf9da830a263f21c0ba011c80ff167cc Mon Sep 17 00:00:00 2001 From: Raymomyar Date: Fri, 26 Nov 2021 20:22:41 -0800 Subject: [PATCH 4/4] add new extractor + fix product page + fix requirements --- extractors/BestBuyExtractor.py | 2 +- extractors/ExtractorBase.py | 19 ++++++++++++++++++- extractors/Multiextractor.py | 2 +- extractors/WalmartExtractor.py | 18 ++++++++++++++++++ extractors/all_extractors.py | 3 ++- requirements.txt | 4 +++- templates/pages/products.html | 2 +- 7 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 extractors/WalmartExtractor.py diff --git a/extractors/BestBuyExtractor.py b/extractors/BestBuyExtractor.py index 75e0b82..d64f12c 100644 --- a/extractors/BestBuyExtractor.py +++ b/extractors/BestBuyExtractor.py @@ -8,7 +8,7 @@ def __init__(self): def extract_data(self,url): ExtractorBase.extract_data(self,url) # superclass call content = self.driver.page_source - soup = BeautifulSoup(content, 'html.parser') + soup = BeautifulSoup(content, 'lxml') priceElem = next(soup.find('div', { 'class': ['priceView-hero-price','priceView-customer-price'] }).children) diff --git a/extractors/ExtractorBase.py b/extractors/ExtractorBase.py index e0999bb..43f15c6 100644 --- a/extractors/ExtractorBase.py +++ b/extractors/ExtractorBase.py @@ -11,7 +11,24 @@ def __init__(self): self.driver = ExtractorBase.globalDriver return if os.getenv('PHANTOMJS_LOCATION') is None: - self.driver = webdriver.Chrome(os.getenv('CHROMEDRIVER_LOCATION','/home/raymond/chromedriver')) + options = webdriver.ChromeOptions() + # options.add_argument("start-maximized") + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option('useAutomationExtension', False) + self.driver = webdriver.Chrome(os.getenv('CHROMEDRIVER_LOCATION','/home/raymond/chromedriver'), options = options) + try: + from selenium_stealth import stealth + stealth(self.driver, + languages=["en-US", "en"], + vendor="Google Inc.", + platform="Win32", + webgl_vendor="Google Inc. (NVIDIA Corporation)", + renderer="ANGLE (NVIDIA Corporation, NVIDIA GeForce GTX 1650/PCIe/SSE2, OpenGL 4.5.0 NVIDIA 470.74)", + fix_hairline=True + ) + except: + print("Selenium Stealth not found, this may increase your changes of being detected as a bot") + else: self.driver = webdriver.PhantomJS(executable_path=os.getenv('PHANTOMJS_LOCATION')) self.driver.set_window_size(1920, 960) diff --git a/extractors/Multiextractor.py b/extractors/Multiextractor.py index 5112111..dcccb65 100644 --- a/extractors/Multiextractor.py +++ b/extractors/Multiextractor.py @@ -8,7 +8,7 @@ def __init__(self): def extract_data(self,url): ExtractorBase.extract_data(self,url) # superclass call content = self.driver.page_source - soup = BeautifulSoup(content, 'html.parser') + soup = BeautifulSoup(content, 'lxml') if "amazon" in url: #for amazon # pw = soup.find('span', 'a-price-whole') diff --git a/extractors/WalmartExtractor.py b/extractors/WalmartExtractor.py new file mode 100644 index 0000000..c6c6498 --- /dev/null +++ b/extractors/WalmartExtractor.py @@ -0,0 +1,18 @@ +from extractors.ExtractorBase import ExtractorBase + +from bs4 import BeautifulSoup + +class WalmartExtractor(ExtractorBase): + def __init__(self): + super(WalmartExtractor, self).__init__() + def extract_data(self,url): + ExtractorBase.extract_data(self,url) # superclass call + content = self.driver.page_source + soup = BeautifulSoup(content, 'lxml') + priceElem = soup.find('div', { + 'itemprop': "price" + }) + return float(priceElem.getText().replace("$","")) + + def is_valid_url(self, url: str): + return url.startswith('https://www.walmart.com') or url.startswith('https://walmart.com') \ No newline at end of file diff --git a/extractors/all_extractors.py b/extractors/all_extractors.py index e6c343f..3294205 100644 --- a/extractors/all_extractors.py +++ b/extractors/all_extractors.py @@ -1,6 +1,7 @@ from .Multiextractor import MultiExtractor from .BestBuyExtractor import BestBuyExtractor -extractors = [MultiExtractor,BestBuyExtractor] +from .WalmartExtractor import WalmartExtractor +extractors = [MultiExtractor,BestBuyExtractor,WalmartExtractor] def create_instances(): instances = [] diff --git a/requirements.txt b/requirements.txt index 0c17d3d..27d703c 100755 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,6 @@ snowflake-id selenium apscheduler flask_admin -BeautifulSoup \ No newline at end of file +BeautifulSoup4 +cchardet +lxml \ No newline at end of file diff --git a/templates/pages/products.html b/templates/pages/products.html index d5c84e4..d02e808 100644 --- a/templates/pages/products.html +++ b/templates/pages/products.html @@ -1,5 +1,5 @@ {% extends 'layouts/main.html' %} -{% block title %}Add Webpage{% endblock %} +{% block title %}Product Status{% endblock %} {% block content %}