diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 68e190f..41e5ee2 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -8,6 +8,10 @@ on:
schedule:
# Runs at 12am IST
- cron: '30 18 * * *'
+ pull_request:
+ branches:
+ - develop
+ - master
jobs:
build:
@@ -24,6 +28,11 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
+ - name: Set up cache
+ uses: actions/cache@v2
+ with:
+ path: ~/.cache/pypoetry/virtualenvs
+ key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f00568e..fa5f25d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [3.2.0] - 2021-09-13
+
+### Added
+
+- View run statistics at the end of the script
+- Documentation updates
+- Improved error handling and logging
+
## [3.1.0] - 2021-05-04
### Added
@@ -82,6 +90,8 @@ can continue as normal
project running locally. Suitable for users who are not looking forward to
contribute.
+[3.2.0]:
+ https://github.com/aapatre/Automatic-Udemy-Course-Enroller-GET-PAID-UDEMY-COURSES-for-FREE/releases/tag/v3.2.0
[3.1.0]:
https://github.com/aapatre/Automatic-Udemy-Course-Enroller-GET-PAID-UDEMY-COURSES-for-FREE/releases/tag/v3.1.0
[3.0.0]:
diff --git a/README.md b/README.md
index 00b3f8e..e229b60 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,6 @@
[![forthebadge](https://forthebadge.com/images/badges/made-with-python.svg)](https://forthebadge.com)
[![forthebadge](https://forthebadge.com/images/badges/it-works-why.svg)](https://forthebadge.com)
-# ALPHA IS A PRE DEVELOPMENT BRANCH, DO NOT EXPECT USER FACING ISSUES TO BE ADDRESSED IN THIS BRANCH!
# Udemy Coupon Grabber & Course Enroller: Grab FREE Coupons!
@@ -14,15 +13,16 @@ web-scraping and automation, this script will find the necessary Udemy Coupons
**NOTE: THIS PROJECT WILL NOT WORK WITH NON ENGLISH UDEMY.**
The code scrapes course links and coupons from:
- - [tutorialbar.com](https://tutorialbar.com)
- - [discudemy.com](https://discudemy.com)
- - [coursevania.com](https://coursevania.com)
+
+- [tutorialbar.com](https://tutorialbar.com)
+- [discudemy.com](https://discudemy.com)
+- [coursevania.com](https://coursevania.com)
In case of any bugs or issues, please open an issue in github.
Also, don't forget to **Fork & Star the repository if you like it!**
-***We are also on [GitLab](https://gitlab.com/the-automators/Automatic-Udemy-Course-Enroller-GET-PAID-UDEMY-COURSES-for-FREE)***
+**_We are also on [GitLab](https://gitlab.com/the-automators/Automatic-Udemy-Course-Enroller-GET-PAID-UDEMY-COURSES-for-FREE)_**
**_Video Proof:_**
@@ -32,9 +32,8 @@ Also, don't forget to **Fork & Star the repository if you like it!**
## **_Disclaimer & WARNINGS:_**
-
1. **Use** this ONLY for **Educational Purposes!** By using this code you agree
- that **I'm not responsible for any kind of trouble** caused by the code. **THIS PROJECT IS NOT AFFILIATED WITH UDEMY.**
+ that **I'm not responsible for any kind of trouble** caused by the code. **THIS PROJECT IS NOT AFFILIATED WITH UDEMY.**
2. **Make sure web-scraping is legal in your region.**
3. This is **NOT a hacking script**, i.e., it can't enroll you for a specific
course! Instead it finds courses that provide coupon links to make the
@@ -48,6 +47,10 @@ Also, don't forget to **Fork & Star the repository if you like it!**
**Required Python version:** [Python 3.8+](https://www.python.org/downloads/)
+**(Windows users only) Required Microsoft Visual C++ 14.0+ version:** [Microsoft Visual C++ 14.0+](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
+
+![alt text](https://docs.microsoft.com/answers/storage/attachments/34873-10262.png)
+
**You must have pip or poetry installed. Please look up how to install them in your OS.**
Download a release of this project or clone the repository then navigate to the
@@ -60,37 +63,38 @@ get all the requirements installed in one go. Similar instructions applies for p
Props to Davidd Sargent for making a super simple video tutorial. If you prefer written instructions then continue reading further, else click on the image below for a quick video tutorial:
-[![GET Udemy Courses for FREE with Python | 2 Minute Tuesday](https://i.ytimg.com/vi/tdLsVoraMxw/hq720.jpg)](https://www.youtube.com/watch?v=tdLsVoraMxw "GET Udemy Courses for FREE with Python | 2 Minute Tuesday")
+[![GET Udemy Courses for FREE with Python | 2 Minute Tuesday](https://i.ytimg.com/vi/6HLbqM-598k/hq720.jpg)](https://www.youtube.com/watch?v=6HLbqM-598k "pip installation of Automatic Udemy Course Enroller")
1 . Install from PyPI `pip install udemy-enroller`
-- Run the script and the cli will guide you through the settings required
-- If you decide to save the settings they will be stored in your home directory:
-**Windows**:
+- Run the script and the cli will guide you through the settings required
+- If you decide to save the settings they will be stored in your home directory:
+ **Windows**:
C:/Users/CurrentUserName/.udemy_enroller
-**Linux**:
+ **Linux**:
/home/username/.udemy_enroller
-
- **The values in settings.yaml should be in the same language as the site you are browsing on**
+ **The values in settings.yaml should be in the same language as the site you are browsing on**
2 . The script can be passed arguments:
-- `--help`: View full list of arguments available
-- `--discudemy`: Run the discudemy scraper only
-- `--coursevania`: Run the coursevania scraper only
-- `--tutorialbar`: Run the tutorialbar scraper only
-- `--max-pages=`: Max number of pages to scrape from sites before exiting the script (default is 5)
-- `--delete-settings`: Delete existing settings file
-- `--debug`: Enable debug logging
+
+- `--help`: View full list of arguments available
+- `--discudemy`: Run the discudemy scraper only
+- `--coursevania`: Run the coursevania scraper only
+- `--tutorialbar`: Run the tutorialbar scraper only
+- `--max-pages=`: Max number of pages to scrape from sites before exiting the script (default is 5)
+- `--delete-settings`: Delete existing settings file
+- `--debug`: Enable debug logging
3 . Run the script in terminal like so:
-- `udemy_enroller`
+
+- `udemy_enroller`
4 . The bot starts scraping the course links from the first **All Courses** page
on [Tutorial Bar](https://www.tutorialbar.com/all-courses/page/1), [DiscUdemy](https://www.discudemy.com/all) and [Coursevania](https://coursevania.com) and starts
enrolling you to Udemy courses. After it has enrolled you to courses from the
first page, it then moves to the next site page and the cycle continues.
-- Stop the script by pressing ctrl+c in terminal to stop the enrollment process.
+- Stop the script by pressing ctrl+c in terminal to stop the enrollment process.
---
@@ -142,7 +146,6 @@ retrieved in the Python console/shell, which may take a while.
It is recommended to run the script using your terminal and system python.
-
### 7. Which branch to commit against?
Pull request should be made on "develop" branch.
@@ -157,7 +160,7 @@ and help us on what you want or talk to us about your proposed changes.
## Support & Maintenance Notice
-By using this repo/script, you agree that the authors and contributors are under no obligation to provide support for the script and can discontinue it's development, as and when necessary, without prior notice.
+By using this repo/script, you agree that the authors and contributors are under no obligation to provide support for the script and can discontinue it's development, as and when necessary, without prior notice.
---
@@ -167,7 +170,7 @@ By using this repo/script, you agree that the authors and contributors are under
[![JetBrains](https://i.imgur.com/h2R018M.jpg)](https://jetbrains.com/?from=udemy-free-course-enroller)
-Thanks to [JetBrains](https://jetbrains.com/?from=udemy-free-course-enroller) for supporting us. They are the maker of world class IDE and developer tooling. If you think their product might help you, please support them.
+Thanks to [JetBrains](https://jetbrains.com/?from=udemy-free-course-enroller) for supporting us. They are the maker of world class IDE and developer tooling. If you think their product might help you, please support them.
### GitBook
diff --git a/pyproject.toml b/pyproject.toml
index aa3e0cc..317e5aa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "automatic-udemy-course-enroller-get-paid-udemy-courses-for-free"
-version = "3.1.0"
+version = "3.2.0"
description = ""
authors = [""]
@@ -18,7 +18,26 @@ isort = "^5.6.4"
pytest = "^6.1.2"
pytest-cov = "^2.10.1"
pytest-asyncio = "^0.14.0"
+bumpver = "^2021.1113"
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
+
+[tool.bumpver]
+current_version = "3.2.0"
+version_pattern = "MAJOR.MINOR.PATCH"
+commit_message = "Bump version {old_version} -> {new_version}"
+commit = true
+tag = true
+push = false
+
+[tool.bumpver.file_patterns]
+"pyproject.toml" = [
+ 'current_version = "{version}"',
+ 'version = "{version}"',
+]
+"setup.py" = [
+ 'version="{version}"',
+]
+
diff --git a/setup.py b/setup.py
index 070aed2..6a09d21 100644
--- a/setup.py
+++ b/setup.py
@@ -8,7 +8,7 @@
setup(
name="udemy-enroller",
- version="3.1.0",
+ version="3.2.0",
long_description=long_description,
long_description_content_type="text/markdown",
author="aapatre",
diff --git a/udemy_enroller/runner.py b/udemy_enroller/runner.py
index de43b76..10e9270 100644
--- a/udemy_enroller/runner.py
+++ b/udemy_enroller/runner.py
@@ -49,6 +49,7 @@ def _redeem_courses(
logger.info("Ending test")
return
else:
+ udemy_actions.stats.table()
logger.info("All scrapers complete")
return
diff --git a/udemy_enroller/settings.py b/udemy_enroller/settings.py
index 92bcc46..987a9a4 100644
--- a/udemy_enroller/settings.py
+++ b/udemy_enroller/settings.py
@@ -188,7 +188,7 @@ def _save_settings(self) -> None:
logger.info(f"Your email has not been saved to settings.")
if not self._should_store_password:
logger.info("Your password has not been saved to settings.")
- if not self._should_store_email or self._should_store_password:
+ if not self._should_store_email or not self._should_store_password:
logger.info(
"You will be prompted to enter your email/password again when the cookie expires"
)
diff --git a/udemy_enroller/udemy.py b/udemy_enroller/udemy.py
index d318303..a9de807 100644
--- a/udemy_enroller/udemy.py
+++ b/udemy_enroller/udemy.py
@@ -2,6 +2,7 @@
import os
import re
import time
+from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List
@@ -16,6 +17,54 @@
logger = get_logger()
+def format_requests(func):
+ """
+ Convenience method for handling requests response
+ """
+
+ def formatting(*args, **kwargs):
+ result = func(*args, **kwargs)
+ result.raise_for_status()
+ return result.json()
+
+ return formatting
+
+
+@dataclass(unsafe_hash=True)
+class RunStatistics:
+ prices: List[float] = field(default_factory=list)
+
+ expired: int = 0
+ enrolled: int = 0
+ already_enrolled: int = 0
+ unwanted_language: int = 0
+ unwanted_category: int = 0
+
+ course_ids_start: int = 0
+ course_ids_end: int = 0
+
+ start_time = None
+ end_time = None
+
+ currency_symbol = "$"
+
+ def savings(self):
+ return sum(self.prices) or 0
+
+ def table(self):
+ logger.info("==================Run Statistics==================")
+ logger.info(f"Enrolled: {self.enrolled}")
+ logger.info(f"Unwanted Category: {self.unwanted_category}")
+ logger.info(f"Unwanted Language: {self.unwanted_language}")
+ logger.info(f"Already Claimed: {self.already_enrolled}")
+ logger.info(f"Expired: {self.expired}")
+ logger.info(f"Total Enrolments: {self.course_ids_end}")
+ logger.info(
+ f"Savings: {self.currency_symbol}{self.savings():.2f}"
+ )
+ logger.info("==================Run Statistics==================")
+
+
class UdemyStatus(Enum):
"""
Possible statuses of udemy course
@@ -68,6 +117,7 @@ def __init__(self, settings: Settings, cookie_file_name: str = ".cookie"):
self._all_course_ids = []
self._currency_symbol = None
self._currency = None
+ self.stats = RunStatistics()
def login(self, retry=False) -> None:
"""
@@ -80,7 +130,11 @@ def login(self, retry=False) -> None:
if cookie_details is None:
response = self.udemy_scraper.get(self.LOGIN_URL)
soup = BeautifulSoup(response.content, "html.parser")
- csrf_token = soup.find("input", {"name": "csrfmiddlewaretoken"})["value"]
+ csrf_token = soup.find("input", {"name": "csrfmiddlewaretoken"}).get(
+ "value"
+ )
+ if csrf_token is None:
+ raise Exception(f"Unable to get csrf_token")
# Prompt for email/password if we don't have them saved in settings
if self.settings.email is None:
@@ -98,9 +152,11 @@ def login(self, retry=False) -> None:
self.LOGIN_URL, data=_form_data, allow_redirects=False
)
if auth_response.status_code != 302:
- raise Exception(
- f"Could not login. Code: {auth_response.status_code} Text: {auth_response.text}"
+ logger.debug(
+ f"Error while trying to login: {auth_response.status_code}"
)
+ logger.debug(f"Failed login response: {auth_response.text}")
+ raise Exception(f"Could not login. Code: {auth_response.status_code}")
else:
cookie_details = {
"csrf_token": csrf_token,
@@ -132,7 +188,14 @@ def login(self, retry=False) -> None:
self._all_course_ids = [
course["id"] for course in self._enrolled_course_info
]
+ self.stats.course_ids_start = len(self._all_course_ids)
+ self.stats.currency_symbol = self._currency_symbol
except Exception as e:
+ # Log some info on the HTTPError we are getting
+ if isinstance(e, requests.HTTPError):
+ logger.error("HTTP error while trying to fetch Udemy information")
+ logger.error(e)
+ retry = True
if not retry:
logger.info("Retrying login")
self._delete_cookies()
@@ -164,13 +227,14 @@ def load_my_courses(self) -> List:
logger.info(f"Currently enrolled in {len(all_courses)} courses")
return all_courses
+ @format_requests
def load_user_details(self):
"""
Load the current users details
:return: Dict containing the users details
"""
- return self.session.get(self.USER_DETAILS).json()
+ return self.session.get(self.USER_DETAILS)
def is_enrolled(self, course_id: int) -> bool:
"""
@@ -189,13 +253,17 @@ def _add_enrolled_course(self, course_id):
:return:
"""
self._all_course_ids.append(course_id)
+ self.stats.course_ids_end = len(self._all_course_ids)
- def is_coupon_valid(self, course_id: int, coupon_code: str) -> bool:
+ def is_coupon_valid(
+ self, course_id: int, coupon_code: str, course_identifier: str
+ ) -> bool:
"""
Check if the coupon is valid for a course
:param int course_id: Id of the course to check the coupon against
:param str coupon_code: Coupon to apply to the course
+ :param str course_identifier: Name of the course used for logging
:return:
"""
coupon_valid = True
@@ -205,7 +273,7 @@ def is_coupon_valid(self, course_id: int, coupon_code: str) -> bool:
]
if bool(current_price):
logger.debug(
- f"Skipping course as it now costs {self._currency_symbol}{current_price}"
+ f"Skipping course '{course_identifier}' as it now costs {self._currency_symbol}{current_price}"
)
coupon_valid = False
if not bool(
@@ -213,31 +281,44 @@ def is_coupon_valid(self, course_id: int, coupon_code: str) -> bool:
"amount"
]
):
- logger.debug("Skipping course as it is always FREE")
+ logger.debug(f"Skipping course '{course_identifier}' as it is always FREE")
coupon_valid = False
+ if coupon_valid:
+ usual_price = coupon_details["price_text"]["data"]["pricing_result"][
+ "saving_price"
+ ]["amount"]
+ self.stats.prices.append(usual_price)
return coupon_valid
- def is_preferred_language(self, course_details: Dict) -> bool:
+ def is_preferred_language(
+ self, course_details: Dict, course_identifier: str
+ ) -> bool:
"""
Check if the course is in one of the languages preferred by the user
:param dict course_details: Dictionary containing course details from Udemy
+ :param str course_identifier: Name of the course used for logging
:return: boolean
"""
is_preferred_language = True
course_language = course_details["locale"]["simple_english_title"]
if course_language not in self.settings.languages:
- logger.debug(f"Course language not wanted: {course_language}")
+ logger.debug(
+ f"Course '{course_identifier}' language not wanted: {course_language}"
+ )
is_preferred_language = False
return is_preferred_language
- def is_preferred_category(self, course_details: Dict) -> bool:
+ def is_preferred_category(
+ self, course_details: Dict, course_identifier: str
+ ) -> bool:
"""
Check if the course is in one of the categories preferred by the user
:param dict course_details: Dictionary containing course details from Udemy
+ :param str course_identifier: Name of the course used for logging
:return: boolean
"""
is_preferred_category = True
@@ -247,10 +328,13 @@ def is_preferred_category(self, course_details: Dict) -> bool:
and course_details["primary_subcategory"]["title"]
not in self.settings.categories
):
- logger.debug("Skipping course as it does not have a wanted category")
+ logger.debug(
+ f"Skipping course '{course_identifier}' as it does not have a wanted category"
+ )
is_preferred_category = False
return is_preferred_category
+ @format_requests
def my_courses(self, page: int, page_size: int) -> Dict:
"""
Load the current logged in users courses
@@ -259,11 +343,9 @@ def my_courses(self, page: int, page_size: int) -> Dict:
:param int page_size: number of courses to load per page
:return: dict containing the current users courses
"""
- response = self.session.get(
- self.MY_COURSES + f"&page={page}&page_size={page_size}"
- )
- return response.json()
+ return self.session.get(self.MY_COURSES + f"&page={page}&page_size={page_size}")
+ @format_requests
def coupon_details(self, course_id: int, coupon_code: str) -> Dict:
"""
Check that the coupon is valid for the current course
@@ -272,9 +354,9 @@ def coupon_details(self, course_id: int, coupon_code: str) -> Dict:
:param str coupon_code: The coupon_code to check against the course
:return: dictionary containing the course pricing details
"""
- response = requests.get(self.CHECK_PRICE.format(course_id, coupon_code))
- return response.json()
+ return requests.get(self.CHECK_PRICE.format(course_id, coupon_code))
+ @format_requests
def course_details(self, course_id: int) -> Dict:
"""
Retrieves details relating to the course passed in
@@ -282,8 +364,7 @@ def course_details(self, course_id: int) -> Dict:
:param int course_id: Id of the course to get the details of
:return: dictionary containing the course details
"""
- response = requests.get(self.COURSE_DETAILS.format(course_id))
- return response.json()
+ return requests.get(self.COURSE_DETAILS.format(course_id))
def enroll(self, course_link: str) -> str:
"""
@@ -300,18 +381,26 @@ def enroll(self, course_link: str) -> str:
course_identifier = course_details.get("title", url)
if self.is_enrolled(course_id):
- logger.info(f"Already enrolled in: {course_identifier}")
+ logger.info(f"Already enrolled in: '{course_identifier}'")
+ self.stats.already_enrolled += 1
return UdemyStatus.ALREADY_ENROLLED.value
if self.user_has_preferences:
if self.settings.languages:
- if not self.is_preferred_language(course_details):
+ if not self.is_preferred_language(
+ course_details, course_identifier
+ ):
+ self.stats.unwanted_language += 1
return UdemyStatus.UNWANTED_LANGUAGE.value
if self.settings.categories:
- if not self.is_preferred_category(course_details):
+ if not self.is_preferred_category(
+ course_details, course_identifier
+ ):
+ self.stats.unwanted_category += 1
return UdemyStatus.UNWANTED_CATEGORY.value
- if not self.is_coupon_valid(course_id, coupon_code):
+ if not self.is_coupon_valid(course_id, coupon_code, course_identifier):
+ self.stats.expired += 1
return UdemyStatus.EXPIRED.value
return self._checkout(course_id, coupon_code, course_identifier)
@@ -327,6 +416,7 @@ def _get_course_id(self, url: str) -> int:
:return: int representing the course id
"""
response = self.session.get(url)
+ response.raise_for_status()
soup = BeautifulSoup(response.content, "html.parser")
return int(soup.find("body")["data-clp-course-id"])
@@ -364,11 +454,12 @@ def _checkout(
else:
result = checkout_result.json()
if result["status"] == "succeeded":
- logger.info(f"Successfully enrolled: {course_identifier}")
+ logger.info(f"Successfully enrolled: '{course_identifier}'")
self._add_enrolled_course(course_id)
+ self.stats.enrolled += 1
return UdemyStatus.ENROLLED.value
elif result["status"] == "failed":
- logger.warning(f"Checkout failed: {course_identifier}")
+ logger.warning(f"Checkout failed: '{course_identifier}'")
logger.debug(f"Checkout payload: {payload}")
# TODO: Shouldn't happen. Need to monitor if it does
return UdemyStatus.EXPIRED.value