Initial release
This commit is contained in:
parent
89c7ec935b
commit
0b0d2727e8
13 changed files with 1011 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
__pycache__
|
||||||
|
config.json
|
||||||
|
*.html
|
||||||
|
TODO.txt
|
||||||
|
restock.db
|
||||||
|
geckodriver.log
|
29
.pre-commit-config.yaml
Normal file
29
.pre-commit-config.yaml
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: master
|
||||||
|
hooks:
|
||||||
|
- id: check-executables-have-shebangs
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: double-quote-string-fixer
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: fix-encoding-pragma
|
||||||
|
args: ['--remove']
|
||||||
|
- id: requirements-txt-fixer
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: check-json
|
||||||
|
- repo: https://gitlab.com/pycqa/flake8
|
||||||
|
rev: master
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
args: ['--max-line-length=120']
|
||||||
|
- repo: https://github.com/FalconSocial/pre-commit-python-sorter
|
||||||
|
rev: master
|
||||||
|
hooks:
|
||||||
|
- id: python-import-sorter
|
||||||
|
args: ['--silent-overwrite']
|
||||||
|
- repo: https://github.com/chewse/pre-commit-mirrors-pydocstyle
|
||||||
|
rev: master
|
||||||
|
hooks:
|
||||||
|
- id: pydocstyle
|
||||||
|
args: ['--config=.pydocstyle', '--match="(?!test_).*\.py"']
|
2
.pydocstyle
Normal file
2
.pydocstyle
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[pydocstyle]
|
||||||
|
ignore = D100,D104,D400,D203,D204,D101,D213,D202
|
89
README.md
Normal file
89
README.md
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
Year 2020 has been quite hard for hardware supply. Graphics Cards are out of stock everywhere. Nobody can grab the
|
||||||
|
new generation (AMD RX 6000 series, NVIDIA GeForce RTX 3000 series). Even older generations are hard to find.
|
||||||
|
**GraphicRestock** is a bot that crawl retailers websites and notify when a product is available.
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
|
||||||
|
Based on Debian 10:
|
||||||
|
|
||||||
|
```
|
||||||
|
apt install python3-selenium python3-sqlalchemy python3-tweepy firefox-esr
|
||||||
|
curl -L -s https://github.com/mozilla/geckodriver/releases/download/v0.28.0/geckodriver-v0.28.0-linux64.tar.gz | tar xvpzf - -C /usr/local/bin/
|
||||||
|
chown root:root /usr/local/bin/geckodriver
|
||||||
|
chmod +x /usr/local/bin/geckodriver
|
||||||
|
```
|
||||||
|
|
||||||
|
# Configure
|
||||||
|
|
||||||
|
Configuration file example can be found [here](config.json.example).
|
||||||
|
|
||||||
|
Options:
|
||||||
|
* **twitter.consumer_key**: key of your Twitter application
|
||||||
|
* **twitter.consumer_secret**: secret of your Twitter application
|
||||||
|
* **twitter.access_token**: authentication token generated by [twitter_auth.py](twitter_auth.py)
|
||||||
|
* **twitter.access_token_secret**: authentication token secret generated by [twitter_auth.py](twitter_auth.py)
|
||||||
|
* **urls**: list of retailers web pages (they need to respect crawlers' format)
|
||||||
|
* **executable_path** (optional): path to selenium driver (firefox/gecko browser)
|
||||||
|
|
||||||
|
|
||||||
|
# Twitter authentication
|
||||||
|
|
||||||
|
Create a configuration file with **twitter.consumer_key** and **twitter.consumer_secret** parameters.
|
||||||
|
|
||||||
|
Then authenticate:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 twitter_auth.py
|
||||||
|
```
|
||||||
|
|
||||||
|
You will have to open the URL and authenticate:
|
||||||
|
|
||||||
|
```
|
||||||
|
Please go to https://api.twitter.com/oauth/authorize?oauth_token=****
|
||||||
|
|
||||||
|
```
|
||||||
|
Click on **Authorize app**. A verifier code will be shown. Go back to your console and enter the code.
|
||||||
|
|
||||||
|
```
|
||||||
|
Verifier:*******
|
||||||
|
```
|
||||||
|
|
||||||
|
Tokens will be created:
|
||||||
|
|
||||||
|
```
|
||||||
|
access_token = *****
|
||||||
|
access_token_secret = ****
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, write them to configuration file in **twitter.access_token** and **twitter.access_token_secret** parameters.
|
||||||
|
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 main.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
# How to contribute
|
||||||
|
|
||||||
|
First things first, check issues to ensure the feature or bug you are facing is not already declared.
|
||||||
|
|
||||||
|
Pull requests are highly appreciated.
|
||||||
|
|
||||||
|
Please lint your code:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run -it -v $(pwd):/mnt/ --rm debian:10 bash
|
||||||
|
apt-get update && apt-get upgrade -y && apt-get install -y python3-pip git
|
||||||
|
pip3 install pre-commit
|
||||||
|
cd /mnt
|
||||||
|
pre-commit run --all-files
|
||||||
|
```
|
||||||
|
|
||||||
|
Happy coding!
|
||||||
|
|
||||||
|
|
||||||
|
# Disclaimer
|
||||||
|
|
||||||
|
Crawling a website should be used with caution. Please check with retailers if the bot respects the terms of use for
|
||||||
|
their websites. Authors of the bot are not responsible of the bot usage.
|
18
config.json.example
Normal file
18
config.json.example
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"twitter": {
|
||||||
|
"consumer_key": "***",
|
||||||
|
"consumer_secret": "***",
|
||||||
|
"access_token": "***",
|
||||||
|
"access_token_secret": "***"
|
||||||
|
},
|
||||||
|
"urls": [
|
||||||
|
"https://www.topachat.com/pages/produits_cat_est_micro_puis_rubrique_est_wgfx_pcie_puis_f_est_58-11447,11445,11446,11559,11558.html",
|
||||||
|
"https://www.ldlc.com/informatique/pieces-informatique/carte-graphique-interne/c4684/+fv121-19183,19184,19185,19339,19340.html",
|
||||||
|
"https://www.materiel.net/carte-graphique/l426/+fv121-19183,19184,19185,19339,19340/",
|
||||||
|
"https://www.alternate.be/Hardware/Grafische-kaarten/NVIDIA/RTX-3060-Ti",
|
||||||
|
"https://www.alternate.be/Hardware/Grafische-kaarten/NVIDIA/RTX-3070",
|
||||||
|
"https://www.alternate.be/Hardware/Grafische-kaarten/NVIDIA/RTX-3080",
|
||||||
|
"https://www.alternate.be/Hardware/Grafische-kaarten/NVIDIA/RTX-3090"
|
||||||
|
],
|
||||||
|
"executable_path": "/usr/bin/geckodriver"
|
||||||
|
}
|
24
config.py
Normal file
24
config.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from utils import parse_base_url
|
||||||
|
|
||||||
|
|
||||||
|
def read_config(filename):
|
||||||
|
with open(filename, 'r') as fd:
|
||||||
|
return json.load(fd)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_shops(urls):
|
||||||
|
"""
|
||||||
|
Parse shop name and return list of addresses for each shop
|
||||||
|
Example: {"toto.com/first", "toto.com/second", "tata.com/first"}
|
||||||
|
-> {"toto.com": ["toto.com/first", "toto.com/second"], "tata.com": ["tata.com/first"]}
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for url in urls:
|
||||||
|
base_url = parse_base_url(url, include_scheme=False)
|
||||||
|
if base_url not in result:
|
||||||
|
result[base_url] = [url]
|
||||||
|
else:
|
||||||
|
result[base_url].append(url)
|
||||||
|
return result
|
94
crawlers.py
Normal file
94
crawlers.py
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from parsers import (AlternateParser, LDLCParser, MaterielNetParser,
|
||||||
|
TopAchatParser)
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.common.exceptions import TimeoutException
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.firefox.options import Options
|
||||||
|
from selenium.webdriver.support import expected_conditions
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCrawler(object):
|
||||||
|
|
||||||
|
TIMEOUT = 3
|
||||||
|
|
||||||
|
def __init__(self, shop):
|
||||||
|
options = Options()
|
||||||
|
options.headless = True
|
||||||
|
self._driver = webdriver.Firefox(executable_path='/usr/local/bin/geckodriver', options=options)
|
||||||
|
self._shop = shop
|
||||||
|
self.products = []
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self._driver.quit()
|
||||||
|
|
||||||
|
def fetch(self, url, wait_for=None):
|
||||||
|
self._driver.get(url)
|
||||||
|
if wait_for:
|
||||||
|
try:
|
||||||
|
condition = expected_conditions.presence_of_element_located((By.CLASS_NAME, wait_for))
|
||||||
|
WebDriverWait(self._driver, self.TIMEOUT).until(condition)
|
||||||
|
except TimeoutException:
|
||||||
|
logger.warning(f'timeout waiting for element "{wait_for}" at {url}')
|
||||||
|
logger.info(f'url {url} fetched')
|
||||||
|
webpage = self._driver.execute_script("return document.getElementsByTagName('html')[0].innerHTML")
|
||||||
|
return webpage
|
||||||
|
|
||||||
|
def add_shop(self, products):
|
||||||
|
for product in products:
|
||||||
|
product.shop = self._shop
|
||||||
|
return products
|
||||||
|
|
||||||
|
|
||||||
|
class TopAchatCrawler(ProductCrawler):
|
||||||
|
def __init__(self, shop, urls):
|
||||||
|
super().__init__(shop)
|
||||||
|
parser = TopAchatParser()
|
||||||
|
for url in urls:
|
||||||
|
webpage = self.fetch(url=url)
|
||||||
|
parser.feed(webpage)
|
||||||
|
self.products += self.add_shop(parser.products)
|
||||||
|
|
||||||
|
|
||||||
|
class LDLCCrawler(ProductCrawler):
|
||||||
|
def __init__(self, shop, urls):
|
||||||
|
super().__init__(shop)
|
||||||
|
parser = LDLCParser()
|
||||||
|
for url in urls:
|
||||||
|
next_page = url
|
||||||
|
previous_page = None
|
||||||
|
while next_page != previous_page:
|
||||||
|
webpage = self.fetch(url=next_page)
|
||||||
|
parser.feed(webpage)
|
||||||
|
previous_page = next_page
|
||||||
|
next_page = parser.next_page
|
||||||
|
self.products += self.add_shop(parser.products)
|
||||||
|
|
||||||
|
|
||||||
|
class MaterielNetCrawler(ProductCrawler):
|
||||||
|
def __init__(self, shop, urls):
|
||||||
|
super().__init__(shop)
|
||||||
|
parser = MaterielNetParser()
|
||||||
|
for url in urls:
|
||||||
|
next_page = url
|
||||||
|
previous_page = None
|
||||||
|
while next_page != previous_page:
|
||||||
|
webpage = self.fetch(url=next_page, wait_for='o-product__price')
|
||||||
|
parser.feed(webpage)
|
||||||
|
previous_page = next_page
|
||||||
|
next_page = parser.next_page
|
||||||
|
self.products += self.add_shop(parser.products)
|
||||||
|
|
||||||
|
|
||||||
|
class AlternateCrawler(ProductCrawler):
|
||||||
|
def __init__(self, shop, urls):
|
||||||
|
super().__init__(shop)
|
||||||
|
parser = AlternateParser()
|
||||||
|
for url in urls:
|
||||||
|
webpage = self.fetch(url=url)
|
||||||
|
parser.feed(webpage)
|
||||||
|
self.products += self.add_shop(parser.products)
|
119
db.py
Normal file
119
db.py
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Integer,
|
||||||
|
String, create_engine, exc)
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import relationship, sessionmaker
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
engine = create_engine('sqlite:///restock.db')
|
||||||
|
Session = sessionmaker(bind=engine, autoflush=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Shop(Base):
|
||||||
|
__tablename__ = 'shop'
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
name = Column(String, unique=True, nullable=False)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'Shop<{self.name}>'
|
||||||
|
|
||||||
|
def __ne__(self, shop):
|
||||||
|
return self.name != shop.name
|
||||||
|
|
||||||
|
|
||||||
|
class Product(Base):
|
||||||
|
__tablename__ = 'product'
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
url = Column(String, nullable=False, unique=True)
|
||||||
|
price = Column(Float, nullable=False)
|
||||||
|
price_currency = Column(String, nullable=False)
|
||||||
|
available = Column(Boolean, nullable=False)
|
||||||
|
updated_at = Column(DateTime)
|
||||||
|
tweet_id = Column(Integer, unique=True)
|
||||||
|
shop_id = Column(Integer, ForeignKey('shop.id'), nullable=False)
|
||||||
|
shop = relationship('Shop', foreign_keys=[shop_id])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'Product<{self.name}@{self.shop.name}>'
|
||||||
|
|
||||||
|
def __ne__(self, product):
|
||||||
|
return self.name != product.name or self.price != product.price or self.available != product.available \
|
||||||
|
or self.url != product.url or self.shop != product.shop
|
||||||
|
|
||||||
|
def ok(self):
|
||||||
|
return self.name and self.url and self.price and self.price_currency and self.available is not None
|
||||||
|
|
||||||
|
|
||||||
|
def create_tables():
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
logger.debug('tables created')
|
||||||
|
|
||||||
|
|
||||||
|
def list_shops():
|
||||||
|
session = Session()
|
||||||
|
shops = session.query(Shop).all()
|
||||||
|
session.close()
|
||||||
|
return shops
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_shops(names):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
for name in names:
|
||||||
|
shop = Shop(name=name)
|
||||||
|
query = session.query(Shop).filter(Shop.name == shop.name)
|
||||||
|
shop_database = query.first()
|
||||||
|
if not shop_database:
|
||||||
|
logger.info(f'{shop} added')
|
||||||
|
session.add(shop)
|
||||||
|
session.commit()
|
||||||
|
logger.debug('transaction committed')
|
||||||
|
except exc.SQLAlchemyError:
|
||||||
|
logger.exception('cannot commit transaction')
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_products(products, notifier=None):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
for product in products:
|
||||||
|
query = session.query(Product).filter(Product.name == product.name, Product.shop == product.shop)
|
||||||
|
product_database = query.first()
|
||||||
|
now = datetime.utcnow()
|
||||||
|
tweet_id = None
|
||||||
|
if not product_database:
|
||||||
|
# product is new and available so we need to create an initial thread
|
||||||
|
if notifier and product.available:
|
||||||
|
product.tweet_id = notifier.create_thread(product).id
|
||||||
|
product.updated_at = now
|
||||||
|
session.add(product)
|
||||||
|
logger.info(f'{product} added')
|
||||||
|
elif product != product_database:
|
||||||
|
# notifications
|
||||||
|
if notifier and product.available != product_database.available:
|
||||||
|
if product.available and not product_database.tweet_id:
|
||||||
|
# product is now available so we need to create an initial tweet (or thread)
|
||||||
|
tweet = notifier.create_thread(product)
|
||||||
|
if tweet:
|
||||||
|
tweet_id = tweet.id
|
||||||
|
elif not product.available and product_database.available and product_database.tweet_id:
|
||||||
|
# product is out of stock so we need to reply to previous tweet to close the thread
|
||||||
|
notifier.close_thread(tweet_id=product_database.tweet_id,
|
||||||
|
duration=now-product_database.updated_at)
|
||||||
|
query.update({Product.price: product.price, Product.price_currency: product.price_currency,
|
||||||
|
Product.available: product.available, Product.url: product.url,
|
||||||
|
Product.tweet_id: tweet_id, Product.updated_at: now})
|
||||||
|
logger.info(f'{product} updated')
|
||||||
|
session.commit()
|
||||||
|
logger.debug('transaction committed')
|
||||||
|
except exc.SQLAlchemyError:
|
||||||
|
logger.exception('cannot commit transaction')
|
||||||
|
finally:
|
||||||
|
session.close()
|
71
main.py
Normal file
71
main.py
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from config import extract_shops, read_config
|
||||||
|
from crawlers import (AlternateCrawler, LDLCCrawler, MaterielNetCrawler,
|
||||||
|
TopAchatCrawler)
|
||||||
|
from db import create_tables, list_shops, upsert_products, upsert_shops
|
||||||
|
from notifiers import TwitterNotifier
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('-v', '--verbose', dest='loglevel', action='store_const', const=logging.INFO,
|
||||||
|
help='print more output')
|
||||||
|
parser.add_argument('-d', '--debug', dest='loglevel', action='store_const', const=logging.DEBUG,
|
||||||
|
default=logging.WARNING, help='print even more output')
|
||||||
|
parser.add_argument('-o', '--logfile', help='logging file location')
|
||||||
|
parser.add_argument('-c', '--config', default='config.json', help='configuration file location')
|
||||||
|
parser.add_argument('-N', '--disable-notifications', dest='disable_notifications', action='store_true',
|
||||||
|
help='Do not send notifications')
|
||||||
|
args = parser.parse_args()
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(args):
|
||||||
|
log_format = '%(asctime)s %(levelname)s: %(message)s' if args.logfile else '%(levelname)s: %(message)s'
|
||||||
|
logging.basicConfig(format=log_format, level=args.loglevel, filename=args.logfile)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_arguments()
|
||||||
|
setup_logging(args)
|
||||||
|
config = read_config(args.config)
|
||||||
|
create_tables()
|
||||||
|
|
||||||
|
shops = extract_shops(config['urls'])
|
||||||
|
upsert_shops(shops.keys())
|
||||||
|
|
||||||
|
if args.disable_notifications:
|
||||||
|
notifier = None
|
||||||
|
else:
|
||||||
|
notifier = TwitterNotifier(consumer_key=config['twitter']['consumer_key'],
|
||||||
|
consumer_secret=config['twitter']['consumer_secret'],
|
||||||
|
access_token=config['twitter']['access_token'],
|
||||||
|
access_token_secret=config['twitter']['access_token_secret'])
|
||||||
|
|
||||||
|
for shop in list_shops():
|
||||||
|
logger.debug(f'processing {shop}')
|
||||||
|
urls = shops.get(shop.name)
|
||||||
|
if not urls:
|
||||||
|
logger.warning(f'cannot find urls for shop {shop} in the configuration file')
|
||||||
|
continue
|
||||||
|
if shop.name == 'topachat.com':
|
||||||
|
crawler = TopAchatCrawler(shop=shop, urls=urls)
|
||||||
|
elif shop.name == 'ldlc.com':
|
||||||
|
crawler = LDLCCrawler(shop=shop, urls=urls)
|
||||||
|
elif shop.name == 'materiel.net':
|
||||||
|
crawler = MaterielNetCrawler(shop=shop, urls=urls)
|
||||||
|
elif shop.name == 'alternate.be':
|
||||||
|
crawler = AlternateCrawler(shop=shop, urls=urls)
|
||||||
|
else:
|
||||||
|
logger.warning(f'shop {shop} not supported')
|
||||||
|
continue
|
||||||
|
upsert_products(products=crawler.products, notifier=notifier)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
57
notifiers.py
Normal file
57
notifiers.py
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import tweepy
|
||||||
|
from utils import format_timedelta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TwitterNotifier(object):
|
||||||
|
|
||||||
|
_hashtags_map = {
|
||||||
|
'rtx 3060 ti': ['#nvidia', '#rtx3060ti'],
|
||||||
|
'rtx 3070': ['#nvidia', '#rtx3070'],
|
||||||
|
'rtx 3080': ['#nvidia', '#rtx3080'],
|
||||||
|
'rtx 3090': ['#nvidia', '#rtx3090'],
|
||||||
|
'rx 6800 xt': ['#amd', '#rx6800xt'],
|
||||||
|
'rx 6800': ['#amd', '#rx6800'],
|
||||||
|
}
|
||||||
|
|
||||||
|
_currency_map = {
|
||||||
|
'EUR': '€'
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, consumer_key, consumer_secret, access_token, access_token_secret):
|
||||||
|
auth = tweepy.OAuthHandler(consumer_key, consumer_secret)
|
||||||
|
auth.set_access_token(access_token, access_token_secret)
|
||||||
|
self._api = tweepy.API(auth)
|
||||||
|
|
||||||
|
def create_thread(self, product):
|
||||||
|
currency_sign = self._currency_map[product.price_currency]
|
||||||
|
shop_name = product.shop.name
|
||||||
|
price = f'{product.price}{currency_sign}'
|
||||||
|
message = f'{shop_name}: {product.name} for {price} is available at {product.url}'
|
||||||
|
hashtags = self._parse_hashtags(product)
|
||||||
|
if hashtags:
|
||||||
|
message += f' {hashtags}'
|
||||||
|
return self._create_tweet(message=message)
|
||||||
|
|
||||||
|
def close_thread(self, tweet_id, duration):
|
||||||
|
thread = self._api.get_status(id=tweet_id)
|
||||||
|
duration = format_timedelta(duration, '{hours_total}h{minutes2}m')
|
||||||
|
message = f'''@{thread.user.screen_name} And it's over ({duration})'''
|
||||||
|
return self._create_tweet(message=message, tweet_id=tweet_id)
|
||||||
|
|
||||||
|
def _create_tweet(self, message, tweet_id=None):
|
||||||
|
try:
|
||||||
|
tweet = self._api.update_status(status=message, in_reply_to_status_id=tweet_id)
|
||||||
|
logger.info(f'tweet {tweet.id} sent with message "{message}"')
|
||||||
|
return tweet
|
||||||
|
except tweepy.error.TweepError as err:
|
||||||
|
logger.warning('cannot send tweet with message "{message}"')
|
||||||
|
logger.warning(str(err))
|
||||||
|
|
||||||
|
def _parse_hashtags(self, product):
|
||||||
|
for patterns in self._hashtags_map:
|
||||||
|
if all(elem in product.name.lower().split(' ') for elem in patterns.split(' ')):
|
||||||
|
return ' '.join(self._hashtags_map[patterns])
|
403
parsers.py
Normal file
403
parsers.py
Normal file
|
@ -0,0 +1,403 @@
|
||||||
|
import logging
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
from db import Product
|
||||||
|
from utils import parse_base_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Parsers definitively need to be replaced by beautifulsoup because the code is not maintainable
|
||||||
|
|
||||||
|
|
||||||
|
class ProductParser(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.products = []
|
||||||
|
self.next_page = None
|
||||||
|
|
||||||
|
|
||||||
|
class TopAchatParser(ProductParser):
|
||||||
|
def __init__(self, url=None):
|
||||||
|
super().__init__()
|
||||||
|
self._parsing_article = False
|
||||||
|
self._parsing_availability = False
|
||||||
|
self._parsing_price = False
|
||||||
|
self._parsing_price_currency = False
|
||||||
|
self._parsing_name = False
|
||||||
|
self._parsing_url = False
|
||||||
|
self._product = Product()
|
||||||
|
if url:
|
||||||
|
self._base_url = parse_base_url(url)
|
||||||
|
else:
|
||||||
|
self._base_url = 'https://www.topachat.com'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_name(data):
|
||||||
|
return data.split(' + ')[0].strip()
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag == 'article':
|
||||||
|
for name, value in attrs:
|
||||||
|
if 'grille-produit' in value.split(' '):
|
||||||
|
self._parsing_article = True
|
||||||
|
elif self._parsing_article:
|
||||||
|
if tag == 'link':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'itemprop' and value == 'availability':
|
||||||
|
self._parsing_availability = True
|
||||||
|
elif self._parsing_availability and name == 'href':
|
||||||
|
self._product.available = value != 'http://schema.org/OutOfStock'
|
||||||
|
elif tag == 'div':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'itemprop' and value == 'price':
|
||||||
|
self._parsing_price = True
|
||||||
|
elif self._parsing_price and name == 'content':
|
||||||
|
self._product.price = float(value)
|
||||||
|
elif name == 'class' and value == 'libelle':
|
||||||
|
self._parsing_url = True
|
||||||
|
self._parsing_name = True
|
||||||
|
elif tag == 'meta':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'itemprop' and value == 'priceCurrency':
|
||||||
|
self._parsing_price_currency = True
|
||||||
|
elif self._parsing_price_currency and name == 'content':
|
||||||
|
self._product.price_currency = value
|
||||||
|
elif tag == 'a':
|
||||||
|
for name, value in attrs:
|
||||||
|
if self._parsing_url and name == 'href':
|
||||||
|
self._product.url = f'{self._base_url}{value}'
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self._parsing_name and self.get_starttag_text().startswith('<h3>') and not self._product.name:
|
||||||
|
self._product.name = self.parse_name(data)
|
||||||
|
self._parsing_name = False
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if self._parsing_article and tag == 'article':
|
||||||
|
self._parsing_article = False
|
||||||
|
self.products.append(self._product)
|
||||||
|
self._product = Product()
|
||||||
|
elif self._parsing_availability and tag == 'link':
|
||||||
|
self._parsing_availability = False
|
||||||
|
elif self._parsing_price and tag == 'div':
|
||||||
|
self._parsing_price = False
|
||||||
|
elif self._parsing_price_currency and tag == 'meta':
|
||||||
|
self._parsing_price_currency = False
|
||||||
|
|
||||||
|
|
||||||
|
class LDLCParser(ProductParser):
|
||||||
|
def __init__(self, url=None):
|
||||||
|
super().__init__()
|
||||||
|
self._product = Product()
|
||||||
|
self.__parsing_pdt_item = False
|
||||||
|
self.__parsing_pdt_id = False
|
||||||
|
self._parsing_title = False
|
||||||
|
self.__parsing_pagination = False
|
||||||
|
self.__parsing_next_page_section = False
|
||||||
|
self._parsing_stock = False
|
||||||
|
self._parsing_price = False
|
||||||
|
if url:
|
||||||
|
self._base_url = parse_base_url(url)
|
||||||
|
else:
|
||||||
|
self._base_url = 'https://www.ldlc.com'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _parsing_item(self):
|
||||||
|
return self.__parsing_pdt_item and self.__parsing_pdt_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _parsing_next_page(self):
|
||||||
|
return self.__parsing_pagination and self.__parsing_next_page_section
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_price(string):
|
||||||
|
currency = None
|
||||||
|
if '€' in string:
|
||||||
|
currency = 'EUR'
|
||||||
|
price = int(''.join([i for i in string if i.isdigit()]))
|
||||||
|
return price, currency
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if not self._parsing_item and tag == 'li' and not self.__parsing_pagination:
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and value == 'pdt-item':
|
||||||
|
self.__parsing_pdt_item = True
|
||||||
|
elif name == 'id' and value.startswith('pdt-'):
|
||||||
|
self.__parsing_pdt_id = True
|
||||||
|
elif not self.__parsing_pagination and tag == 'ul':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and value == 'pagination':
|
||||||
|
self.__parsing_pagination = True
|
||||||
|
elif self.__parsing_pagination and tag == 'li':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and value == 'next':
|
||||||
|
self.__parsing_next_page_section = True
|
||||||
|
elif self._parsing_next_page and tag == 'a':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'href':
|
||||||
|
self.next_page = f'{self._base_url}{value}'
|
||||||
|
elif self._parsing_item:
|
||||||
|
if tag == 'h3':
|
||||||
|
self._parsing_title = True
|
||||||
|
elif self._parsing_title and tag == 'a':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'href':
|
||||||
|
self._product.url = f'{self._base_url}{value}'
|
||||||
|
elif tag == 'div':
|
||||||
|
for name, value in attrs:
|
||||||
|
if not self._parsing_stock and name == 'class' and 'modal-stock-web' in value.split(' '):
|
||||||
|
self._parsing_stock = True
|
||||||
|
elif not self._parsing_price and name == 'class' and value == 'price':
|
||||||
|
self._parsing_price = True
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
last_tag = self.get_starttag_text()
|
||||||
|
if self._parsing_title and not self._product.name and last_tag.startswith('<a'):
|
||||||
|
self._product.name = data.strip()
|
||||||
|
elif self._parsing_stock and self._product.available is None and last_tag.startswith('<span>'):
|
||||||
|
self._product.available = data.strip() != 'Rupture'
|
||||||
|
elif self._parsing_price:
|
||||||
|
if last_tag.startswith('<div'):
|
||||||
|
self._product.price, self._product.price_currency = self.parse_price(data)
|
||||||
|
elif last_tag.startswith('<sup>'):
|
||||||
|
self._product.price += int(data) / 100
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if self._parsing_item and tag == 'li':
|
||||||
|
self.__parsing_pdt_item = False
|
||||||
|
self.__parsing_pdt_id = False
|
||||||
|
self.products.append(self._product)
|
||||||
|
self._product = Product()
|
||||||
|
elif self._parsing_title and tag == 'h3':
|
||||||
|
self._parsing_title = False
|
||||||
|
elif self._parsing_stock and tag == 'span':
|
||||||
|
self._parsing_stock = False
|
||||||
|
elif self._parsing_price and tag == 'div':
|
||||||
|
self._parsing_price = False
|
||||||
|
elif self.__parsing_pagination and tag == 'ul':
|
||||||
|
self.__parsing_pagination = False
|
||||||
|
elif self.__parsing_next_page_section and tag == 'a':
|
||||||
|
self.__parsing_next_page_section = False
|
||||||
|
|
||||||
|
|
||||||
|
class MaterielNetParser(ProductParser):
|
||||||
|
def __init__(self, url=None):
|
||||||
|
super().__init__()
|
||||||
|
self._product = Product()
|
||||||
|
self._parsing_product = False
|
||||||
|
self._parsing_product_meta = False
|
||||||
|
self._parsing_title = False
|
||||||
|
self.__parsing_product_availability = False
|
||||||
|
self.__stock_web_id = None
|
||||||
|
self._parsing_availability = False
|
||||||
|
self.__parsing_price_category = False
|
||||||
|
self.__parsing_price_objects = False
|
||||||
|
self._parsing_price = False
|
||||||
|
self._parsing_pagination = False
|
||||||
|
self.__active_page_found = False
|
||||||
|
self.__parsing_next_page = False
|
||||||
|
self._pagination_parsed = False
|
||||||
|
if url:
|
||||||
|
self._base_url = parse_base_url(url)
|
||||||
|
else:
|
||||||
|
self._base_url = 'https://www.materiel.net'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _parsing_web_availability(self):
|
||||||
|
return self.__parsing_product_availability and self.__stock_web_id
|
||||||
|
|
||||||
|
def _close_availability_parsing(self):
|
||||||
|
self._parsing_availability = False
|
||||||
|
self.__stock_web_id = None
|
||||||
|
self.__parsing_product_availability = False
|
||||||
|
|
||||||
|
def _close_product_meta_parsing(self):
|
||||||
|
self._parsing_product_meta = False
|
||||||
|
|
||||||
|
def _close_title_parsing(self):
|
||||||
|
self._parsing_title = False
|
||||||
|
|
||||||
|
def _close_price_parsing(self):
|
||||||
|
self.__parsing_price_category = False
|
||||||
|
self.__parsing_price_objects = False
|
||||||
|
self._parsing_price = False
|
||||||
|
|
||||||
|
def _close_product_parsing(self):
|
||||||
|
self._parsing_product = False
|
||||||
|
self.products.append(self._product)
|
||||||
|
self._product = Product()
|
||||||
|
|
||||||
|
def _close_pagination_parsing(self):
|
||||||
|
self._parsing_pagination = False
|
||||||
|
self._pagination_parsed = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_price(string):
|
||||||
|
currency = None
|
||||||
|
if '€' in string:
|
||||||
|
currency = 'EUR'
|
||||||
|
price = int(''.join([i for i in string if i.isdigit()]))
|
||||||
|
return price, currency
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if not self._parsing_product and tag == 'li':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and 'ajax-product-item' in value.split(' '):
|
||||||
|
self._parsing_product = True
|
||||||
|
|
||||||
|
if not self._parsing_product_meta and tag == 'div':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and value == 'c-product__meta':
|
||||||
|
self._parsing_product_meta = True
|
||||||
|
elif self._parsing_product_meta:
|
||||||
|
if tag == 'a':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'href':
|
||||||
|
self._product.url = f'{self._base_url}{value}'
|
||||||
|
elif tag == 'h2':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and value == 'c-product__title':
|
||||||
|
self._parsing_title = True
|
||||||
|
if tag == 'div':
|
||||||
|
for name, value in attrs:
|
||||||
|
if not self.__parsing_product_availability and name == 'class' and value == 'c-product__availability':
|
||||||
|
self.__parsing_product_availability = True
|
||||||
|
elif self.__parsing_product_availability and name == 'data-stock-web':
|
||||||
|
self.__stock_web_id = value
|
||||||
|
elif tag == 'span' and self._parsing_web_availability:
|
||||||
|
for name, value in attrs:
|
||||||
|
availability_class_name = f'o-availability__value--stock_{self.__stock_web_id}'
|
||||||
|
if name == 'class' and availability_class_name in value.split(' '):
|
||||||
|
self._parsing_availability = True
|
||||||
|
if not self.__parsing_price_objects and tag == 'div':
|
||||||
|
for name, value in attrs:
|
||||||
|
if not self.__parsing_price_category and name == 'class' and value == 'c-product__prices':
|
||||||
|
self.__parsing_price_category = True
|
||||||
|
elif self.__parsing_price_category and name == 'class' and 'o-product__prices' in value.split(' '):
|
||||||
|
self.__parsing_price_objects = True
|
||||||
|
elif self.__parsing_price_objects and tag == 'span':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and value == 'o-product__price':
|
||||||
|
self._parsing_price = True
|
||||||
|
if not self._pagination_parsed:
|
||||||
|
if not self._parsing_pagination and tag == 'ul':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and value == 'pagination':
|
||||||
|
self._parsing_pagination = True
|
||||||
|
elif self._parsing_pagination and tag == 'li':
|
||||||
|
for name, value in attrs:
|
||||||
|
values = value.split(' ')
|
||||||
|
if not self.__active_page_found and name == 'class' and 'page-item' in values \
|
||||||
|
and 'active' in values:
|
||||||
|
self.__active_page_found = True
|
||||||
|
elif self.__active_page_found and name == 'class' and 'page-item' in values:
|
||||||
|
self.__parsing_next_page = True
|
||||||
|
elif self.__parsing_next_page and tag == 'a':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'href':
|
||||||
|
self.next_page = f'{self._base_url}{value}'
|
||||||
|
self.__parsing_next_page = False
|
||||||
|
self._pagination_parsed = True
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if self._parsing_product_meta and tag == 'div':
|
||||||
|
self._close_product_meta_parsing()
|
||||||
|
elif self._parsing_product and tag == 'li':
|
||||||
|
self._close_product_parsing()
|
||||||
|
elif self._parsing_pagination and tag == 'ul':
|
||||||
|
self._close_pagination_parsing()
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
last_tag = self.get_starttag_text()
|
||||||
|
if self._parsing_title and last_tag.startswith('<h2'):
|
||||||
|
self._product.name = data
|
||||||
|
self._close_title_parsing()
|
||||||
|
elif self._parsing_availability and last_tag.startswith('<span'):
|
||||||
|
self._product.available = data != 'Rupture'
|
||||||
|
self._close_availability_parsing()
|
||||||
|
elif self._parsing_price:
|
||||||
|
if last_tag.startswith('<span'):
|
||||||
|
self._product.price, self._product.price_currency = self.parse_price(data)
|
||||||
|
elif last_tag.startswith('<sup>'):
|
||||||
|
self._product.price += int(data) / 100
|
||||||
|
self._close_price_parsing()
|
||||||
|
|
||||||
|
|
||||||
|
class AlternateParser(ProductParser):
|
||||||
|
def __init__(self, url=None):
|
||||||
|
super().__init__()
|
||||||
|
self._product = Product()
|
||||||
|
if url:
|
||||||
|
self._base_url = parse_base_url(url)
|
||||||
|
else:
|
||||||
|
self._base_url = 'https://www.alternate.be'
|
||||||
|
self._parsing_row = False
|
||||||
|
self._parsing_name = False
|
||||||
|
self._parsing_price = False
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if not self._parsing_row and tag == 'div':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and value == 'listRow':
|
||||||
|
self._parsing_row = True
|
||||||
|
elif self._parsing_row:
|
||||||
|
if tag == 'a':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'href' and not self._product.url:
|
||||||
|
self._product.url = self.parse_url(value)
|
||||||
|
elif tag == 'span':
|
||||||
|
if not self._parsing_name:
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class':
|
||||||
|
if value == 'name':
|
||||||
|
self._parsing_name = True
|
||||||
|
elif self._parsing_name:
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and value == 'additional':
|
||||||
|
self._parsing_name = False
|
||||||
|
if not self._parsing_price:
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and 'price' in value.split(' '):
|
||||||
|
self._parsing_price = True
|
||||||
|
elif tag == 'strong':
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'class' and 'stockStatus' in value.split(' '):
|
||||||
|
values = value.split(' ')
|
||||||
|
available = 'available_unsure' not in values and 'preorder' not in values
|
||||||
|
self._product.available = available
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self._parsing_name:
|
||||||
|
data = data.replace('grafische kaart', '').strip()
|
||||||
|
if data:
|
||||||
|
if not self._product.name:
|
||||||
|
self._product.name = data
|
||||||
|
else:
|
||||||
|
self._product.name += f' {data}'
|
||||||
|
elif self._parsing_price:
|
||||||
|
price, currency = self.parse_price(data)
|
||||||
|
if price and currency:
|
||||||
|
self._product.price = price
|
||||||
|
self._product.price_currency = currency
|
||||||
|
self._parsing_price = False
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag == 'span' and self._parsing_price:
|
||||||
|
self._parsing_price = False
|
||||||
|
elif tag == 'div' and self._parsing_row and self._product.ok():
|
||||||
|
self._parsing_row = False
|
||||||
|
self.products.append(self._product)
|
||||||
|
self._product = Product()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_price(string):
|
||||||
|
currency = None
|
||||||
|
if '€' in string:
|
||||||
|
currency = 'EUR'
|
||||||
|
price = int(''.join([i for i in string if i.isdigit()]))
|
||||||
|
return price, currency
|
||||||
|
|
||||||
|
def parse_url(self, string):
|
||||||
|
string = string.split('?')[0] # remove query string
|
||||||
|
return f'{self._base_url}{string}'
|
46
twitter_auth.py
Normal file
46
twitter_auth.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import tweepy
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open('config.json', 'r') as fd:
|
||||||
|
config = json.load(fd)
|
||||||
|
|
||||||
|
if 'access_token' in config['twitter'] and 'access_token_secret' in config['twitter']:
|
||||||
|
access_token = config['twitter']['access_token']
|
||||||
|
access_token_secret = config['twitter']['access_token_secret']
|
||||||
|
else:
|
||||||
|
consumer_key = config['twitter']['consumer_key']
|
||||||
|
consumer_secret = config['twitter']['consumer_secret']
|
||||||
|
|
||||||
|
auth = tweepy.OAuthHandler(consumer_key, consumer_secret)
|
||||||
|
|
||||||
|
try:
|
||||||
|
redirect_url = auth.get_authorization_url()
|
||||||
|
print(f'Please go to {redirect_url}')
|
||||||
|
except tweepy.TweepError:
|
||||||
|
print('Failed to get request token')
|
||||||
|
|
||||||
|
token = urlparse(redirect_url).query.split('=')[1]
|
||||||
|
|
||||||
|
verifier = input('Verifier:')
|
||||||
|
auth.request_token = {'oauth_token': token, 'oauth_token_secret': verifier}
|
||||||
|
|
||||||
|
try:
|
||||||
|
auth.get_access_token(verifier)
|
||||||
|
except tweepy.TweepError:
|
||||||
|
print('Failed to get access token')
|
||||||
|
|
||||||
|
access_token = auth.access_token
|
||||||
|
access_token_secret = auth.access_token_secret
|
||||||
|
|
||||||
|
print(f'access_token = {access_token}')
|
||||||
|
print(f'access_token_secret = {access_token_secret}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
53
utils.py
Normal file
53
utils.py
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
from math import floor
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def format_timedelta(value, time_format='{days} days, {hours2}:{minutes2}:{seconds2}'):
|
||||||
|
"""
|
||||||
|
Taken from https://github.com/frnhr/django_timedeltatemplatefilter and
|
||||||
|
https://github.com/frnhr/django_timedeltatemplatefilter
|
||||||
|
"""
|
||||||
|
if hasattr(value, 'seconds'):
|
||||||
|
seconds = value.seconds + value.days * 24 * 3600
|
||||||
|
else:
|
||||||
|
seconds = int(value)
|
||||||
|
|
||||||
|
seconds_total = seconds
|
||||||
|
|
||||||
|
minutes = int(floor(seconds / 60))
|
||||||
|
minutes_total = minutes
|
||||||
|
seconds -= minutes * 60
|
||||||
|
|
||||||
|
hours = int(floor(minutes / 60))
|
||||||
|
hours_total = hours
|
||||||
|
minutes -= hours * 60
|
||||||
|
|
||||||
|
days = int(floor(hours / 24))
|
||||||
|
days_total = days
|
||||||
|
hours -= days * 24
|
||||||
|
|
||||||
|
years = int(floor(days / 365))
|
||||||
|
years_total = years
|
||||||
|
days -= years * 365
|
||||||
|
|
||||||
|
return time_format.format(**{
|
||||||
|
'seconds': seconds,
|
||||||
|
'seconds2': str(seconds).zfill(2),
|
||||||
|
'minutes': minutes,
|
||||||
|
'minutes2': str(minutes).zfill(2),
|
||||||
|
'hours': hours,
|
||||||
|
'hours2': str(hours).zfill(2),
|
||||||
|
'days': days,
|
||||||
|
'years': years,
|
||||||
|
'seconds_total': seconds_total,
|
||||||
|
'minutes_total': minutes_total,
|
||||||
|
'hours_total': hours_total,
|
||||||
|
'days_total': days_total,
|
||||||
|
'years_total': years_total,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_base_url(url, include_scheme=True):
|
||||||
|
result = urlparse(url)
|
||||||
|
base_url = f'{result.scheme}://{result.netloc}' if include_scheme else result.netloc.replace('www.', '')
|
||||||
|
return base_url
|
Reference in a new issue