├── test ├── __init__.py ├── test_main.py └── test_utils.py ├── localized_activities ├── __init__.py ├── en.py ├── it.py ├── es.py └── fr.py ├── .trunk ├── configs │ ├── .isort.cfg │ ├── ruff.toml │ ├── .markdownlint.yaml │ └── .yamllint.yaml ├── .gitignore └── trunk.yaml ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.yml ├── CODEOWNERS ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── stale.yml │ ├── dependency-review.yml │ └── build_push_docker_image.yml ├── requirements-dev.txt ├── src ├── constants.py ├── __init__.py ├── remainingSearches.py ├── loggingColoredFormatter.py ├── punchCards.py ├── readToEarn.py ├── searches.py ├── userAgentGenerator.py ├── login.py ├── activities.py ├── browser.py └── utils.py ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── .dockerignore ├── docker-compose.yml ├── Dockerfile ├── .flake8 ├── requirements.txt ├── .idea ├── inspectionProfiles │ └── Project_Default.xml └── runConfigurations │ ├── main_headless.xml │ └── main.xml ├── docker.sh ├── LICENSE ├── generate_task_xml.py ├── .gitignore ├── run.ps1 ├── CHANGELOG.md ├── main.py ├── README.md └── .pylintrc /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /localized_activities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.trunk/configs/.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile=black 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | parameterized~=0.9.0 -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @cal4 2 | src/readToEarn.py @jdeath -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ klept0, cal4 ] 4 | ko_fi: its_klept0 5 | -------------------------------------------------------------------------------- /.trunk/.gitignore: -------------------------------------------------------------------------------- 1 | *out 2 | *logs 3 | *actions 4 | *notifications 5 | *tools 6 | plugins 7 | user_trunk.yaml 8 | user.yaml 9 | -------------------------------------------------------------------------------- /src/constants.py: -------------------------------------------------------------------------------- 1 | REWARDS_URL = "https://rewards.bing.com/" 2 | SEARCH_URL = "https://www.bing.com/search?q=death%20of%20swit&FORM=HDRSC1" 3 | VERSION = 3 4 | -------------------------------------------------------------------------------- /.trunk/configs/ruff.toml: -------------------------------------------------------------------------------- 1 | # Generic, formatter-friendly config. 2 | select = ["B", "D3", "E", "F"] 3 | 4 | # Never enforce `E501` (line length violations). This should be handled by formatters. 5 | ignore = ["E501"] 6 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | from .remainingSearches import RemainingSearches 2 | from .browser import Browser 3 | from .login import Login 4 | from .punchCards import PunchCards 5 | from .readToEarn import ReadToEarn 6 | from .searches import Searches 7 | -------------------------------------------------------------------------------- /.trunk/configs/.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # Autoformatter friendly markdownlint config (all formatting rules disabled) 2 | default: true 3 | blank_lines: false 4 | bullet: false 5 | html: false 6 | indentation: false 7 | line_length: false 8 | spaces: false 9 | url: false 10 | whitespace: false 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.black-formatter", 4 | "ms-python.flake8", 5 | "ms-python.isort", 6 | "ms-python.mypy-type-checker", 7 | "ms-python.vscode-pylance", 8 | "ms-python.pylint", 9 | "ms-python.python" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.trunk/configs/.yamllint.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | quoted-strings: 3 | required: only-when-needed 4 | extra-allowed: ["{|}"] 5 | empty-values: 6 | forbid-in-block-mappings: true 7 | forbid-in-flow-mappings: true 8 | key-duplicates: {} 9 | octal-values: 10 | forbid-implicit-octal: true 11 | -------------------------------------------------------------------------------- /src/remainingSearches.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | 4 | class RemainingSearches(NamedTuple): 5 | """ 6 | Remaining searches for the current account. 7 | """ 8 | 9 | desktop: int 10 | mobile: int 11 | 12 | def getTotal(self) -> int: 13 | return self.desktop + self.mobile 14 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | 4 | # Allow files and directories 5 | !/localized_activities 6 | !/src 7 | !/main.py 8 | !/docker.sh 9 | !/requirements.txt 10 | 11 | # Ignore unnecessary files inside allowed directories 12 | # This should go after the allowed directories 13 | **/*~ 14 | **/*.log 15 | **/.DS_Store 16 | **/Thumbs.db -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ms-rewards: 3 | container_name: ms-rewards-farmer 4 | build: . 5 | image: ms-rewards-farmer:latest 6 | volumes: 7 | - /etc/localtime:/etc/localtime:ro 8 | - ./config.yaml:/app/config.yaml:ro 9 | # - ./sessions:/app/sessions # Optional but avoids re-logging in # fixme Not working 10 | restart: unless-stopped 11 | environment: 12 | - RUN_ONCE=false 13 | - CRON_SCHEDULE=0 4 * * * 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | target-branch: "develop" 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | COPY . /app 4 | 5 | WORKDIR /app 6 | 7 | ENV LANG=en_US.UTF-8 8 | ENV LANGUAGE=en_US:en 9 | ENV LC_ALL=en_US.UTF-8 10 | 11 | RUN apt-get update && \ 12 | DEBIAN_FRONTEND=noninteractive apt-get install -y chromium chromium-driver cron locales && \ 13 | sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ 14 | locale-gen en_US.UTF-8 && \ 15 | chmod +x /app/docker.sh && \ 16 | rm -rf /var/lib/apt/lists/* && \ 17 | rm -rf /etc/cron.*/* && \ 18 | pip install --no-cache-dir -r requirements.txt 19 | 20 | CMD ["/app/docker.sh"] 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": "explicit" 5 | }, 6 | "editor.defaultFormatter": "ms-python.black-formatter", 7 | "files.eol": "\n" 8 | }, 9 | "python.formatting.autopep8Args": ["--aggressive"], 10 | "python.linting.mypyEnabled": false, 11 | "python.formatting.provider": "none", 12 | "editor.formatOnSave": true, 13 | "python.testing.pytestEnabled": true, 14 | "files.exclude": { 15 | "**/*.pyc": { 16 | "when": "$(basename).py" 17 | }, 18 | "**/__pycache__": true 19 | }, 20 | "python.analysis.typeCheckingMode": "basic" 21 | } 22 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # Ignore line length because black takes care of it 2 | # Ignore W503 because black takes care of it 3 | # Ignore E203 because black takes care of it 4 | # Ignore E501 because black takes care of it 5 | 6 | [flake8] 7 | max-line-length = 88 8 | ignore = W503, E203, E501 9 | max-complexity = 14 10 | exclude = 11 | .git 12 | __pycache__ 13 | setup.py 14 | build 15 | dist 16 | releases 17 | .venv 18 | .tox 19 | .mypy_cache 20 | .pytest_cache 21 | .vscode 22 | .github 23 | select = C,E,F,W,B,B901 24 | classmethod-decorators = 25 | classmethod 26 | validator 27 | per-file-ignores = 28 | __init__.py:F401 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | apprise~=1.9.3 2 | blinker==1.7.0 # prevents issues on newer versions 3 | ipapi~=1.0.4 4 | numpy>=1.22.2 # not directly required, pinned by Snyk to avoid a vulnerability 5 | psutil~=7.0.0 6 | PyAutoGUI~=0.9.54 7 | pycountry~=24.6.1 8 | pyotp~=2.9.0 9 | pyyaml~=6.0.2 10 | requests-oauthlib~=2.0.0 11 | requests~=2.32.3 12 | selenium-wire~=5.1.0 13 | selenium>=4.15.2 # not directly required, pinned by Snyk to avoid a vulnerability 14 | setuptools==80.9.0 15 | trendspy~=0.1.6 16 | undetected-chromedriver==3.5.5 17 | urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability 18 | zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Microsoft Rewards Farmer Debug", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "main.py", 12 | "console": "integratedTerminal", 13 | "justMyCode": true 14 | }, 15 | { 16 | "name": "Microsoft Rewards Farmer Debug (visible browser)", 17 | "type": "python", 18 | "request": "launch", 19 | "program": "main.py", 20 | "args": ["-v"], 21 | "console": "integratedTerminal", 22 | "justMyCode": true 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "30 3 * * *" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v5 14 | with: 15 | days-before-issue-stale: 120 16 | days-before-issue-close: 120 17 | stale-issue-label: "stale" 18 | stale-issue-message: "This issue is stale because it has been open for 14 days with no activity." 19 | close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale." 20 | days-before-pr-stale: -1 21 | days-before-pr-close: -1 22 | repo-token: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 21 | -------------------------------------------------------------------------------- /docker.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copy runtime environment 4 | env > /etc/environment 5 | 6 | # Check if RUN_ONCE environment variable is set. In case, running the script now and exiting. 7 | if [ "$RUN_ONCE" = "true" ] 8 | then 9 | echo "RUN_ONCE environment variable is set. Running the script now and exiting." 10 | python main.py 11 | exit 0 12 | fi 13 | 14 | # Check if CRON_SCHEDULE environment variable is set 15 | if [ -z "$CRON_SCHEDULE" ] 16 | then 17 | echo "CRON_SCHEDULE environment variable is not set. Setting it to 4 AM everyday by default" 18 | CRON_SCHEDULE="0 4 * * *" 19 | fi 20 | 21 | # Setting up cron job 22 | echo "$CRON_SCHEDULE root /usr/bin/env python3 /app/main.py >/proc/1/fd/1 2>/proc/1/fd/2" >> /etc/crontab 23 | 24 | # Run the cron 25 | echo "Cron job is set to run at $CRON_SCHEDULE. Waiting for the cron to run..." 26 | exec /usr/sbin/cron -f 27 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v3 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v3 21 | -------------------------------------------------------------------------------- /src/loggingColoredFormatter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | class ColoredFormatter(logging.Formatter): 5 | """ 6 | Custom formatter to add colors to log messages based on their severity level. 7 | """ 8 | 9 | grey = "\x1b[38;21m" 10 | blue = "\x1b[38;5;39m" 11 | yellow = "\x1b[38;5;226m" 12 | red = "\x1b[38;5;196m" 13 | boldRed = "\x1b[31;1m" 14 | reset = "\x1b[0m" 15 | 16 | def __init__(self, fmt): 17 | super().__init__() 18 | self.fmt = fmt 19 | self.FORMATS = { 20 | logging.DEBUG: self.grey + self.fmt + self.reset, 21 | logging.INFO: self.blue + self.fmt + self.reset, 22 | logging.WARNING: self.yellow + self.fmt + self.reset, 23 | logging.ERROR: self.red + self.fmt + self.reset, 24 | logging.CRITICAL: self.boldRed + self.fmt + self.reset, 25 | } 26 | 27 | def format(self, record): 28 | logFmt = self.FORMATS.get(record.levelno) 29 | 30 | formatter = logging.Formatter(logFmt) 31 | return formatter.format(record) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Charles Bel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/build_push_docker_image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout Code 15 | uses: actions/checkout@v4 16 | - name: Docker meta 17 | id: meta 18 | uses: docker/metadata-action@v5 19 | with: 20 | images: ${{ secrets.DOCKERHUB_USERNAME }}/ms-rewards 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v2 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v2 25 | - name: Login to DockerHub 26 | uses: docker/login-action@v2 27 | with: 28 | username: ${{ secrets.DOCKERHUB_USERNAME }} 29 | password: ${{ secrets.DOCKERHUB_TOKEN }} 30 | - name: Build and push 31 | uses: docker/build-push-action@v5 32 | with: 33 | context: . 34 | platforms: linux/amd64,linux/arm64 35 | push: true 36 | tags: ${{ steps.meta.outputs.tags }} 37 | labels: ${{ steps.meta.outputs.labels }} 38 | -------------------------------------------------------------------------------- /test/test_main.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, MagicMock 3 | 4 | import main 5 | from src.utils import Config, CONFIG, APPRISE 6 | 7 | 8 | class TestMain(unittest.TestCase): 9 | 10 | @patch.object(main, "executeBot") 11 | def test_exit_1_when_exception( 12 | self, 13 | mock_executeBot: MagicMock, 14 | ): 15 | CONFIG.accounts = [Config({"password": "foo", "email": "bar"})] 16 | mock_executeBot.side_effect = Exception("Test exception") 17 | 18 | with self.assertRaises(SystemExit): 19 | main.main() 20 | 21 | @patch.object(APPRISE, "notify") 22 | @patch.object(main, "executeBot") 23 | def test_send_notification_when_exception( 24 | self, 25 | mock_executeBot: MagicMock, 26 | mock_notify: MagicMock, 27 | ): 28 | CONFIG.accounts = [Config({"password": "foo", "email": "bar"})] 29 | mock_executeBot.side_effect = Exception("Test exception") 30 | 31 | try: 32 | main.main() 33 | except SystemExit: 34 | pass 35 | 36 | mock_notify.assert_called() 37 | 38 | 39 | if __name__ == "__main__": 40 | unittest.main() 41 | -------------------------------------------------------------------------------- /.idea/runConfigurations/main_headless.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | -------------------------------------------------------------------------------- /.idea/runConfigurations/main.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | -------------------------------------------------------------------------------- /.trunk/trunk.yaml: -------------------------------------------------------------------------------- 1 | # This file controls the behavior of Trunk: https://docs.trunk.io/cli 2 | # To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml 3 | version: 0.1 4 | cli: 5 | version: 1.17.2 6 | # Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) 7 | plugins: 8 | sources: 9 | - id: trunk 10 | ref: v1.3.0 11 | uri: https://github.com/trunk-io/plugins 12 | # Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) 13 | runtimes: 14 | enabled: 15 | - node@18.12.1 16 | - python@3.10.8 17 | # This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) 18 | lint: 19 | enabled: 20 | - actionlint@1.6.26 21 | - bandit@1.7.5 22 | - black@23.9.1 23 | - checkov@3.1.9 24 | - flake8@6.1.0 25 | - git-diff-check 26 | - isort@5.12.0 27 | - markdownlint@0.37.0 28 | - osv-scanner@1.4.3 29 | - prettier@3.1.0 30 | - ruff@0.1.6 31 | - trivy@0.47.0 32 | - trufflehog@3.63.2-rc0 33 | - yamllint@1.33.0 34 | actions: 35 | disabled: 36 | - trunk-announce 37 | - trunk-check-pre-push 38 | - trunk-fmt-pre-commit 39 | enabled: 40 | - trunk-upgrade-available 41 | -------------------------------------------------------------------------------- /test/test_utils.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | # noinspection PyPackageRequirements 4 | from parameterized import parameterized 5 | 6 | from src.utils import CONFIG, APPRISE, isValidCountryCode, isValidLanguageCode 7 | 8 | 9 | class TestUtils(TestCase): 10 | def test_send_notification(self): 11 | CONFIG.apprise.enabled = True 12 | APPRISE.notify("body", "title") 13 | 14 | @parameterized.expand( 15 | [ 16 | ("US", True), 17 | ("US-GA", True), 18 | ("XX", False), 19 | ("US-XX", False), 20 | ] 21 | ) 22 | def test_isValidCountryCode(self, code, expected): 23 | self.assertEqual(isValidCountryCode(code), expected) 24 | 25 | @parameterized.expand( 26 | [ 27 | ("en", True), 28 | ("en-US", True), 29 | ("xx", False), 30 | ("en-XX", False), 31 | ] 32 | ) 33 | def test_isValidLanguageCode(self, code, expected): 34 | self.assertEqual(isValidLanguageCode(code), expected) 35 | 36 | def test_load_localized_activities_with_valid_language(self): 37 | from src.utils import load_localized_activities 38 | 39 | localized_activities = load_localized_activities("en") 40 | self.assertTrue( 41 | localized_activities.title_to_query, 42 | "localized_activities.title_to_query should not be empty", 43 | ) 44 | self.assertTrue( 45 | localized_activities.ignore, 46 | "localized_activities.ignore should not be empty", 47 | ) 48 | 49 | def test_load_localized_activities_with_invalid_language(self): 50 | from src.utils import load_localized_activities 51 | 52 | with self.assertRaises(FileNotFoundError): 53 | load_localized_activities("foo") 54 | -------------------------------------------------------------------------------- /localized_activities/en.py: -------------------------------------------------------------------------------- 1 | title_to_query = { 2 | "Black Friday shopping": "black friday deals", 3 | "Discover open job roles": "jobs at microsoft", 4 | "Expand your vocabulary": "define baroque", 5 | "Feeling symptoms?": "covid symptoms", 6 | "Find deals on Bing": "65 inch tv deals", 7 | "Find places to stay": "hotels rome italy", 8 | "Find somewhere new to explore": "directions to new york", 9 | "Gaming time": "vampire survivors video game", 10 | "Get your shopping done faster": "new iphone", 11 | "Houses near you": "apartments manhattan", 12 | "How's the economy?": "sp 500", 13 | "Learn to cook a new recipe": "how cook pierogi", 14 | "Learn a new recipe": "how cook pierogi", 15 | "Learn song lyrics": "hook blues traveler lyrics", 16 | "Lights, camera, action!": "lord of the rings fellowship of the bing", 17 | "Let's watch that movie again!": "aliens movie", 18 | "Plan a quick getaway": "flights nyc to paris", 19 | "Prepare for the weather": "weather tomorrow", 20 | "Quickly convert your money": "convert 374 usd to yen", 21 | "Search the lyrics of a song": "black sabbath supernaut lyrics", 22 | "Stay on top of the elections": "election news latest", 23 | "Too tired to cook tonight?": "Pizza Hut near me", 24 | "Too tired to cook?": "Pizza Hut near me", 25 | "Translate anything": "translate pencil sharpener to spanish", 26 | "What time is it?": "china time", 27 | "What's for Thanksgiving dinner?": "pumpkin pie recipe", 28 | "What's new?": "latest news", 29 | "Who won?": "braves score", 30 | "You can track your package": "usps tracking", 31 | "Quickly convert money": "usd to euro", 32 | } 33 | ignore = { 34 | "Bing app search", 35 | "Chrome extension search", 36 | "Get 50 entries plus 1000 points!", 37 | "Get 100 points with search bar", 38 | "Safeguard your family's info", 39 | } 40 | -------------------------------------------------------------------------------- /localized_activities/it.py: -------------------------------------------------------------------------------- 1 | title_to_query = { 2 | "Black Friday shopping": "offerte black friday", 3 | "Discover open job roles": "offerte lavoro microsoft", 4 | "Expand your vocabulary": "definisci barocco", 5 | "Feeling symptoms?": "sintomi morbillo", 6 | "Find deals on Bing": "offerte tv 65 pollici", 7 | "Find places to stay": "hotel roma italia", 8 | "Find somewhere new to explore": "indicazioni per Ginevra", 9 | "Gaming time": "gioco vampire survivors", 10 | "Get your shopping done faster": "nuovo samsung galaxy", 11 | "Houses near you": "appartamenti Firenze", 12 | "How's the economy?": "mib 30", 13 | "Learn to cook a new recipe": "ricetta giallo zafferano", 14 | "Learn a new recipe": "ricetta pierogi", 15 | "Learn song lyrics": "testo Marco mengoni due vite", 16 | "Lights, camera, action!": "il signore degli anelli la compagnia dell'anello", 17 | "Let's watch that movie again!": "film il gladiatore", 18 | "Plan a quick getaway": "voli da milano a catania", 19 | "Prepare for the weather": "meteo domani", 20 | "Quickly convert your money": "converti 374 euro in usd", 21 | "Search the lyrics of a song": "testo Laura Pausini la solitudine", 22 | "Stay on top of the elections": "ultime notizie elezioni", 23 | "Too tired to cook tonight?": "Burger King vicino a me", 24 | "Too tired to cook?": "McDonald vicino a me", 25 | "Translate anything": "traduci temperamatite in spagnolo", 26 | "What time is it?": "ora in cina", 27 | "What's for Thanksgiving dinner?": "ricetta torta di mele", 28 | "What's new?": "ultime notizie", 29 | "Who won?": "risultati inter Milan", 30 | "You can track your package": "tracciamento gas", 31 | "Quickly convert money": "euro in yen", 32 | } 33 | ignore = { 34 | "Bing app search", 35 | "Chrome extension search", 36 | "Get 50 entries plus 1000 points!", 37 | "Get 100 points with search bar", 38 | "Safeguard your family's info", 39 | } -------------------------------------------------------------------------------- /localized_activities/es.py: -------------------------------------------------------------------------------- 1 | # todo Finish translating 2 | title_to_query = { 3 | "Black Friday shopping": "ofertas de black friday", 4 | "Discover open job roles": "trabajos en microsoft", 5 | "Expand your vocabulary": "definir barroco", 6 | "Feeling symptoms?": "síntomas de covid", 7 | "Find deals on Bing": "ofertas de televisores de 65 pulgadas", 8 | "Find places to stay": "hoteles en roma italia", 9 | "Find somewhere new to explore": "direcciones a nueva york", 10 | "Gaming time": "videojuego vampire survivors", 11 | "Get your shopping done faster": "nuevo iphone", 12 | "Houses near you": "apartamentos en manhattan", 13 | "How's the economy?": "sp 500", 14 | "Learn to cook a new recipe": "cómo cocinar pierogi", 15 | "Learn a new recipe": "cómo cocinar pierogi", 16 | "Learn song lyrics": "letras de hook blues traveler", 17 | "Lights, camera, action!": "el señor de los anillos la comunidad del anillo", 18 | "Let's watch that movie again!": "película aliens", 19 | "Plan a quick getaway": "vuelos de nyc a parís", 20 | "Prepare for the weather": "el clima de mañana", 21 | "Quickly convert your money": "convertir 374 usd a yen", 22 | "Search the lyrics of a song": "letras de black sabbath supernaut", 23 | "Stay on top of the elections": "últimas noticias de elecciones", 24 | "Too tired to cook tonight?": "Pizza Hut cerca de mí", 25 | "Too tired to cook?": "Pizza Hut cerca de mí", 26 | "Translate anything": "traducir sacapuntas al español", 27 | "What time is it?": "hora en china", 28 | "What's for Thanksgiving dinner?": "receta de pastel de calabaza", 29 | "Who won?": "resultado de los bravos", 30 | "You can track your package": "seguimiento de usps", 31 | "Quickly convert money": "usd a euro", 32 | } 33 | ignore = { 34 | "Bing app search", 35 | "Chrome extension search", 36 | "Get 50 entries plus 1000 points!", 37 | "Get 100 points with search bar", 38 | "Safeguard your family's info", 39 | } 40 | -------------------------------------------------------------------------------- /localized_activities/fr.py: -------------------------------------------------------------------------------- 1 | # todo Finish translating 2 | title_to_query = { 3 | "Black Friday shopping": "offres du black friday", 4 | "Discover open job roles": "emplois chez microsoft", 5 | "Expand your vocabulary": "définir baroque", 6 | "Feeling symptoms?": "symptômes du covid", 7 | "Find deals on Bing": "offres de téléviseurs 65 pouces", 8 | "Find places to stay": "hôtels à rome italie", 9 | "Find somewhere new to explore": "itinéraires vers new york", 10 | "Gaming time": "jeu vidéo vampire survivors", 11 | "Get your shopping done faster": "nouvel iphone", 12 | "Houses near you": "appartements à manhattan", 13 | "How's the economy?": "sp 500", 14 | "Learn to cook a new recipe": "comment cuisiner des pierogi", 15 | "Learn a new recipe": "comment cuisiner des pierogi", 16 | "Learn song lyrics": "paroles de hook blues traveler", 17 | "Lights, camera, action!": "le seigneur des anneaux la communauté de l'anneau", 18 | "Let's watch that movie again!": "film aliens", 19 | "Plan a quick getaway": "vols de nyc à paris", 20 | "Prepare for the weather": "météo de demain", 21 | "Quickly convert your money": "convertir 374 usd en yen", 22 | "Search the lyrics of a song": "paroles de black sabbath supernaut", 23 | "Stay on top of the elections": "dernières nouvelles des élections", 24 | "Too tired to cook tonight?": "Pizza Hut près de chez moi", 25 | "Too tired to cook?": "Pizza Hut près de chez moi", 26 | "Translate anything": "traduire taille-crayon en espagnol", 27 | "What time is it?": "heure en chine", 28 | "What's for Thanksgiving dinner?": "recette de tarte à la citrouille", 29 | "Who won?": "score des braves", 30 | "You can track your package": "suivi usps", 31 | "Quickly convert money": "usd en euro", 32 | } 33 | ignore = { 34 | "Bing app search", 35 | "Chrome extension search", 36 | "Get 50 entries plus 1000 points!", 37 | "Get 100 points with search bar", 38 | "Safeguard your family's info", 39 | } 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Make sure you check if you are purposefully causing an error! (bad installation, etc.) 3 | labels: [ "bug" ] 4 | body: 5 | 6 | - type: checkboxes 7 | id: prerequisites 8 | attributes: 9 | label: Before submitting a bug report... 10 | options: 11 | - label: | 12 | This bug wasn't already reported. 13 | (I have checked every bug report on GitHub) 14 | required: true 15 | - label: | 16 | I've ran the program with the -r argument. 17 | required: true 18 | - type: dropdown 19 | id: branch 20 | attributes: 21 | label: Branch 22 | options: 23 | - master 24 | - develop 25 | default: 0 26 | validations: 27 | required: true 28 | - type: input 29 | id: commit 30 | attributes: 31 | label: Commit 32 | placeholder: 5fd7d6c (get using `git rev-parse --short HEAD`) 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: description 37 | attributes: 38 | label: Describe the bug 39 | description: | 40 | A clear and concise description of what the issue is. 41 | Provide as much information as possible. 42 | validations: 43 | required: true 44 | - type: textarea 45 | id: logs 46 | attributes: 47 | label: Import the logs 48 | description: | 49 | Paste the `logs/activity.log` file, or the `logs/activity-*.log` file containing the error. 50 | placeholder: Don't write the logs here, just drag and drop the file here 51 | validations: 52 | required: true 53 | - type: textarea 54 | id: screenshots 55 | attributes: 56 | label: Screenshots 57 | description: | 58 | Run the program with -v argument (ex: python main.py -v) and take screenshots of what's happening in the browser during the issue 59 | validations: 60 | required: true 61 | - type: textarea 62 | id: dashboard 63 | attributes: 64 | label: Value of dashboard variable 65 | description: | 66 | Open the source code of the [Microsoft Rewards Dashboard Page](https://rewards.bing.com), search for "var dashboard = ", copy the whole json of this line and paste it on [Gist](https://gist.github.com), then copy the link here 67 | validations: 68 | required: true 69 | -------------------------------------------------------------------------------- /generate_task_xml.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import getpass 3 | import os 4 | import subprocess 5 | from pathlib import Path 6 | 7 | # Get the directory of the script being run 8 | script_dir = Path(__file__).parent.resolve() 9 | script_path = script_dir / "run.ps1" 10 | 11 | # Get the current user's name 12 | current_user = getpass.getuser() 13 | 14 | 15 | # Get the current user's SID 16 | def get_user_sid(username): 17 | try: 18 | command = [ 19 | "powershell", 20 | "-Command", 21 | f"(Get-WmiObject -Class Win32_UserAccount -Filter \"Name='{username}'\").SID", 22 | ] 23 | output = subprocess.check_output(command, universal_newlines=True) 24 | sid = output.strip() 25 | return sid 26 | except Exception as e: 27 | print(f"Error getting SID for user {username}: {e}") 28 | return None 29 | 30 | 31 | sid = get_user_sid(current_user) 32 | 33 | if sid is None: 34 | print("Unable to retrieve SID automatically.") 35 | print( 36 | "Please manually check your SID by running the following command in Command Prompt:" 37 | ) 38 | print("whoami /user") 39 | sid = input("Enter your SID manually: ") 40 | 41 | computer_name = os.environ["COMPUTERNAME"] 42 | 43 | xml_content = f""" 44 | 45 | 46 | {dt.datetime.now(dt.UTC).isoformat()} 47 | {computer_name}\\{current_user} 48 | \\Custom\\MsReward 49 | 50 | 51 | 52 | 2024-08-09T06:00:00 53 | true 54 | 55 | 1 56 | 57 | 58 | 59 | 60 | 61 | {sid} 62 | InteractiveToken 63 | LeastPrivilege 64 | 65 | 66 | 67 | IgnoreNew 68 | true 69 | true 70 | true 71 | true 72 | false 73 | 74 | false 75 | false 76 | 77 | true 78 | true 79 | false 80 | false 81 | false 82 | PT0S 83 | 7 84 | 85 | 86 | 87 | %windir%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe 88 | -File "{script_path}" -ExecutionPolicy Bypass 89 | {script_dir} 90 | 91 | 92 | """ 93 | 94 | # Use the script directory as the output path 95 | output_path = script_dir / "MsReward.xml" 96 | 97 | with open(output_path, "w", encoding="utf-16") as file: 98 | file.write(xml_content) 99 | 100 | print(f"XML file has been generated and saved to: {output_path}") 101 | print("To import, see https://superuser.com/a/485565/709704") 102 | print("The trigger time is set to 6:00 AM on the specified day.") 103 | print("You can modify the settings after importing the task into the Task Scheduler.") 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | # Created by https://www.toptal.com/developers/gitignore/api/python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # poetry 103 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 104 | # This is especially recommended for binary packages to ensure reproducibility, and is more 105 | # commonly ignored for libraries. 106 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 107 | #poetry.lock 108 | 109 | # pdm 110 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 111 | #pdm.lock 112 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 113 | # in version control. 114 | # https://pdm.fming.dev/#use-with-ide 115 | .pdm.toml 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 163 | # and can be added to the global gitignore or merged into this file. For a more nuclear 164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 165 | #.idea/ 166 | 167 | ### Python Patch ### 168 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 169 | poetry.toml 170 | 171 | # ruff 172 | .ruff_cache/ 173 | 174 | # LSP config files 175 | pyrightconfig.json 176 | 177 | # End of https://www.toptal.com/developers/gitignore/api/python 178 | 179 | # MacOS 180 | .DS_Store 181 | .AppleDouble 182 | .LSOverride 183 | Icon 184 | ._* 185 | 186 | # Custom rules (everything added below won't be overridden by 'Generate .gitignore File' if you use 'Update' option) 187 | 188 | sessions/ 189 | logs/ 190 | /google_trends.* 191 | config.yaml 192 | MsReward.xml 193 | -------------------------------------------------------------------------------- /src/punchCards.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import time 4 | import urllib.parse 5 | 6 | from selenium.webdriver.common.by import By 7 | 8 | from src.browser import Browser 9 | from .constants import REWARDS_URL 10 | 11 | 12 | class PunchCards: 13 | """ 14 | Class to handle punch cards in MS Rewards. 15 | """ 16 | 17 | def __init__(self, browser: Browser): 18 | self.browser = browser 19 | self.webdriver = browser.webdriver 20 | 21 | def completePunchCard(self, url: str, childPromotions: dict): 22 | # Function to complete a specific punch card 23 | self.webdriver.get(url) 24 | for child in childPromotions: 25 | if child["complete"] is False: 26 | if child["promotionType"] == "urlreward": 27 | self.webdriver.find_element( 28 | By.XPATH, "//a[@class='offer-cta']/div" 29 | ).click() 30 | self.browser.utils.switchToNewTab(True) 31 | if child["promotionType"] == "quiz": 32 | self.webdriver.find_element( 33 | By.XPATH, "//a[@class='offer-cta']/div" 34 | ).click() 35 | self.browser.utils.switchToNewTab() 36 | counter = str( 37 | self.webdriver.find_element( 38 | By.XPATH, '//*[@id="QuestionPane0"]/div[2]' 39 | ).get_attribute("innerHTML") 40 | )[:-1][1:] 41 | numberOfQuestions = max( 42 | int(s) for s in counter.split() if s.isdigit() 43 | ) 44 | for question in range(numberOfQuestions): 45 | # Answer random quiz questions 46 | self.webdriver.find_element( 47 | By.XPATH, 48 | f'//*[@id="QuestionPane{question}"]/div[1]/div[2]' 49 | f'/a[{random.randint(1, 3)}]/div', 50 | ).click() 51 | time.sleep(random.randint(100, 700) / 100) 52 | self.webdriver.find_element( 53 | By.XPATH, 54 | f'//*[@id="AnswerPane{question}"]/div[1]/div[2]' 55 | f'/div[4]/a/div/span/input', 56 | ).click() 57 | time.sleep(random.randint(100, 700) / 100) 58 | time.sleep(random.randint(100, 700) / 100) 59 | 60 | def completePunchCards(self): 61 | # Function to complete all punch cards 62 | logging.info("[PUNCH CARDS] " + "Trying to complete the Punch Cards...") 63 | self.completePromotionalItems() 64 | punchCards = self.browser.utils.getDashboardData()["punchCards"] 65 | self.browser.utils.goToRewards() 66 | for punchCard in punchCards: 67 | try: 68 | if ( 69 | punchCard["parentPromotion"] 70 | and punchCard["childPromotions"] 71 | and not punchCard["parentPromotion"]["complete"] 72 | and punchCard["parentPromotion"]["pointProgressMax"] != 0 73 | ): 74 | # Complete each punch card 75 | self.completePunchCard( 76 | punchCard["parentPromotion"]["attributes"]["destination"], 77 | punchCard["childPromotions"], 78 | ) 79 | except Exception: 80 | logging.error("[PUNCH CARDS] Error Punch Cards", exc_info=True) 81 | self.browser.utils.resetTabs() 82 | continue 83 | logging.info("[PUNCH CARDS] Exiting") 84 | 85 | def completePromotionalItems(self): 86 | # Function to complete promotional items 87 | try: 88 | item = self.browser.utils.getDashboardData()["promotionalItem"] 89 | self.browser.utils.goToRewards() 90 | destUrl = urllib.parse.urlparse(item["destinationUrl"]) 91 | baseUrl = urllib.parse.urlparse(REWARDS_URL) 92 | if ( 93 | (item["pointProgressMax"] in [100, 200, 500]) 94 | and not item["complete"] 95 | and ( 96 | ( 97 | destUrl.hostname == baseUrl.hostname 98 | and destUrl.path == baseUrl.path 99 | ) 100 | or destUrl.hostname == "www.bing.com" 101 | ) 102 | ): 103 | # Click on promotional item and visit new tab 104 | self.webdriver.find_element( 105 | By.XPATH, '//*[@id="promo-item"]/section/div/div/div/span' 106 | ).click() 107 | self.browser.utils.switchToNewTab(True) 108 | except Exception: 109 | logging.debug("", exc_info=True) 110 | -------------------------------------------------------------------------------- /src/readToEarn.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import secrets 4 | import time 5 | from selenium.common.exceptions import ( 6 | ElementNotInteractableException, 7 | NoSuchElementException, 8 | ) 9 | from requests_oauthlib import OAuth2Session 10 | 11 | from src.browser import Browser 12 | from .activities import Activities 13 | from .utils import makeRequestsSession, cooldown 14 | from selenium.webdriver.common.by import By 15 | 16 | # todo Use constant naming style 17 | client_id = "0000000040170455" 18 | authorization_base_url = "https://login.live.com/oauth20_authorize.srf" 19 | token_url = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" 20 | redirect_uri = " https://login.live.com/oauth20_desktop.srf" 21 | scope = ["service::prod.rewardsplatform.microsoft.com::MBI_SSL"] 22 | 23 | 24 | class ReadToEarn: 25 | """ 26 | Class to handle Read to Earn in MS Rewards. 27 | """ 28 | 29 | def __init__(self, browser: Browser): 30 | self.browser = browser 31 | self.webdriver = browser.webdriver 32 | self.activities = Activities(browser) 33 | self.utils = browser.utils 34 | 35 | def completeReadToEarn(self): 36 | 37 | logging.info("[READ TO EARN] " + "Trying to complete Read to Earn...") 38 | 39 | accountName = self.browser.email 40 | mobileApp = makeRequestsSession( 41 | OAuth2Session(client_id, scope=scope, redirect_uri=redirect_uri) 42 | ) 43 | authorization_url = mobileApp.authorization_url( 44 | authorization_base_url, access_type="offline_access", login_hint=accountName 45 | )[0] 46 | 47 | # Get Referer URL from webdriver 48 | self.webdriver.get(authorization_url) 49 | counter = 0 50 | while True: 51 | logging.info("[READ TO EARN] Waiting for Login") 52 | if self.webdriver.current_url.startswith( 53 | "https://login.live.com/oauth20_desktop.srf?code=" 54 | ): 55 | redirect_response = self.webdriver.current_url 56 | break 57 | time.sleep(1) 58 | counter = counter + 1 59 | 60 | logging.info("[READ TO EARN] Printing Buttons") 61 | buttons = self.webdriver.find_elements(By.TAG_NAME, "button") 62 | for index, button in enumerate(buttons): 63 | if button.is_enabled() and button.is_displayed(): 64 | print(f"Button {index + 1}: {button.text}") 65 | if "Skip" in button.text: 66 | logging.info("[Login] Bypass Passkey") 67 | button.click() 68 | 69 | if counter > 5: 70 | logging.info("[READ TO EARN] Login Failed") 71 | return 72 | 73 | logging.info("[READ TO EARN] Logged-in successfully !") 74 | token = mobileApp.fetch_token( 75 | token_url, authorization_response=redirect_response, include_client_id=True 76 | ) 77 | # Do Daily Check in 78 | json_data = { 79 | "amount": 1, 80 | "country": self.browser.localeGeo.lower(), 81 | "id": 1, 82 | "type": 101, 83 | "attributes": { 84 | "offerid": "Gamification_Sapphire_DailyCheckIn", 85 | }, 86 | } 87 | json_data["id"] = secrets.token_hex(64) 88 | logging.info("[READ TO EARN] Daily App Check In") 89 | r = mobileApp.post( 90 | "https://prod.rewardsplatform.microsoft.com/dapi/me/activities", 91 | json=json_data, 92 | ) 93 | balance = r.json().get("response").get("balance") 94 | time.sleep(random.randint(10, 20)) 95 | 96 | # json data to confirm an article is read 97 | json_data = { 98 | "amount": 1, 99 | "country": self.browser.localeGeo.lower(), 100 | "id": 1, 101 | "type": 101, 102 | "attributes": { 103 | "offerid": "ENUS_readarticle3_30points", 104 | }, 105 | } 106 | 107 | # 10 is the most articles you can read. Sleep time is a guess, not tuned 108 | for i in range(10): 109 | # Replace ID with a random value so get credit for a new article 110 | json_data["id"] = secrets.token_hex(64) 111 | r = mobileApp.post( 112 | "https://prod.rewardsplatform.microsoft.com/dapi/me/activities", 113 | json=json_data, 114 | ) 115 | newbalance = r.json().get("response").get("balance") 116 | 117 | if newbalance == balance: 118 | logging.info("[READ TO EARN] Read All Available Articles !") 119 | break 120 | 121 | logging.info("[READ TO EARN] Read Article " + str(i + 1)) 122 | balance = newbalance 123 | cooldown() 124 | 125 | logging.info("[READ TO EARN] Completed the Read to Earn successfully !") 126 | -------------------------------------------------------------------------------- /src/searches.py: -------------------------------------------------------------------------------- 1 | import dbm.dumb 2 | import logging 3 | import shelve 4 | from enum import Enum, auto 5 | from random import random, randint 6 | from time import sleep 7 | from typing import Final 8 | 9 | from selenium.webdriver.common.by import By 10 | from trendspy import Trends 11 | 12 | from src.browser import Browser 13 | from src.utils import CONFIG, getProjectRoot, cooldown, COUNTRY 14 | 15 | 16 | class RetriesStrategy(Enum): 17 | """ 18 | method to use when retrying 19 | """ 20 | 21 | EXPONENTIAL = auto() 22 | """ 23 | an exponentially increasing `backoff-factor` between attempts 24 | """ 25 | CONSTANT = auto() 26 | """ 27 | the default; a constant `backoff-factor` between attempts 28 | """ 29 | 30 | 31 | class Searches: 32 | """ 33 | Class to handle searches in MS Rewards. 34 | """ 35 | 36 | maxRetries: Final[int] = CONFIG.retries.max 37 | """ 38 | the max amount of retries to attempt 39 | """ 40 | baseDelay: Final[float] = CONFIG.get("retries.backoff-factor") 41 | """ 42 | how many seconds to delay 43 | """ 44 | # retriesStrategy = Final[ # todo Figure why doesn't work with equality below 45 | retriesStrategy = RetriesStrategy[CONFIG.retries.strategy] 46 | 47 | def __init__(self, browser: Browser): 48 | self.browser = browser 49 | self.webdriver = browser.webdriver 50 | 51 | dumbDbm = dbm.dumb.open((getProjectRoot() / "google_trends").__str__()) 52 | self.googleTrendsShelf: shelve.Shelf = shelve.Shelf(dumbDbm) 53 | 54 | def __enter__(self): 55 | return self 56 | 57 | def __exit__(self, exc_type, exc_val, exc_tb): 58 | self.googleTrendsShelf.__exit__(None, None, None) 59 | 60 | def bingSearches(self) -> None: 61 | # Function to perform Bing searches 62 | logging.info( 63 | f"[BING] Starting {self.browser.browserType.capitalize()} Edge Bing searches..." 64 | ) 65 | 66 | self.browser.utils.goToSearch() 67 | 68 | while True: 69 | desktopAndMobileRemaining = self.browser.getRemainingSearches( 70 | desktopAndMobile=True 71 | ) 72 | logging.info(f"[BING] Remaining searches={desktopAndMobileRemaining}") 73 | if ( 74 | self.browser.browserType == "desktop" 75 | and desktopAndMobileRemaining.desktop == 0 76 | ) or ( 77 | self.browser.browserType == "mobile" 78 | and desktopAndMobileRemaining.mobile == 0 79 | ): 80 | break 81 | 82 | if desktopAndMobileRemaining.getTotal() > len(self.googleTrendsShelf): 83 | logging.debug( 84 | f"google_trends before load = {list(self.googleTrendsShelf.items())}" 85 | ) 86 | trends = Trends() 87 | trends = trends.trending_now(geo=COUNTRY)[ 88 | : desktopAndMobileRemaining.getTotal() 89 | ] 90 | for trend in trends: 91 | self.googleTrendsShelf[trend.keyword] = trend 92 | logging.debug( 93 | f"google_trends after load = {list(self.googleTrendsShelf.items())}" 94 | ) 95 | 96 | self.bingSearch() 97 | sleep(randint(10, 15)) 98 | 99 | logging.info( 100 | f"[BING] Finished {self.browser.browserType.capitalize()} Edge Bing searches !" 101 | ) 102 | 103 | def bingSearch(self) -> None: 104 | # Function to perform a single Bing search 105 | try: 106 | pointsBefore = self.browser.utils.getAccountPoints() 107 | except: 108 | logging.error("[BING] Error Getting AccountPoints") 109 | cooldown() 110 | return 111 | 112 | trend = list(self.googleTrendsShelf.keys())[0] 113 | trendKeywords = self.googleTrendsShelf[trend].trend_keywords 114 | logging.debug(f"trendKeywords={trendKeywords}") 115 | logging.debug(f"trend={trend}") 116 | baseDelay = Searches.baseDelay 117 | 118 | for i in range(self.maxRetries + 1): 119 | if i != 0: 120 | if not trendKeywords: 121 | del self.googleTrendsShelf[trend] 122 | 123 | trend = list(self.googleTrendsShelf.keys())[0] 124 | trendKeywords = self.googleTrendsShelf[trend].trend_keywords 125 | 126 | sleepTime: float 127 | if Searches.retriesStrategy == Searches.retriesStrategy.EXPONENTIAL: 128 | sleepTime = baseDelay * 2 ** (i - 1) 129 | elif Searches.retriesStrategy == Searches.retriesStrategy.CONSTANT: 130 | sleepTime = baseDelay 131 | else: 132 | raise AssertionError 133 | sleepTime += baseDelay * random() # Add jitter 134 | logging.debug( 135 | f"[BING] Search attempt not counted {i}/{Searches.maxRetries}," 136 | f" sleeping {sleepTime}" 137 | f" seconds..." 138 | ) 139 | sleep(sleepTime) 140 | 141 | self.browser.utils.goToSearch() 142 | searchbar = self.browser.utils.waitUntilClickable( 143 | By.ID, "sb_form_q", timeToWait=40 144 | ) 145 | searchbar.clear() 146 | trendKeyword = trendKeywords.pop() 147 | logging.debug(f"trendKeyword={trendKeyword}") 148 | sleep(10) 149 | searchbar.send_keys(trendKeyword) 150 | sleep(10) 151 | searchbar.submit() 152 | 153 | try: 154 | pointsAfter = self.browser.utils.getAccountPoints() 155 | except: 156 | logging.error("[BING] Error Getting AccountPoints After Search - Assume Search Successfull") 157 | pointsAfter = pointsBefore + 3 158 | 159 | if pointsBefore < pointsAfter: 160 | del self.googleTrendsShelf[trend] 161 | cooldown() 162 | return 163 | 164 | # todo 165 | # if i == (maxRetries / 2): 166 | # logging.info("[BING] " + "TIMED OUT GETTING NEW PROXY") 167 | # self.webdriver.proxy = self.browser.giveMeProxy() 168 | logging.error("[BING] Reached max search attempt retries") 169 | -------------------------------------------------------------------------------- /run.ps1: -------------------------------------------------------------------------------- 1 | # ----------------------------- Script description ----------------------------- 2 | # This script is a wrapper for the "MS Rewards Farmer" python script, which is 3 | # a tool to automate the Microsoft Rewards daily tasks. The script will try to 4 | # update the main script, detect the Python installation, stop orphan chrome 5 | # instances, run the main script and retry if it fails (optionally cleaning 6 | # every error-prone sessions). 7 | 8 | # Use the `Set-ExecutionPolicy RemoteSigned -Scope CurrentUser` command to allow 9 | # script execution in PowerShell without confirmation each time. 10 | 11 | 12 | # --------------------------- Script initialization ---------------------------- 13 | # Set the script directory as the working directory and define the script 14 | # parameters 15 | 16 | param ( 17 | [Alias('h')][switch]$help = $false, 18 | [Alias('u')][switch]$update = $false, 19 | [Alias('d')][switch]$deleteSessionsOnFail = $false, 20 | [Alias('r', 'retries')][int]$maxAttempts = 1, 21 | [Alias('a', 'args')][string]$arguments = "", 22 | [Alias('p', 'python')][string]$pythonPath = "", 23 | [Alias('sc', 'script')][string]$scriptDir = "", 24 | [Alias('se')][string]$sessions = ".\sessions" 25 | ) 26 | 27 | $name = "MS Rewards Farmer" 28 | $startTime = Get-Date 29 | 30 | if ($scriptDir) { 31 | if (-not (Test-Path $scriptDir)) { 32 | Write-Host "> Script directory not found at $scriptDir. Please provide a valid path." -ForegroundColor "Green" 33 | exit 1 34 | } 35 | } else { 36 | $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition 37 | } 38 | 39 | Write-Host "> Entering $scriptDir" -ForegroundColor "Green" 40 | Set-Location $scriptDir 41 | 42 | if ($help) { 43 | Write-Host "Usage: .\run.ps1 [-h] [-u] [-d] [-r ] [-a ] [-p ] [-s ] [-c ]" 44 | Write-Host "" 45 | Write-Host "Options:" 46 | Write-Host " -h, -help" 47 | Write-Host " Display this help message." 48 | Write-Host " -u, -update" 49 | Write-Host " Update the script if a new version is available." 50 | Write-Host " -d, -deleteSessionsOnFail" 51 | Write-Host " Delete the cache folder if the script fails." 52 | Write-Host " -r , -retries , -maxRetries " 53 | Write-Host " Maximum number of retries if the script fails (default: 3)." 54 | Write-Host " -a , -args , -arguments " 55 | Write-Host " Arguments to pass to the main script (default: none)." 56 | Write-Host " -p , -python , -pythonPath " 57 | Write-Host " Path to the Python executable (default: detected)." 58 | Write-Host " -sc , -script , -scriptDir " 59 | Write-Host " Path to the main script directory (default: current directory)." 60 | Write-Host " -se , -sessions " 61 | Write-Host " Folder to store the sessions (default: .\sessions)." 62 | exit 0 63 | } 64 | 65 | 66 | # ------------------------------- Script update -------------------------------- 67 | # Try to update the script if git is available and the script is in a 68 | # git repository 69 | 70 | $updated = $false 71 | 72 | if ($update -and (Test-Path .git) -and (Get-Command git -ErrorAction SilentlyContinue)) { 73 | $gitOutput = & git pull --ff-only 74 | if ($LastExitCode -eq 0) { 75 | if ($gitOutput -match "Already up to date.") { 76 | Write-Host "> $name is already up-to-date" -ForegroundColor "Green" 77 | } else { 78 | $updated = $true 79 | Write-Host "> $name updated successfully" -ForegroundColor "Green" 80 | } 81 | } else { 82 | Write-Host "> Cannot automatically update $name - please update it manually." -ForegroundColor "Green" 83 | } 84 | } 85 | 86 | 87 | # ----------------------- Python installation detection ------------------------ 88 | # Try to detect the Python installation or virtual environments 89 | 90 | # If the python path is provided, check if it is a valid Python executable 91 | if ($pythonPath -and -not (Test-Path $pythonPath)) { 92 | Write-Host "> Python executable not found at $pythonPath. Please provide a valid path." -ForegroundColor "Green" 93 | exit 1 94 | } 95 | 96 | # If no virtual environment Python executable was provided, try to find a 97 | # virtual environment Python executable 98 | if (-not $pythonPath) { 99 | $pythonPath = (Get-ChildItem -Path .\ -Recurse -Filter python.exe | Where-Object { $_.FullName -match "Scripts\\python.exe" }).FullName | Select-Object -First 1 100 | } 101 | 102 | # If no virtual environment Python executable was found, try to find the py 103 | # launcher 104 | if (-not $pythonPath) { 105 | $pythonPath = (Get-Command py -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source) 106 | } 107 | 108 | # If no virtual environment Python executable or py launcher was found, try to 109 | # find the system Python 110 | if (-not $pythonPath) { 111 | $pythonPath = (Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source) 112 | } 113 | 114 | # If no Python executable was found, exit with an error 115 | if (-not $pythonPath) { 116 | Write-Host "> Python executable not found. Please install Python." -ForegroundColor "Green" 117 | exit 1 118 | } 119 | 120 | Write-Host "> Using Python executable at $pythonPath" -ForegroundColor "Green" 121 | 122 | # ------------------------- Python dependencies update ------------------------- 123 | # Try to update the Python dependencies if the script was updated 124 | 125 | if ($updated) { 126 | & $pythonPath -m pip install -r requirements.txt --upgrade 127 | if ($LastExitCode -eq 0) { 128 | Write-Host "> Python dependencies updated successfully" -ForegroundColor "Green" 129 | } else { 130 | Write-Host "> Cannot update Python dependencies - please update them manually." -ForegroundColor "Green" 131 | } 132 | } 133 | 134 | 135 | # --------------------------------- Script run --------------------------------- 136 | # Try to run the script and retry if it fails, while cleaning every error-prone 137 | # elements (sesions, orphan chrome instances, etc.) 138 | 139 | function Invoke-Farmer { 140 | for ($i = 1; $i -le $maxAttempts; $i++) { 141 | Stop-Process -Name "undetected_chromedriver" -ErrorAction SilentlyContinue 142 | Get-Process -Name "chrome" -ErrorAction SilentlyContinue | Where-Object { $_.StartTime -gt $startTime } | Stop-Process -ErrorAction SilentlyContinue 143 | if ($arguments) { 144 | & $pythonPath "main.py" $arguments 145 | } else { 146 | & $pythonPath "main.py" 147 | } 148 | if ($LastExitCode -eq 0) { 149 | Write-Host "> $name completed (Attempt $i/$maxAttempts)." -ForegroundColor "Green" 150 | exit 0 151 | } 152 | Write-Host "> $name failed (Attempt $i/$maxAttempts) with exit code $LastExitCode." -ForegroundColor "Green" 153 | } 154 | } 155 | 156 | Invoke-Farmer 157 | 158 | if ($deleteSessionsOnFail) { 159 | Write-Host "> All $name runs failed ($maxAttempts/$maxAttempts). Removing cache and re-trying..." -ForegroundColor "Green" 160 | 161 | if (Test-Path "$sessions") { 162 | Remove-Item -Recurse -Force "$sessions" -ErrorAction SilentlyContinue 163 | } 164 | 165 | Invoke-Farmer 166 | } 167 | 168 | Write-Host "> All $name runs failed ($maxAttempts/$maxAttempts). Exiting with error." -ForegroundColor "Green" 169 | 170 | exit 1 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.0.0] - 2025-04-08 9 | 10 | ### Added 11 | 12 | - Consolidated all language-specific data into a single dictionary for `localized_activities`, simplifying management and improving performance. 13 | - New activity handling. 14 | - Added country and language code validation functions and updated browser geolocation handling. 15 | - Added configuration options in `config.yaml` for activity handling and error notifications: 16 | - `apprise.notify.incomplete-activity` for incomplete activity notifications. 17 | - `apprise.notify.login-code` for login with phone code notifications. 18 | - Added reset functionality to delete session files and terminate Chrome processes. 19 | - Added CODEOWNERS file for repository management. 20 | - Added Docker build for easier deployment and execution. 21 | - Added `run.ps1` script with the following features: 22 | - Automatic detection of Python installations or virtual environments. 23 | - Retry logic for running the main script with configurable maximum attempts. 24 | - Automatic cleanup of Chrome processes and session files on failure. 25 | - Support for updating the script via Git if in a Git repository. 26 | - Command-line options for customization, including Python path, script directory, and session folder. 27 | 28 | ### Changed 29 | 30 | - Improved retry logic in `getBingInfo` and updated backoff factor configuration. 31 | - Updated `config.yaml` to enhance logging and error reporting configurations. 32 | - Refactored activity handling to use localized titles and queries. 33 | - Improved logging for activity completion, error reporting, and localization warnings. 34 | - Enhanced JSON response handling in `utils.py` and updated parameters in `run.ps1`. 35 | - Replaced `accounts.json` with account information now stored in `config.yaml` for better configuration management. 36 | - Adjusted cooldowns and wait times for better performance. 37 | 38 | ### Fixed 39 | 40 | - Fixed issues with quiz completion logic for "This or That" and "ABC" activities. 41 | - Addressed edge cases where activities were incorrectly marked as incomplete. 42 | - Fixed activities containing non-breakable spaces in their names. 43 | - Fixed Google Trends API integration and improved trend keyword handling. 44 | - Fixed click handling in `activities.py` to use the correct answer element. 45 | - Fixed issue related to mobile search for level 1 users. 46 | - Fixed Apprise notification error. 47 | - Fixed exit code to return `exit 1` on errors instead of `exit 0`. 48 | 49 | ### Removed 50 | 51 | - Removed unused imports and deprecated classes for cleaner codebase. 52 | - Removed `MS_reward.bat` in favor of the more robust `run.ps1` script. 53 | - Removed `config-private.yaml` in favor of consolidating configurations into `config.yaml`. 54 | - Removed password logging. 55 | 56 | ### Other 57 | 58 | - Added locked/banned user detection. 59 | - Skipped un-doable activities. 60 | 61 | ## [1.1.0] - 2024-08-30 62 | 63 | ### Added 64 | 65 | - Promotions/More activities 66 | - Expand your vocabulary 67 | - What time is it? 68 | 69 | ## [1.0.1] - 2024-08-25 70 | 71 | ### Fixed 72 | 73 | - [AssertionError from apprise.notify(title=str(title), body=str(body))](https://github.com/klept0/MS-Rewards-Farmer/issues) 74 | 75 | ## [1.0.0] - 2024-08-23 76 | 77 | ### Removed 78 | 79 | - `apprise.urls` from [config.yaml](config.yaml) 80 | - This now lives in `config-private.yaml`, see [.template-config-private.yaml](.template-config-private.yaml) on how 81 | to configure 82 | - This prevents accidentally leaking sensitive information since `config-private.yaml` is .gitignore'd 83 | 84 | ### Added 85 | 86 | - Support for automatic handling of logins with 2FA and for passwordless setups: 87 | - Passwordless login is supported in both visible and headless mode by displaying the code that the user has to select 88 | on their phone in the terminal window 89 | - 2FA login with TOTPs is supported in both visible and headless mode by allowing the user to provide their TOTP key 90 | in `accounts.json` which automatically generates the one time password 91 | - 2FA login with device-based authentication is supported in theory, BUT doesn't currently work as the undetected 92 | chromedriver for some reason does not receive the confirmation signal after the user approves the login 93 | - Completing quizzes started but not completed in previous runs 94 | - Promotions/More activities 95 | - Find places to stay 96 | - How's the economy? 97 | - Who won? 98 | - Gaming time 99 | 100 | ### Changed 101 | 102 | - Incomplete promotions Apprise notifications 103 | - How incomplete promotions are determined 104 | - Batched into single versus multiple notifications 105 | - Full exception is sent via Apprise versus just error message 106 | 107 | ### Fixed 108 | 109 | - Promotions/More activities 110 | - Too tired to cook tonight? 111 | - [Last searches always timing out](https://github.com/klept0/MS-Rewards-Farmer/issues/172) 112 | - [Quizzes don't complete](https://github.com/klept0/MS-Rewards-Farmer/issues) 113 | 114 | ## [0.2.1] - 2024-08-13 115 | 116 | ### Fixed 117 | 118 | - [Fix ElementNotInteractableException](https://github.com/klept0/MS-Rewards-Farmer/pull/176) 119 | 120 | ## [0.2.0] - 2024-08-09 121 | 122 | ### Added 123 | 124 | - Initial release of the Python script: 125 | - Generates a Task Scheduler XML file 126 | - Allows users to choose between Miniconda, Anaconda, and Local Python 127 | - Prompts users to input the name of their environment (if using Miniconda or Anaconda) 128 | - Uses the script directory as the output path 129 | - Default trigger time is set to 6:00 AM on a specified day, with instructions to modify settings after importing to 130 | Task Scheduler 131 | - Includes a batch file (`MS_reward.bat`) for automatic execution of the Python script 132 | 133 | ### Fixed 134 | 135 | - [Error when trends fail to load](https://github.com/klept0/MS-Rewards-Farmer/issues/163) 136 | 137 | ## [0.1.0] - 2024-07-27 138 | 139 | ### Added 140 | 141 | - New [config.yaml](config.yaml) options 142 | - `retries` 143 | - `base-delay-in-seconds`: how many seconds to delay 144 | - `max`: the max amount of retries to attempt 145 | - `strategy`: method to use when retrying, can be either: 146 | - `CONSTANT`: the default; a constant `base-delay-in-seconds` between attempts 147 | - `EXPONENTIAL`: an exponentially increasing `base-delay-in-seconds` between attempts 148 | - `apprise.summary`: configures how results are summarized via Apprise, can be either: 149 | - `ALWAYS`: the default, as it was before, how many points were gained and goal percentage if set 150 | - `ON_ERROR`: only sends email if for some reason there's remaining searches 151 | - `NEVER`: never send summary 152 | - Apprise notification if activity isn't completed/completable 153 | - Support for more activities 154 | - New arguments (see [readme](README.md#launch-arguments) for details) 155 | - Some useful JetBrains config 156 | - More logging 157 | - Config to make `requests` more reliable 158 | - More checks for bug report 159 | - Me, cal4, as a sponsoree 160 | 161 | ### Changed 162 | 163 | - More reliable searches and closer to human behavior 164 | - When logger is set to debug, doesn't include library code now 165 | - Line endings to LF 166 | 167 | ### Removed 168 | 169 | - Calls to close all Chrome processes 170 | 171 | ### Fixed 172 | 173 | - [Error when executing script from .bat file](https://github.com/klept0/MS-Rewards-Farmer/issues/113) 174 | - [\[BUG\] AttributeError: 'Browser' object has no attribute 'giveMeProxy'](https://github.com/klept0/MS-Rewards-Farmer/issues/115) 175 | - [\[BUG\] driver.quit causing previous issue of hanging process with heavy load on cpu](https://github.com/klept0/MS-Rewards-Farmer/issues/136) 176 | - Login 177 | - Errors when [config.yaml](config.yaml) doesn't exist 178 | - General reliability and maintainability fixes 179 | 180 | ## [0.0.0] - 2023-03-05 181 | 182 | ### Added 183 | 184 | - Farmer and lots of other things, but gotta start a changelog somewhere! 185 | -------------------------------------------------------------------------------- /src/userAgentGenerator.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import Any 3 | 4 | import requests 5 | from requests import HTTPError, Response 6 | 7 | from src.utils import makeRequestsSession 8 | 9 | 10 | class GenerateUserAgent: 11 | """A class for generating user agents for Microsoft Rewards Farmer.""" 12 | 13 | # Reduced device name 14 | # ref: https://developer.chrome.com/blog/user-agent-reduction-android-model-and-version/ 15 | MOBILE_DEVICE = "K" 16 | 17 | USER_AGENT_TEMPLATES = { 18 | "edge_pc": ( 19 | "Mozilla/5.0" 20 | " ({system}) AppleWebKit/537.36 (KHTML, like Gecko)" 21 | " Chrome/{app[chrome_reduced_version]} Safari/537.36" 22 | " Edg/{app[edge_version]}" 23 | ), 24 | "edge_mobile": ( 25 | "Mozilla/5.0" 26 | " ({system}) AppleWebKit/537.36 (KHTML, like Gecko)" 27 | " Chrome/{app[chrome_reduced_version]} Mobile Safari/537.36" 28 | " EdgA/{app[edge_version]}" 29 | ), 30 | } 31 | 32 | OS_PLATFORMS = {"win": "Windows NT 10.0", "android": "Linux"} 33 | OS_CPUS = {"win": "Win64; x64", "android": "Android 13"} 34 | 35 | def userAgent( 36 | self, 37 | browserConfig: dict[str, Any] | None, 38 | mobile: bool = False, 39 | ) -> tuple[str, dict[str, Any], Any]: 40 | """ 41 | Generates a user agent string for either a mobile or PC device. 42 | 43 | Args: 44 | mobile: A boolean indicating whether the user agent should be 45 | generated for a mobile device. 46 | 47 | Returns: 48 | A string containing the user agent for the specified device. 49 | """ 50 | 51 | system = self.getSystemComponents(mobile) 52 | app = self.getAppComponents(mobile) 53 | uaTemplate = ( 54 | self.USER_AGENT_TEMPLATES.get("edge_mobile", "") 55 | if mobile 56 | else self.USER_AGENT_TEMPLATES.get("edge_pc", "") 57 | ) 58 | 59 | # todo - Refactor, kinda spaghetti code-y 60 | newBrowserConfig = None 61 | if browserConfig is not None: 62 | platformVersion = browserConfig.get("userAgentMetadata")["platformVersion"] 63 | else: 64 | # ref : https://textslashplain.com/2021/09/21/determining-os-platform-version/ 65 | platformVersion = ( 66 | f"{random.randint(9,13) if mobile else random.randint(1,15)}.0.0" 67 | ) 68 | newBrowserConfig = {} 69 | newBrowserConfig["userAgentMetadata"] = { 70 | "platformVersion": platformVersion, 71 | } 72 | uaMetadata = { 73 | "mobile": mobile, 74 | "platform": "Android" if mobile else "Windows", 75 | "fullVersionList": [ 76 | {"brand": "Not/A)Brand", "version": "99.0.0.0"}, 77 | {"brand": "Microsoft Edge", "version": app["edge_version"]}, 78 | {"brand": "Chromium", "version": app["chrome_version"]}, 79 | ], 80 | "brands": [ 81 | {"brand": "Not/A)Brand", "version": "99"}, 82 | {"brand": "Microsoft Edge", "version": app["edge_major_version"]}, 83 | {"brand": "Chromium", "version": app["chrome_major_version"]}, 84 | ], 85 | "platformVersion": platformVersion, 86 | "architecture": "" if mobile else "x86", 87 | "bitness": "" if mobile else "64", 88 | "model": "", 89 | } 90 | 91 | return uaTemplate.format(system=system, app=app), uaMetadata, newBrowserConfig 92 | 93 | def getSystemComponents(self, mobile: bool) -> str: 94 | """ 95 | Generates the system components for the user agent string. 96 | 97 | Args: 98 | mobile: A boolean indicating whether the user agent should be 99 | generated for a mobile device. 100 | 101 | Returns: 102 | A string containing the system components for the user agent string. 103 | """ 104 | osId = self.OS_CPUS.get("android") if mobile else self.OS_CPUS.get("win") 105 | uaPlatform = ( 106 | self.OS_PLATFORMS.get("android") if mobile else self.OS_PLATFORMS.get("win") 107 | ) 108 | if mobile: 109 | osId = f"{osId}; {self.MOBILE_DEVICE}" 110 | return f"{uaPlatform}; {osId}" 111 | 112 | def getAppComponents(self, mobile: bool) -> dict[str, str]: 113 | """ 114 | Generates the application components for the user agent string. 115 | 116 | Returns: 117 | A dictionary containing the application components for the user agent string. 118 | """ 119 | edgeWindowsVersion, edgeAndroidVersion = self.getEdgeVersions() 120 | edgeVersion = edgeAndroidVersion if mobile else edgeWindowsVersion 121 | edgeMajorVersion = edgeVersion.split(".")[0] 122 | 123 | chromeVersion = self.getChromeVersion() 124 | chromeMajorVersion = chromeVersion.split(".")[0] 125 | chromeReducedVersion = f"{chromeMajorVersion}.0.0.0" 126 | 127 | return { 128 | "edge_version": edgeVersion, 129 | "edge_major_version": edgeMajorVersion, 130 | "chrome_version": chromeVersion, 131 | "chrome_major_version": chromeMajorVersion, 132 | "chrome_reduced_version": chromeReducedVersion, 133 | } 134 | 135 | def getEdgeVersions(self) -> tuple[str, str]: 136 | """ 137 | Get the latest version of Microsoft Edge. 138 | 139 | Returns: 140 | str: The latest version of Microsoft Edge. 141 | """ 142 | response = self.getWebdriverPage( 143 | "https://edgeupdates.microsoft.com/api/products" 144 | ) 145 | 146 | def getValueIgnoreCase(data: dict, key: str) -> Any: 147 | """Get the value from a dictionary ignoring the case of the first letter of the key.""" 148 | for k, v in data.items(): 149 | if k.lower() == key.lower(): 150 | return v 151 | return None 152 | 153 | data = response.json() 154 | if stableProduct := next( 155 | ( 156 | product 157 | for product in data 158 | if getValueIgnoreCase(product, "product") == "Stable" 159 | ), 160 | None, 161 | ): 162 | releases = getValueIgnoreCase(stableProduct, "releases") 163 | androidRelease = next( 164 | ( 165 | release 166 | for release in releases 167 | if getValueIgnoreCase(release, "platform") == "Android" 168 | ), 169 | None, 170 | ) 171 | windowsRelease = next( 172 | ( 173 | release 174 | for release in releases 175 | if getValueIgnoreCase(release, "platform") == "Windows" 176 | and getValueIgnoreCase(release, "architecture") == "x64" 177 | ), 178 | None, 179 | ) 180 | if androidRelease and windowsRelease: 181 | return ( 182 | getValueIgnoreCase(windowsRelease, "productVersion"), 183 | getValueIgnoreCase(androidRelease, "productVersion"), 184 | ) 185 | raise HTTPError("Failed to get Edge versions.") 186 | 187 | def getChromeVersion(self) -> str: 188 | """ 189 | Get the latest version of Google Chrome. 190 | 191 | Returns: 192 | str: The latest version of Google Chrome. 193 | """ 194 | response = self.getWebdriverPage( 195 | "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json" 196 | ) 197 | data = response.json() 198 | return data["channels"]["Stable"]["version"] 199 | 200 | @staticmethod 201 | def getWebdriverPage(url: str) -> Response: 202 | response = makeRequestsSession().get(url) 203 | if response.status_code != requests.codes.ok: # pylint: disable=no-member 204 | raise HTTPError( 205 | f"Failed to get webdriver page {url}. " 206 | f"Status code: {response.status_code}" 207 | ) 208 | return response 209 | -------------------------------------------------------------------------------- /src/login.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import logging 3 | 4 | from pyotp import TOTP 5 | from selenium.common import TimeoutException 6 | from selenium.common.exceptions import ( 7 | ElementNotInteractableException, 8 | NoSuchElementException, 9 | ) 10 | from selenium.webdriver.common.by import By 11 | from undetected_chromedriver import Chrome 12 | 13 | from src.browser import Browser 14 | from src.utils import CONFIG, APPRISE 15 | 16 | 17 | class LoginError(Exception): 18 | """ 19 | Custom exception for login errors. 20 | """ 21 | 22 | 23 | class Login: 24 | """ 25 | Class to handle login to MS Rewards. 26 | """ 27 | browser: Browser 28 | webdriver: Chrome 29 | 30 | def __init__(self, browser: Browser): 31 | self.browser = browser 32 | self.webdriver = browser.webdriver 33 | self.utils = browser.utils 34 | 35 | def check_locked_user(self): 36 | try: 37 | element = self.webdriver.find_element( 38 | By.XPATH, "//div[@id='serviceAbuseLandingTitle']" 39 | ) 40 | self.locked(element) 41 | except NoSuchElementException: 42 | pass 43 | 44 | def check_banned_user(self): 45 | try: 46 | element = self.webdriver.find_element(By.XPATH, '//*[@id="fraudErrorBody"]') 47 | self.banned(element) 48 | except NoSuchElementException: 49 | pass 50 | 51 | def check_loggin_pref(self): 52 | try: 53 | buttons = self.webdriver.find_elements(By.TAG_NAME, "button") 54 | for index, button in enumerate(buttons): 55 | if button.is_enabled() and button.is_displayed(): 56 | print(f"Button {index + 1}: {button.text}") 57 | if "Yes" in button.text: 58 | logging.info("[Login] Setting Auto Login Preference") 59 | button.click() 60 | except: 61 | pass 62 | 63 | def check_passkey_skip(self): 64 | try: 65 | buttons = self.webdriver.find_elements(By.TAG_NAME, "button") 66 | for index, button in enumerate(buttons): 67 | if button.is_enabled() and button.is_displayed(): 68 | print(f"Button {index + 1}: {button.text}") 69 | if "Skip" in button.text: 70 | logging.info("[Login] Setting Auto Login Preference") 71 | button.click() 72 | except: 73 | pass 74 | 75 | def locked(self, element): 76 | try: 77 | if element.is_displayed(): 78 | logging.critical("This Account is Locked!") 79 | self.webdriver.close() 80 | raise LoginError("Account locked, moving to the next account.") 81 | except (ElementNotInteractableException, NoSuchElementException): 82 | pass 83 | 84 | def banned(self, element): 85 | try: 86 | if element.is_displayed(): 87 | logging.critical("This Account is Banned!") 88 | self.webdriver.close() 89 | raise LoginError("Account banned, moving to the next account.") 90 | except (ElementNotInteractableException, NoSuchElementException): 91 | pass 92 | 93 | def login(self) -> None: 94 | try: 95 | if self.utils.isLoggedIn(): 96 | logging.info("[LOGIN] Already logged-in") 97 | self.check_locked_user() 98 | self.check_banned_user() 99 | else: 100 | logging.info("[LOGIN] Logging-in...") 101 | self.execute_login() 102 | logging.info("[LOGIN] Logged-in successfully!") 103 | self.check_locked_user() 104 | self.check_banned_user() 105 | assert self.utils.isLoggedIn() 106 | except Exception as e: 107 | logging.error(f"Error during login: {e}") 108 | self.webdriver.close() 109 | raise 110 | 111 | def execute_login(self) -> None: 112 | # Email field 113 | self.check_passkey_skip() 114 | 115 | #emailField = self.utils.waitUntilVisible(By.ID, "i0116") 116 | emailField = self.utils.waitUntilVisible(By.XPATH, "//input[@id='usernameEntry']") 117 | logging.info("[LOGIN] Entering email...") 118 | emailField.click() 119 | emailField.send_keys(self.browser.email) 120 | assert emailField.get_attribute("value") == self.browser.email 121 | #self.utils.waitUntilClickable(By.ID, "idSIButton9").click() 122 | self.utils.waitUntilClickable(By.XPATH, "//button[@type='submit']").click() 123 | 124 | # Passwordless check 125 | isPasswordless = False 126 | with contextlib.suppress(TimeoutException): 127 | self.utils.waitUntilVisible(By.ID, "displaySign") 128 | isPasswordless = True 129 | logging.debug("isPasswordless = %s", isPasswordless) 130 | 131 | if isPasswordless: 132 | # Passworless login, have user confirm code on phone 133 | codeField = self.utils.waitUntilVisible(By.ID, "displaySign") 134 | logging.warning( 135 | "[LOGIN] Confirm your login with code %s on your phone (you have one minute)!\a", 136 | codeField.text, 137 | ) 138 | if CONFIG.get("apprise.notify.login-code"): 139 | APPRISE.notify( 140 | f"Code: {codeField.text} (expires in 1 minute)", 141 | "Confirm your login on your phone", 142 | ) 143 | self.utils.waitUntilVisible(By.NAME, "kmsiForm", 60) 144 | logging.info("[LOGIN] Successfully verified!") 145 | else: 146 | # Password-based login, enter password from accounts.json 147 | passwordField = self.utils.waitUntilClickable(By.NAME, "passwd") 148 | logging.info("[LOGIN] Entering password...") 149 | passwordField.click() 150 | passwordField.send_keys(self.browser.password) 151 | assert passwordField.get_attribute("value") == self.browser.password 152 | self.utils.waitUntilClickable(By.XPATH, "//button[@type='submit']").click() 153 | 154 | # Check if 2FA is enabled, both device auth and TOTP are supported 155 | isDeviceAuthEnabled = False 156 | with contextlib.suppress(TimeoutException): 157 | self.utils.waitUntilVisible(By.ID, "idSpan_SAOTCAS_DescSessionID") 158 | isDeviceAuthEnabled = True 159 | logging.debug("isDeviceAuthEnabled = %s", isDeviceAuthEnabled) 160 | 161 | isTOTPEnabled = False 162 | with contextlib.suppress(TimeoutException): 163 | self.utils.waitUntilVisible(By.ID, "idTxtBx_SAOTCC_OTC", 1) 164 | isTOTPEnabled = True 165 | logging.debug("isTOTPEnabled = %s", isTOTPEnabled) 166 | 167 | if isDeviceAuthEnabled: 168 | # Device-based authentication not supported 169 | raise LoginError( 170 | "Device authentication not supported. Please use TOTP or disable 2FA." 171 | ) 172 | 173 | if isTOTPEnabled: 174 | # One-time password required 175 | if self.browser.totp is not None: 176 | # TOTP token provided 177 | logging.info("[LOGIN] Entering OTP...") 178 | otp = TOTP(self.browser.totp.replace(" ", "")).now() 179 | otpField = self.utils.waitUntilClickable( 180 | By.ID, "idTxtBx_SAOTCC_OTC" 181 | ) 182 | otpField.send_keys(otp) 183 | assert otpField.get_attribute("value") == otp 184 | self.utils.waitUntilClickable( 185 | By.ID, "idSubmit_SAOTCC_Continue" 186 | ).click() 187 | else: 188 | # TOTP token not provided, manual intervention required 189 | assert CONFIG.browser.visible, ( 190 | "[LOGIN] 2FA detected, provide token in accounts.json or or run in" 191 | "[LOGIN] 2FA detected, provide token in accounts.json or handle manually." 192 | " visible mode to handle login." 193 | ) 194 | print( 195 | "[LOGIN] 2FA detected, handle prompts and press enter when on" 196 | " keep me signed in page." 197 | ) 198 | input() 199 | 200 | self.check_locked_user() 201 | self.check_banned_user() 202 | 203 | # Check for Stay Signed In 204 | self.check_loggin_pref() 205 | 206 | #self.utils.waitUntilVisible(By.NAME, "kmsiForm") 207 | #self.utils.waitUntilClickable(By.ID, "acceptButton").click() 208 | 209 | # TODO: This should probably instead be checked with an element's id, 210 | # as the hardcoded text might be different in other languages 211 | isAskingToProtect = self.utils.checkIfTextPresentAfterDelay( 212 | "protect your account", 5 213 | ) 214 | logging.debug("isAskingToProtect = %s", isAskingToProtect) 215 | 216 | if isAskingToProtect: 217 | assert ( 218 | CONFIG.browser.visible 219 | ), "Account protection detected, run in visible mode to handle login" 220 | print( 221 | "Account protection detected, handle prompts and press enter when on rewards page" 222 | ) 223 | input() 224 | 225 | self.utils.waitUntilVisible( 226 | By.CSS_SELECTOR, 'html[data-role-name="RewardsPortal"]' 227 | ) 228 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import json 3 | import logging 4 | import logging.config 5 | import sys 6 | from datetime import datetime 7 | from enum import Enum, auto 8 | from logging import handlers 9 | 10 | from src import ( 11 | Browser, 12 | Login, 13 | PunchCards, 14 | Searches, 15 | ReadToEarn, 16 | ) 17 | from src.activities import Activities 18 | from src.browser import RemainingSearches 19 | from src.loggingColoredFormatter import ColoredFormatter 20 | from src.utils import CONFIG, APPRISE, getProjectRoot, formatNumber 21 | 22 | 23 | def main(): 24 | setupLogging() 25 | 26 | # Load previous day's points data 27 | previous_points_data = load_previous_points_data() 28 | 29 | foundError = False 30 | 31 | for currentAccount in CONFIG.accounts: 32 | try: 33 | earned_points = executeBot(currentAccount) 34 | except Exception as e1: 35 | logging.error("", exc_info=True) 36 | foundError = True 37 | if CONFIG.get("apprise.notify.uncaught-exception"): 38 | APPRISE.notify( 39 | f"{type(e1).__name__}: {e1}", 40 | f"⚠️ Error executing {currentAccount.email}, please check the log", 41 | ) 42 | continue 43 | 44 | previous_points = previous_points_data.get(currentAccount.email, 0) 45 | 46 | # Calculate the difference in points from the prior day 47 | points_difference = earned_points - previous_points 48 | 49 | # Append the daily points and points difference to CSV and Excel 50 | log_daily_points_to_csv(earned_points, points_difference) 51 | 52 | # Update the previous day's points data 53 | previous_points_data[currentAccount.email] = earned_points 54 | 55 | logging.info( 56 | f"[POINTS] Data for '{currentAccount.email}' appended to the file." 57 | ) 58 | 59 | # Save the current day's points data for the next day in the "logs" folder 60 | save_previous_points_data(previous_points_data) 61 | logging.info("[POINTS] Data saved for the next day.") 62 | 63 | if foundError: 64 | sys.exit(1) 65 | 66 | 67 | def log_daily_points_to_csv(earned_points, points_difference): 68 | logs_directory = getProjectRoot() / "logs" 69 | csv_filename = logs_directory / "points_data.csv" 70 | 71 | # Create a new row with the date, daily points, and points difference 72 | date = datetime.now().strftime("%Y-%m-%d") 73 | new_row = { 74 | "Date": date, 75 | "Earned Points": earned_points, 76 | "Points Difference": points_difference, 77 | } 78 | 79 | fieldnames = ["Date", "Earned Points", "Points Difference"] 80 | is_new_file = not csv_filename.exists() 81 | 82 | with open(csv_filename, mode="a", newline="", encoding="utf-8") as file: 83 | writer = csv.DictWriter(file, fieldnames=fieldnames) 84 | 85 | if is_new_file: 86 | writer.writeheader() 87 | 88 | writer.writerow(new_row) 89 | 90 | 91 | def setupLogging(): 92 | _format = CONFIG.logging.format 93 | terminalHandler = logging.StreamHandler(sys.stdout) 94 | terminalHandler.setFormatter(ColoredFormatter(_format)) 95 | terminalHandler.setLevel(logging.getLevelName(CONFIG.logging.level.upper())) 96 | 97 | logs_directory = getProjectRoot() / "logs" 98 | logs_directory.mkdir(parents=True, exist_ok=True) 99 | 100 | fileHandler = handlers.TimedRotatingFileHandler( 101 | logs_directory / "activity.log", 102 | when="midnight", 103 | backupCount=2, 104 | encoding="utf-8", 105 | ) 106 | fileHandler.namer = lambda name: name.replace('.log.', '-') + '.log' 107 | fileHandler.setLevel(logging.DEBUG) 108 | 109 | logging.config.dictConfig({ 110 | "version": 1, 111 | "disable_existing_loggers": True, 112 | }) 113 | 114 | logging.basicConfig( 115 | level=logging.DEBUG, 116 | format=_format, 117 | handlers=[fileHandler, terminalHandler], 118 | force=True, 119 | ) 120 | 121 | 122 | class AppriseSummary(Enum): 123 | """ 124 | configures how results are summarized via Apprise 125 | """ 126 | 127 | ALWAYS = auto() 128 | """ 129 | the default, as it was before, how many points were gained and goal percentage if set 130 | """ 131 | ON_ERROR = auto() 132 | """ 133 | only sends email if for some reason there's remaining searches 134 | """ 135 | NEVER = auto() 136 | """ 137 | never send summary 138 | """ 139 | 140 | 141 | def executeBot(currentAccount): 142 | logging.info(f"********************{currentAccount.email}********************") 143 | 144 | startingPoints: int | None = None 145 | accountPoints: int 146 | remainingSearches: RemainingSearches 147 | goalTitle: str 148 | goalPoints: int 149 | 150 | if CONFIG.search.type in ("desktop", "both", None): 151 | with Browser(mobile=False, account=currentAccount) as desktopBrowser: 152 | utils = desktopBrowser.utils 153 | Login(desktopBrowser).login() 154 | startingPoints = utils.getAccountPoints() 155 | logging.info( 156 | f"[POINTS] You have {formatNumber(startingPoints)} points on your account - Searching First" 157 | ) 158 | 159 | with Searches(desktopBrowser) as searches: 160 | searches.bingSearches() 161 | 162 | # Activities After Search For Debugging 163 | Activities(desktopBrowser).completeActivities() 164 | PunchCards(desktopBrowser).completePunchCards() 165 | # VersusGame(desktopBrowser).completeVersusGame() 166 | 167 | goalPoints = 100 # utils.getGoalPoints() 168 | goalTitle = "Title" # utils.getGoalTitle() 169 | 170 | remainingSearches = desktopBrowser.getRemainingSearches( 171 | desktopAndMobile=True 172 | ) 173 | accountPoints = utils.getAccountPoints() 174 | 175 | if CONFIG.search.type in ("mobile", "both", None): 176 | with Browser(mobile=True, account=currentAccount) as mobileBrowser: 177 | utils = mobileBrowser.utils 178 | Login(mobileBrowser).login() 179 | if startingPoints is None: 180 | startingPoints = utils.getAccountPoints() 181 | try: 182 | ReadToEarn(mobileBrowser).completeReadToEarn() 183 | except Exception: 184 | logging.exception("[READ TO EARN] Failed to complete Read to Earn") 185 | with Searches(mobileBrowser) as searches: 186 | searches.bingSearches() 187 | 188 | goalPoints = utils.getGoalPoints() 189 | goalTitle = utils.getGoalTitle() 190 | 191 | remainingSearches = mobileBrowser.getRemainingSearches( 192 | desktopAndMobile=True 193 | ) 194 | accountPoints = utils.getAccountPoints() 195 | 196 | logging.info( 197 | f"[POINTS] You have earned {formatNumber(accountPoints - startingPoints)} points this run !" 198 | ) 199 | logging.info(f"[POINTS] You are now at {formatNumber(accountPoints)} points !") 200 | appriseSummary = AppriseSummary[CONFIG.apprise.summary] 201 | if appriseSummary == AppriseSummary.ALWAYS: 202 | goalStatus = "" 203 | if goalPoints > 0: 204 | logging.info( 205 | f"[POINTS] You are now at {(formatNumber((accountPoints / goalPoints) * 100))}%" 206 | f" of your goal ({goalTitle}) !" 207 | ) 208 | goalStatus = ( 209 | f"🎯 Goal reached: {(formatNumber((accountPoints / goalPoints) * 100))}%" 210 | f" ({goalTitle})" 211 | ) 212 | 213 | APPRISE.notify( 214 | "\n".join( 215 | [ 216 | f"👤 Account: {currentAccount.email}", 217 | f"⭐️ Points earned today: {formatNumber(accountPoints - startingPoints)}", 218 | f"💰 Total points: {formatNumber(accountPoints)}", 219 | goalStatus, 220 | ] 221 | ), 222 | "Daily Points Update", 223 | ) 224 | elif appriseSummary == AppriseSummary.ON_ERROR: 225 | if remainingSearches.getTotal() > 0: 226 | APPRISE.notify( 227 | f"account email: {currentAccount.email}, {remainingSearches}", 228 | "Error: remaining searches", 229 | ) 230 | elif appriseSummary == AppriseSummary.NEVER: 231 | pass 232 | 233 | return accountPoints 234 | 235 | 236 | def export_points_to_csv(points_data): 237 | logs_directory = getProjectRoot() / "logs" 238 | csv_filename = logs_directory / "points_data.csv" 239 | with open(csv_filename, mode="a", newline="", encoding="utf-8") as file: 240 | fieldnames = ["Account", "Earned Points", "Points Difference"] 241 | writer = csv.DictWriter(file, fieldnames=fieldnames) 242 | 243 | # Check if the file is empty, and if so, write the header row 244 | if file.tell() == 0: 245 | writer.writeheader() 246 | 247 | for data in points_data: 248 | writer.writerow(data) 249 | 250 | 251 | # Define a function to load the previous day's points data from a file in the "logs" folder 252 | def load_previous_points_data(): 253 | try: 254 | with open( 255 | getProjectRoot() / "logs" / "previous_points_data.json", encoding='utf-8') as file: 256 | return json.load(file) 257 | except FileNotFoundError: 258 | return {} 259 | 260 | 261 | # Define a function to save the current day's points data for the next day in the "logs" folder 262 | def save_previous_points_data(data): 263 | logs_directory = getProjectRoot() / "logs" 264 | with open(logs_directory / "previous_points_data.json", "w", encoding="utf-8") as file: 265 | json.dump(data, file, indent=4) 266 | 267 | 268 | if __name__ == "__main__": 269 | try: 270 | main() 271 | except Exception as e: 272 | logging.exception("") 273 | if CONFIG.get("apprise.notify.uncaught-exception"): 274 | APPRISE.notify( 275 | f"{type(e).__name__}: {e}", 276 | "⚠️ Error occurred, please check the log", 277 | ) 278 | sys.exit(1) 279 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### A "simple" python application that uses Selenium to help with your M$ Rewards 2 | 3 | ![Static Badge](https://img.shields.io/badge/Made_in-python-violet?style=for-the-badge) 4 | ![MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge) 5 | ![Maintained](https://img.shields.io/badge/Maintained%3F-yes-green.svg?style=for-the-badge) 6 | ![GitHub contributors](https://img.shields.io/github/contributors/klept0/MS-Rewards-Farmer?style=for-the-badge) 7 | ![GitHub issues](https://img.shields.io/github/issues/klept0/MS-Rewards-Farmer?style=for-the-badge) 8 | 9 | 10 | 11 | > [!IMPORTANT] 12 | > If you are multi-accounting and abusing the service for which this is intended - * 13 | *_DO NOT COMPLAIN ABOUT BANS!!!_** 14 | 15 | 16 | 17 | > [!CAUTION] 18 | > Use it at your own risk, M$ may ban your account (and I would not be responsible for it) 19 | > 20 | > Do not run more than one account at a time. 21 | > 22 | > Do not use more than one phone number per 5 accounts. 23 | > 24 | > Do not redeem more than one reward per day. 25 | 26 | 27 | #### Original bot by [@charlesbel](https://github.com/charlesbel) - refactored/updated/maintained by [@klept0](https://github.com/klept0) and a community of volunteers. 28 | 29 | #### PULL REQUESTS ARE WELCOME AND APPRECIATED! 30 | 31 | ## Installation 32 | 33 | [//]: # (todo - add Docker installation instructions) 34 | 35 | 1. Create a virtual environment using `venv`: 36 | ```sh 37 | python -m venv venv 38 | ``` 39 | 2. Activate the virtual environment: 40 | 41 | * On Windows: 42 | 43 | ```sh 44 | venv\Scripts\activate 45 | ``` 46 | 47 | * On macOS/Linux: 48 | 49 | ```sh 50 | source venv/bin/activate 51 | ``` 52 | 53 | 3. Install requirements with the following command : 54 | ```sh 55 | pip install -r requirements.txt 56 | ``` 57 | 58 | Or, if developing or running tests, install the dev requirements with: 59 | ```sh 60 | pip install -r requirements-dev.txt 61 | ``` 62 | 63 | Upgrade all required with the following command: 64 | `pip install --upgrade -r requirements.txt` 65 | 66 | 4. Make sure you have Chrome installed 67 | 68 | 5. (Windows Only) Make sure Visual C++ redistributable DLLs are installed 69 | 70 | If they're not, install the current "vc_redist.exe" from 71 | this [link](https://learn.microsoft.com/en-GB/cpp/windows/latest-supported-vc-redist?view=msvc-170) 72 | and reboot your computer 73 | 74 | 6. Run the script with the following arguments: 75 | ```sh 76 | python main.py -C 77 | ``` 78 | 79 | 7. Open the generated `config.yaml` file and edit it with your information. 80 | 81 | The "totp" field is not mandatory, only enter your TOTP key if you use it for 2FA (if 82 | ommitting, don't keep it as an empty string, remove the line completely). 83 | 84 | The "proxy" field is not mandatory, you can omit it if you don't want to use proxy (don't 85 | keep it as an empty string, remove the line completely). 86 | 87 | You can add or remove accounts according to your will. 88 | 89 | the "apprise.urls" field is not mandatory, you can remove it if you don't want to get notifications. 90 | 91 | 8. Run the script: 92 | ```sh 93 | python main.py 94 | ``` 95 | 96 | (Windows Only) You can also run the script wrapper that will detect your python installation 97 | and re-run the script if it crashes using `.\run.ps1` (`.\run.ps1 -help` for more 98 | information). To allow script execution without confirmation, use the following command: 99 | `Set-ExecutionPolicy RemoteSigned -Scope CurrentUser`. 100 | 101 | 9. (Windows Only) You can set up automatic execution by generating a Task Scheduler XML file. 102 | 103 | If you are a Windows user, run the `generate_task_xml.py` script to create a `.xml` file. 104 | After generating the file, import it into Task Scheduler to schedule automatic execution of 105 | the script. This will allow the script to run at the specified time without manual 106 | intervention. 107 | 108 | To import the XML file into Task Scheduler, 109 | see [this guide](https://superuser.com/a/485565/709704). 110 | 111 | ## Configuration file 112 | 113 | All the variable listed here can be added to you `config.yaml` configuration file, and the values represented here show 114 | the default ones, if not said otherwise. 115 | 116 | > [!CAUTION] 117 | > Please don't use this as your configuration. It's an example to show configuration possibilities, 118 | > not the best configuration for you. Doing so will only prevent default values from being used, 119 | > and it'll result in preventing updates (such as ignored activities). 120 | > You should only add a variable to your configuration file if you want to change it. 121 | 122 | ```yaml 123 | # config.yaml 124 | apprise: # 'apprise' is the name of the service used for notifications https://github.com/caronc/apprise 125 | enabled: true # set it to false to disable apprise globally, can be overridden with command-line arguments. 126 | notify: 127 | incomplete-activity: true # set it to false to disable notifications for incomplete activities 128 | uncaught-exception: true # set it to false to disable notifications for uncaught exceptions 129 | login-code: true # set it to false to disable notifications for the temporary M$ Authenticator login code 130 | summary: ON_ERROR # set it to ALWAYS to always receive a summary about your points progression or errors, or to 131 | # NEVER to never receive a summary, even in case of an error. 132 | urls: # add apprise urls here to receive notifications on the specified services : 133 | # https://github.com/caronc/apprise#supported-notifications 134 | # Empty by default. 135 | - discord://{WebhookID}/{WebhookToken} # Exemple url 136 | browser: 137 | geolocation: US # Replace with your country code https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2. 138 | # Detected by default, can be overridden with command-line arguments. 139 | language: en # Replace with your language code https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes. 140 | # Detected by default, can be overridden with command-line arguments. 141 | visible: false # set it to true to show the browser window, can be overridden with command-line arguments. 142 | proxy: null # set the global proxy using the 'http://user:pass@host:port' syntax. 143 | # Override per-account proxies. Can be overridden with command-line arguments. 144 | rtfr: true # If true, display the "read the readme" message at the start of the script and prevent the script 145 | # from running. Default is false. 146 | logging: 147 | level: INFO # Set to DEBUG, WARNING, ERROR or CRITICAL to change the level of displayed information in the terminal 148 | # See https://docs.python.org/3/library/logging.html#logging-levels. Can be overridden with command-line arguments. 149 | retries: 150 | backoff-factor: 120 # The base wait time between each retries. Multiplied by two each try. 151 | max: 4 # The maximal number of retries to do 152 | strategy: EXPONENTIAL # Set it to CONSTANT to use the same delay between each retries. 153 | # Else, increase it exponentially each time. 154 | cooldown: 155 | min: 300 # The minimal wait time between two searches/activities 156 | max: 600 # The maximal wait time between two searches/activities 157 | search: 158 | type: both # Set it to 'mobile' or 'desktop' to only complete searches on one plateform, 159 | # can be overridden with command-line arguments. 160 | accounts: # The accounts to use. You can put zero, one or an infinite number of accounts here. 161 | # Empty by default, can be overridden with command-line arguments. 162 | - email: Your Email 1 # replace with your email 163 | password: Your Password 1 # replace with your password 164 | totp: 0123 4567 89ab cdef # replace with your totp, or remove it 165 | proxy: http://user:pass@host1:port # replace with your account proxy, or remove it 166 | - email: Your Email 2 # replace with your email 167 | password: Your Password 2 # replace with your password 168 | totp: 0123 4567 89ab cdef # replace with your totp, or remove it 169 | proxy: http://user:pass@host2:port # replace with your account proxy, or remove it 170 | ``` 171 | 172 | ## Usage 173 | 174 | ``` 175 | usage: main.py [-h] [-c CONFIG] [-C] [-v] [-l LANG] [-g GEO] [-em EMAIL] [-pw PASSWORD] 176 | [-p PROXY] [-t {desktop,mobile,both}] [-da] [-d] [-r] 177 | 178 | A simple bot that uses Selenium to farm M$ Rewards in Python 179 | 180 | options: 181 | -h, --help show this help message and exit 182 | -c CONFIG, --config CONFIG 183 | Specify the configuration file path 184 | -C, --create-config Create a fillable configuration file with basic settings and given 185 | ones if none exists 186 | -v, --visible Visible browser (Disable headless mode) 187 | -l LANG, --lang LANG Language (ex: en) see https://serpapi.com/google-languages for options 188 | -g GEO, --geo GEO Searching geolocation (ex: US) see https://serpapi.com/google-trends- 189 | locations for options (should be uppercase) 190 | -em EMAIL, --email EMAIL 191 | Email address of the account to run. Only used if a password is given. 192 | -pw PASSWORD, --password PASSWORD 193 | Password of the account to run. Only used if an email is given. 194 | -p PROXY, --proxy PROXY 195 | Global Proxy, supports http/https/socks4/socks5 (overrides config per- 196 | account proxies) `(ex: http://user:pass@host:port)` 197 | -t {desktop,mobile,both}, --searchtype {desktop,mobile,both} 198 | Set to search in either desktop, mobile or both (default: both) 199 | -da, --disable-apprise 200 | Disable Apprise notifications, useful when developing 201 | -d, --debug Set the logging level to DEBUG 202 | -r, --reset Delete the session folder and temporary files and kill all chrome 203 | processes. Can help resolve issues. 204 | 205 | At least one account should be specified, either using command line arguments or a 206 | configuration file. All specified arguments will override the configuration file values 207 | ``` 208 | 209 | You can display this message at any moment using `python main.py -h`. 210 | 211 | ## Features 212 | 213 | - Bing searches (Desktop and Mobile) with current User-Agents 214 | - Complete the daily set automatically 215 | - Complete punch cards automatically 216 | - Complete the others promotions automatically 217 | - Headless Mode 218 | - Multi-Account Management 219 | - Session storing 220 | - 2FA Support 221 | - Notifications via [Apprise](https://github.com/caronc/apprise) - no longer limited to 222 | Telegram or Discord 223 | - Proxy Support (3.0) - they need to be **high quality** proxies 224 | - Logs to CSV file for point tracking 225 | 226 | ## Contributing 227 | 228 | Fork this repo and: 229 | 230 | * if providing a bugfix, create a pull request into master. 231 | * if providing a new feature, please create a pull request into develop. Extra points if you 232 | update the [CHANGELOG.md](CHANGELOG.md). 233 | -------------------------------------------------------------------------------- /src/activities.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import logging 3 | from random import randint 4 | from time import sleep 5 | 6 | from selenium.common import TimeoutException 7 | from selenium.webdriver.common.by import By 8 | from selenium.webdriver.remote.webelement import WebElement 9 | 10 | from src.browser import Browser 11 | from src.constants import REWARDS_URL 12 | from src.utils import ( 13 | CONFIG, 14 | APPRISE, 15 | getAnswerCode, 16 | cooldown, 17 | ACTIVITY_TITLES_TO_QUERIES, 18 | IGNORED_ACTIVITIES, 19 | ) 20 | 21 | 22 | class Activities: 23 | """ 24 | Class to handle activities in MS Rewards. 25 | """ 26 | 27 | def __init__(self, browser: Browser): 28 | self.browser = browser 29 | self.webdriver = browser.webdriver 30 | 31 | def completeSearch(self): 32 | # Simulate completing a search activity 33 | pass 34 | 35 | def completeSurvey(self): 36 | # Simulate completing a survey activity 37 | # noinspection SpellCheckingInspection 38 | self.browser.utils.waitUntilClickable(By.ID, f"btoption{randint(0, 1)}").click() 39 | 40 | def completeQuiz(self): 41 | # Simulate completing a quiz activity 42 | with contextlib.suppress( 43 | TimeoutException 44 | ): # Handles in case quiz was started in previous run 45 | startQuiz = self.browser.utils.waitUntilQuizLoads() 46 | self.browser.utils.click(startQuiz) 47 | self.browser.utils.waitUntilVisible(By.ID, "overlayPanel", 5) 48 | maxQuestions = self.webdriver.execute_script( 49 | "return _w.rewardsQuizRenderInfo.maxQuestions" 50 | ) 51 | numberOfOptions = self.webdriver.execute_script( 52 | "return _w.rewardsQuizRenderInfo.numberOfOptions" 53 | ) 54 | while True: 55 | correctlyAnsweredQuestionCount: int = self.webdriver.execute_script( 56 | "return _w.rewardsQuizRenderInfo.CorrectlyAnsweredQuestionCount" 57 | ) 58 | 59 | if correctlyAnsweredQuestionCount == maxQuestions: 60 | return 61 | 62 | self.browser.utils.waitUntilQuestionRefresh() 63 | 64 | if numberOfOptions == 8: 65 | answers = [] 66 | for i in range(numberOfOptions): 67 | isCorrectOption = self.webdriver.find_element( 68 | By.ID, f"rqAnswerOption{i}" 69 | ).get_attribute("iscorrectoption") 70 | if isCorrectOption and isCorrectOption.lower() == "true": 71 | answers.append(f"rqAnswerOption{i}") 72 | for answer in answers: 73 | element = self.webdriver.find_element(By.ID, answer) 74 | self.browser.utils.click(element) 75 | elif numberOfOptions in [2, 3, 4]: 76 | correctOption = self.webdriver.execute_script( 77 | "return _w.rewardsQuizRenderInfo.correctAnswer" 78 | ) 79 | for i in range(numberOfOptions): 80 | if ( 81 | self.webdriver.find_element( 82 | By.ID, f"rqAnswerOption{i}" 83 | ).get_attribute("data-option") 84 | == correctOption 85 | ): 86 | correctAnswer = self.browser.utils.waitUntilClickable( 87 | By.ID, f"rqAnswerOption{i}" 88 | ) 89 | self.browser.utils.click(correctAnswer) 90 | break 91 | 92 | def completeABC(self): 93 | # Simulate completing an ABC activity 94 | counter = self.webdriver.find_element( 95 | By.XPATH, '//*[@id="QuestionPane0"]/div[2]' 96 | ).text[:-1][1:] 97 | numberOfQuestions = max(int(s) for s in counter.split() if s.isdigit()) 98 | for question in range(numberOfQuestions): 99 | element = self.webdriver.find_element( 100 | By.ID, f"questionOptionChoice{question}{randint(0, 2)}" 101 | ) 102 | self.browser.utils.click(element) 103 | sleep(randint(10, 15)) 104 | element = self.webdriver.find_element(By.ID, f"nextQuestionbtn{question}") 105 | self.browser.utils.click(element) 106 | sleep(randint(10, 15)) 107 | 108 | def completeThisOrThat(self): 109 | # Simulate completing a This or That activity 110 | with contextlib.suppress( 111 | TimeoutException 112 | ): # Handles in case quiz was started in previous run 113 | startQuiz = self.browser.utils.waitUntilQuizLoads() 114 | self.browser.utils.click(startQuiz) 115 | self.browser.utils.waitUntilQuestionRefresh() 116 | for _ in range(10): 117 | correctAnswerCode = self.webdriver.execute_script( 118 | "return _w.rewardsQuizRenderInfo.correctAnswer" 119 | ) 120 | answer1, answer1Code = self.getAnswerAndCode("rqAnswerOption0") 121 | answer2, answer2Code = self.getAnswerAndCode("rqAnswerOption1") 122 | answerToClick: WebElement 123 | if answer1Code == correctAnswerCode: 124 | answerToClick = answer1 125 | elif answer2Code == correctAnswerCode: 126 | answerToClick = answer2 127 | 128 | self.browser.utils.click(answerToClick) 129 | sleep(randint(10, 15)) 130 | 131 | def getAnswerAndCode(self, answerId: str) -> tuple[WebElement, str]: 132 | # Helper function to get answer element and its code 133 | answerEncodeKey = self.webdriver.execute_script("return _G.IG") 134 | answer = self.webdriver.find_element(By.ID, answerId) 135 | answerTitle = answer.get_attribute("data-option") 136 | return ( 137 | answer, 138 | getAnswerCode(answerEncodeKey, answerTitle), 139 | ) 140 | 141 | def completeActivity(self, activity: dict) -> None: 142 | try: 143 | activityTitle = cleanupActivityTitle(activity["title"]) 144 | logging.debug(f"activityTitle={activityTitle}") 145 | if activity["complete"] or activity["pointProgressMax"] == 0: 146 | logging.debug("Already done, returning") 147 | return 148 | if activity["attributes"].get("is_unlocked", "True") != "True": 149 | logging.debug("Activity locked, returning") 150 | if activityTitle not in ACTIVITY_TITLES_TO_QUERIES: 151 | logging.warning( 152 | f"Add activity title '{activityTitle}' to search mapping in relevant language file in localized_activities") 153 | return 154 | if activityTitle in IGNORED_ACTIVITIES: 155 | logging.debug(f"Ignoring {activityTitle}") 156 | return 157 | # Open the activity for the activity 158 | if "puzzle" in activityTitle.lower(): 159 | logging.info(f"Skipping {activityTitle} because it's not supported") 160 | return 161 | if "Windows search" == activityTitle: 162 | # for search in {"what time is it in dublin", "what is the weather"}: 163 | # pyautogui.press("win") 164 | # sleep(1) 165 | # pyautogui.write(search) 166 | # sleep(5) 167 | # pyautogui.press("enter") 168 | # sleep(5) 169 | # pyautogui.hotkey("alt", "f4") # Close Edge 170 | return 171 | activityElement = self.browser.utils.waitUntilClickable( 172 | By.XPATH, f'//*[contains(text(), "{activity["title"]}")]', timeToWait=20 173 | ) 174 | self.browser.utils.click(activityElement) 175 | self.browser.utils.switchToNewTab() 176 | with contextlib.suppress(TimeoutException): 177 | searchbar = self.browser.utils.waitUntilClickable( 178 | By.ID, "sb_form_q", timeToWait=30 179 | ) 180 | self.browser.utils.click(searchbar) 181 | searchbar.clear() 182 | if activityTitle in ACTIVITY_TITLES_TO_QUERIES: 183 | searchbar.send_keys(ACTIVITY_TITLES_TO_QUERIES[activityTitle]) 184 | sleep(2) 185 | searchbar.submit() 186 | elif "poll" in activityTitle: 187 | # Complete survey for a specific scenario 188 | self.completeSurvey() 189 | elif activity["promotionType"] == "urlreward": 190 | # Complete search for URL reward 191 | self.completeSearch() 192 | elif activity["promotionType"] == "quiz": 193 | # Complete different types of quizzes based on point progress max 194 | if activity["pointProgressMax"] == 10: 195 | self.completeABC() 196 | elif activity["pointProgressMax"] in [30, 40]: 197 | self.completeQuiz() 198 | elif activity["pointProgressMax"] == 50: 199 | self.completeThisOrThat() 200 | else: 201 | # Default to completing search 202 | self.completeSearch() 203 | logging.debug("Done") 204 | except Exception: 205 | logging.error(f"[ACTIVITY] Error doing {activityTitle}", exc_info=True) 206 | logging.debug(f"activity={activity}") 207 | return 208 | finally: 209 | self.browser.utils.resetTabs() 210 | cooldown() 211 | 212 | def completeActivities(self): 213 | logging.info("[ACTIVITIES] " + "Trying to complete all activities...") 214 | for activity in self.browser.utils.getActivities(): 215 | self.completeActivity(activity) 216 | logging.info("[ACTIVITIES] " + "Done") 217 | 218 | # todo Send one email for all accounts? 219 | if CONFIG.get("apprise.notify.incomplete-activity"): # todo Use fancy new way 220 | incompleteActivities: list[str] = [] 221 | for activity in self.browser.utils.getActivities(): # Have to refresh 222 | activityTitle = cleanupActivityTitle(activity["title"]) 223 | if ( 224 | activityTitle not in IGNORED_ACTIVITIES 225 | and activity["pointProgress"] < activity["pointProgressMax"] 226 | and activity["attributes"].get("is_unlocked", "True") == "True" 227 | # todo Add check whether activity was in original set, in case added in between 228 | ): 229 | incompleteActivities.append(activityTitle) 230 | if incompleteActivities: 231 | logging.info(f"incompleteActivities: {incompleteActivities}") 232 | APPRISE.notify( 233 | '"' + '", "'.join(incompleteActivities) + '"\n' + REWARDS_URL, 234 | f"We found some incomplete activities for {self.browser.email}", 235 | ) 236 | 237 | 238 | def cleanupActivityTitle(activityTitle: str) -> str: 239 | return activityTitle.replace("\u200b", "").replace("\xa0", " ") 240 | -------------------------------------------------------------------------------- /src/browser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import random 4 | from pathlib import Path 5 | from types import TracebackType 6 | from typing import Any, Type 7 | 8 | import seleniumwire.undetected_chromedriver as webdriver 9 | import undetected_chromedriver 10 | from selenium.webdriver import ChromeOptions 11 | from selenium.webdriver.chrome.webdriver import WebDriver 12 | 13 | from src import RemainingSearches 14 | from src.userAgentGenerator import GenerateUserAgent 15 | from src.utils import ( 16 | CONFIG, 17 | Utils, 18 | getBrowserConfig, 19 | getProjectRoot, 20 | saveBrowserConfig, 21 | PREFER_BING_INFO, LANGUAGE, COUNTRY, 22 | ) 23 | 24 | 25 | class Browser: 26 | """WebDriver wrapper class.""" 27 | 28 | webdriver: undetected_chromedriver.Chrome 29 | 30 | def __init__(self, mobile: bool, account) -> None: 31 | # Initialize browser instance 32 | logging.debug("in __init__") 33 | self.mobile = mobile 34 | self.browserType = "mobile" if mobile else "desktop" 35 | self.headless = not CONFIG.browser.visible 36 | self.email = account.email 37 | self.password = account.password 38 | self.totp = account.get("totp") 39 | self.localeLang, self.localeGeo = LANGUAGE, COUNTRY 40 | self.proxy = CONFIG.browser.proxy 41 | if not self.proxy and account.get("proxy"): 42 | self.proxy = account.proxy 43 | self.userDataDir = self.setupProfiles() 44 | self.browserConfig = getBrowserConfig(self.userDataDir) 45 | ( 46 | self.userAgent, 47 | self.userAgentMetadata, 48 | newBrowserConfig, 49 | ) = GenerateUserAgent().userAgent(self.browserConfig, mobile) 50 | if newBrowserConfig: 51 | self.browserConfig = newBrowserConfig 52 | saveBrowserConfig(self.userDataDir, self.browserConfig) 53 | self.webdriver = self.browserSetup() 54 | self.utils = Utils(self.webdriver) 55 | logging.debug("out __init__") 56 | 57 | def __enter__(self): 58 | logging.debug("in __enter__") 59 | return self 60 | 61 | def __exit__( 62 | self, 63 | exc_type: Type[BaseException] | None, 64 | exc_value: BaseException | None, 65 | traceback: TracebackType | None, 66 | ): 67 | # Cleanup actions when exiting the browser context 68 | logging.debug( 69 | f"in __exit__ exc_type={exc_type} exc_value={exc_value} traceback={traceback}" 70 | ) 71 | # turns out close is needed for undetected_chromedriver 72 | self.webdriver.close() 73 | self.webdriver.quit() 74 | 75 | def browserSetup( 76 | self, 77 | ) -> undetected_chromedriver.Chrome: 78 | # Configure and setup the Chrome browser 79 | options = undetected_chromedriver.ChromeOptions() 80 | options.headless = self.headless 81 | options.add_argument(f"--lang={self.localeLang}") 82 | options.add_argument("--log-level=3") 83 | options.add_argument( 84 | "--blink-settings=imagesEnabled=false" 85 | ) # If you are having MFA sign in issues comment this line out 86 | options.add_argument("--ignore-certificate-errors") 87 | options.add_argument("--ignore-certificate-errors-spki-list") 88 | options.add_argument("--ignore-ssl-errors") 89 | if os.path.exists("/.dockerenv"): 90 | options.add_argument("--disable-dev-shm-usage") 91 | options.add_argument("--headless=new") 92 | options.add_argument("--no-sandbox") 93 | options.add_argument("--disable-extensions") 94 | options.add_argument("--dns-prefetch-disable") 95 | options.add_argument("--disable-gpu") 96 | options.add_argument("--disable-default-apps") 97 | options.add_argument("--disable-features=Translate") 98 | options.add_argument("--disable-features=PrivacySandboxSettings4") 99 | options.add_argument("--disable-http2") 100 | options.add_argument("--disable-search-engine-choice-screen") # 153 101 | options.page_load_strategy = "normal" 102 | 103 | seleniumwireOptions: dict[str, Any] = {"verify_ssl": False} 104 | 105 | if self.proxy: 106 | # Setup proxy if provided 107 | seleniumwireOptions["proxy"] = { 108 | "http": self.proxy, 109 | "https": self.proxy, 110 | "no_proxy": "localhost,127.0.0.1", 111 | } 112 | driver = None 113 | 114 | if os.path.exists("/.dockerenv"): 115 | driver = webdriver.Chrome( 116 | options=options, 117 | seleniumwire_options=seleniumwireOptions, 118 | user_data_dir=self.userDataDir.as_posix(), 119 | driver_executable_path="/usr/bin/chromedriver", 120 | ) 121 | else: 122 | # Obtain webdriver chrome driver version 123 | version = self.getChromeVersion() 124 | major = int(version.split(".")[0]) 125 | 126 | driver = webdriver.Chrome( 127 | options=options, 128 | seleniumwire_options=seleniumwireOptions, 129 | user_data_dir=self.userDataDir.as_posix(), 130 | version_main=major, 131 | ) 132 | 133 | seleniumLogger = logging.getLogger("seleniumwire") 134 | seleniumLogger.setLevel(logging.ERROR) 135 | 136 | if self.browserConfig.get("sizes"): 137 | deviceHeight = self.browserConfig["sizes"]["height"] 138 | deviceWidth = self.browserConfig["sizes"]["width"] 139 | else: 140 | if self.mobile: 141 | deviceHeight = random.randint(568, 1024) 142 | deviceWidth = random.randint(320, min(576, int(deviceHeight * 0.7))) 143 | else: 144 | deviceWidth = random.randint(1024, 2560) 145 | deviceHeight = random.randint(768, min(1440, int(deviceWidth * 0.8))) 146 | self.browserConfig["sizes"] = { 147 | "height": deviceHeight, 148 | "width": deviceWidth, 149 | } 150 | saveBrowserConfig(self.userDataDir, self.browserConfig) 151 | 152 | if self.mobile: 153 | screenHeight = deviceHeight + 146 154 | screenWidth = deviceWidth 155 | else: 156 | screenWidth = deviceWidth + 55 157 | screenHeight = deviceHeight + 151 158 | 159 | logging.info(f"Screen size: {screenWidth}x{screenHeight}") 160 | logging.info(f"Device size: {deviceWidth}x{deviceHeight}") 161 | 162 | if self.mobile: 163 | driver.execute_cdp_cmd( 164 | "Emulation.setTouchEmulationEnabled", 165 | { 166 | "enabled": True, 167 | }, 168 | ) 169 | 170 | driver.execute_cdp_cmd( 171 | "Emulation.setDeviceMetricsOverride", 172 | { 173 | "width": deviceWidth, 174 | "height": deviceHeight, 175 | "deviceScaleFactor": 0, 176 | "mobile": self.mobile, 177 | "screenWidth": screenWidth, 178 | "screenHeight": screenHeight, 179 | "positionX": 0, 180 | "positionY": 0, 181 | "viewport": { 182 | "x": 0, 183 | "y": 0, 184 | "width": deviceWidth, 185 | "height": deviceHeight, 186 | "scale": 1, 187 | }, 188 | }, 189 | ) 190 | 191 | driver.execute_cdp_cmd( 192 | "Emulation.setUserAgentOverride", 193 | { 194 | "userAgent": self.userAgent, 195 | "platform": self.userAgentMetadata["platform"], 196 | "userAgentMetadata": self.userAgentMetadata, 197 | }, 198 | ) 199 | 200 | return driver 201 | 202 | def setupProfiles(self) -> Path: 203 | """ 204 | Sets up the sessions profile for the chrome browser. 205 | Uses the email to create a unique profile for the session. 206 | 207 | Returns: 208 | Path 209 | """ 210 | sessionsDir = getProjectRoot() / "sessions" 211 | 212 | # Concatenate email and browser type for a plain text session ID 213 | sessionid = f"{self.email}" 214 | 215 | sessionsDir = sessionsDir / sessionid 216 | sessionsDir.mkdir(parents=True, exist_ok=True) 217 | return sessionsDir 218 | 219 | @staticmethod 220 | def getChromeVersion() -> str: 221 | chrome_options = ChromeOptions() 222 | chrome_options.add_argument("--headless=new") 223 | chrome_options.add_argument("--no-sandbox") 224 | driver = WebDriver(options=chrome_options) 225 | version = driver.capabilities["browserVersion"] 226 | 227 | driver.close() 228 | driver.quit() 229 | # driver.__exit__(None, None, None) 230 | 231 | return version 232 | 233 | def getRemainingSearches( 234 | self, desktopAndMobile: bool = False 235 | ) -> RemainingSearches | int: 236 | if PREFER_BING_INFO: 237 | bingInfo = self.utils.getBingInfo() 238 | else: 239 | try: 240 | bingInfo = self.utils.getDashboardData() 241 | except: 242 | logging.info("Dashboard Error: Forcing Searches Remaining to 1") 243 | return RemainingSearches( 244 | desktop=1, mobile=1 245 | ) 246 | searchPoints = 1 247 | if PREFER_BING_INFO: 248 | counters = bingInfo["flyoutResult"]["userStatus"]["counters"] 249 | else: 250 | counters = bingInfo["userStatus"]["counters"] 251 | pcSearch: dict = counters["PCSearch" if PREFER_BING_INFO else "pcSearch"][0] 252 | pointProgressMax: int = pcSearch["pointProgressMax"] 253 | 254 | searchPoints: int 255 | if pointProgressMax in [30, 90, 102]: 256 | searchPoints = 3 257 | elif pointProgressMax in [50, 150] or pointProgressMax >= 170: 258 | searchPoints = 5 259 | pcPointsRemaining = pcSearch["pointProgressMax"] - pcSearch["pointProgress"] 260 | assert pcPointsRemaining % searchPoints == 0 261 | remainingDesktopSearches: int = int(pcPointsRemaining / searchPoints) 262 | 263 | if PREFER_BING_INFO: 264 | activeLevel = bingInfo["userInfo"]["profile"]["attributes"]["level"] 265 | else: 266 | activeLevel = bingInfo["userStatus"]["levelInfo"]["activeLevel"] 267 | remainingMobileSearches: int = 0 268 | if activeLevel == "Level2": 269 | mobileSearch: dict = counters[ 270 | "MobileSearch" if PREFER_BING_INFO else "mobileSearch" 271 | ][0] 272 | mobilePointsRemaining = ( 273 | mobileSearch["pointProgressMax"] - mobileSearch["pointProgress"] 274 | ) 275 | assert mobilePointsRemaining % searchPoints == 0 276 | remainingMobileSearches = int(mobilePointsRemaining / searchPoints) 277 | elif activeLevel == "Level1": 278 | pass 279 | else: 280 | raise AssertionError(f"Unknown activeLevel: {activeLevel}") 281 | 282 | if desktopAndMobile: 283 | return RemainingSearches( 284 | desktop=remainingDesktopSearches, mobile=remainingMobileSearches 285 | ) 286 | if self.mobile: 287 | return remainingMobileSearches 288 | return remainingDesktopSearches 289 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Analyse import fallback blocks. This can be used to support both Python 2 and 4 | # 3 compatible code, which means that the block might have code that exists 5 | # only in one or another interpreter, leading to false positives when analysed. 6 | analyse-fallback-blocks=no 7 | 8 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint 9 | # in a server-like mode. 10 | clear-cache-post-run=no 11 | 12 | # Load and enable all available extensions. Use --list-extensions to see a list 13 | # all available extensions. 14 | #enable-all-extensions= 15 | 16 | # In error mode, messages with a category besides ERROR or FATAL are 17 | # suppressed, and no reports are done by default. Error mode is compatible with 18 | # disabling specific errors. 19 | #errors-only= 20 | 21 | # Always return a 0 (non-error) status code, even if lint errors are found. 22 | # This is primarily useful in continuous integration scripts. 23 | #exit-zero= 24 | 25 | # A comma-separated list of package or module names from where C extensions may 26 | # be loaded. Extensions are loading into the active Python interpreter and may 27 | # run arbitrary code. 28 | extension-pkg-allow-list= 29 | 30 | # A comma-separated list of package or module names from where C extensions may 31 | # be loaded. Extensions are loading into the active Python interpreter and may 32 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 33 | # for backward compatibility.) 34 | extension-pkg-whitelist= 35 | 36 | # Return non-zero exit code if any of these messages/categories are detected, 37 | # even if score is above --fail-under value. Syntax same as enable. Messages 38 | # specified are enabled, while categories only check already-enabled messages. 39 | fail-on= 40 | 41 | # Specify a score threshold under which the program will exit with error. 42 | fail-under=10 43 | 44 | # Interpret the stdin as a python script, whose filename needs to be passed as 45 | # the module_or_package argument. 46 | #from-stdin= 47 | 48 | # Files or directories to be skipped. They should be base names, not paths. 49 | ignore=CVS 50 | 51 | # Add files or directories matching the regular expressions patterns to the 52 | # ignore-list. The regex matches against paths and can be in Posix or Windows 53 | # format. Because '\\' represents the directory delimiter on Windows systems, 54 | # it can't be used as an escape character. 55 | ignore-paths= 56 | 57 | # Files or directories matching the regular expression patterns are skipped. 58 | # The regex matches against base names, not paths. The default value ignores 59 | # Emacs file locks 60 | ignore-patterns=^\.# 61 | 62 | # List of module names for which member attributes should not be checked and 63 | # will not be imported (useful for modules/projects where namespaces are 64 | # manipulated during runtime and thus existing member attributes cannot be 65 | # deduced by static analysis). It supports qualified module names, as well as 66 | # Unix pattern matching. 67 | ignored-modules= 68 | 69 | # Python code to execute, usually for sys.path manipulation such as 70 | # pygtk.require(). 71 | #init-hook= 72 | 73 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 74 | # number of processors available to use, and will cap the count on Windows to 75 | # avoid hangs. 76 | jobs=1 77 | 78 | # Control the amount of potential inferred values when inferring a single 79 | # object. This can help the performance when dealing with large functions or 80 | # complex, nested conditions. 81 | limit-inference-results=100 82 | 83 | # List of plugins (as comma separated values of python module names) to load, 84 | # usually to register additional checkers. 85 | load-plugins= 86 | 87 | # Pickle collected data for later comparisons. 88 | persistent=yes 89 | 90 | # Resolve imports to .pyi stubs if available. May reduce no-member messages and 91 | # increase not-an-iterable messages. 92 | prefer-stubs=no 93 | 94 | # Minimum Python version to use for version dependent checks. Will default to 95 | # the version used to run pylint. 96 | py-version=3.11 97 | 98 | # Discover python modules and packages in the file system subtree. 99 | recursive=no 100 | 101 | # Add paths to the list of the source roots. Supports globbing patterns. The 102 | # source root is an absolute path or a path relative to the current working 103 | # directory used to determine a package namespace for modules located under the 104 | # source root. 105 | source-roots= 106 | 107 | # When enabled, pylint would attempt to guess common misconfiguration and emit 108 | # user-friendly hints instead of false-positive error messages. 109 | suggestion-mode=yes 110 | 111 | # Allow loading of arbitrary C extensions. Extensions are imported into the 112 | # active Python interpreter and may run arbitrary code. 113 | unsafe-load-any-extension=no 114 | 115 | # In verbose mode, extra non-checker-related info will be displayed. 116 | #verbose= 117 | 118 | 119 | [BASIC] 120 | 121 | # Naming style matching correct argument names. 122 | argument-naming-style=snake_case 123 | 124 | # Regular expression matching correct argument names. Overrides argument- 125 | # naming-style. If left empty, argument names will be checked with the set 126 | # naming style. 127 | #argument-rgx= 128 | 129 | # Naming style matching correct attribute names. 130 | attr-naming-style=snake_case 131 | 132 | # Regular expression matching correct attribute names. Overrides attr-naming- 133 | # style. If left empty, attribute names will be checked with the set naming 134 | # style. 135 | #attr-rgx= 136 | 137 | # Bad variable names which should always be refused, separated by a comma. 138 | bad-names=foo, 139 | bar, 140 | baz, 141 | toto, 142 | tutu, 143 | tata 144 | 145 | # Bad variable names regexes, separated by a comma. If names match any regex, 146 | # they will always be refused 147 | bad-names-rgxs= 148 | 149 | # Naming style matching correct class attribute names. 150 | class-attribute-naming-style=any 151 | 152 | # Regular expression matching correct class attribute names. Overrides class- 153 | # attribute-naming-style. If left empty, class attribute names will be checked 154 | # with the set naming style. 155 | #class-attribute-rgx= 156 | 157 | # Naming style matching correct class constant names. 158 | class-const-naming-style=UPPER_CASE 159 | 160 | # Regular expression matching correct class constant names. Overrides class- 161 | # const-naming-style. If left empty, class constant names will be checked with 162 | # the set naming style. 163 | #class-const-rgx= 164 | 165 | # Naming style matching correct class names. 166 | class-naming-style=PascalCase 167 | 168 | # Regular expression matching correct class names. Overrides class-naming- 169 | # style. If left empty, class names will be checked with the set naming style. 170 | #class-rgx= 171 | 172 | # Naming style matching correct constant names. 173 | const-naming-style=UPPER_CASE 174 | 175 | # Regular expression matching correct constant names. Overrides const-naming- 176 | # style. If left empty, constant names will be checked with the set naming 177 | # style. 178 | #const-rgx= 179 | 180 | # Minimum line length for functions/classes that require docstrings, shorter 181 | # ones are exempt. 182 | docstring-min-length=-1 183 | 184 | # Naming style matching correct function names. 185 | function-naming-style=snake_case 186 | 187 | # Regular expression matching correct function names. Overrides function- 188 | # naming-style. If left empty, function names will be checked with the set 189 | # naming style. 190 | #function-rgx= 191 | 192 | # Good variable names which should always be accepted, separated by a comma. 193 | good-names=i, 194 | j, 195 | k, 196 | ex, 197 | Run, 198 | _ 199 | 200 | # Good variable names regexes, separated by a comma. If names match any regex, 201 | # they will always be accepted 202 | good-names-rgxs= 203 | 204 | # Include a hint for the correct naming format with invalid-name. 205 | include-naming-hint=no 206 | 207 | # Naming style matching correct inline iteration names. 208 | inlinevar-naming-style=any 209 | 210 | # Regular expression matching correct inline iteration names. Overrides 211 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 212 | # with the set naming style. 213 | #inlinevar-rgx= 214 | 215 | # Naming style matching correct method names. 216 | method-naming-style=snake_case 217 | 218 | # Regular expression matching correct method names. Overrides method-naming- 219 | # style. If left empty, method names will be checked with the set naming style. 220 | #method-rgx= 221 | 222 | # Naming style matching correct module names. 223 | module-naming-style=snake_case 224 | 225 | # Regular expression matching correct module names. Overrides module-naming- 226 | # style. If left empty, module names will be checked with the set naming style. 227 | #module-rgx= 228 | 229 | # Colon-delimited sets of names that determine each other's naming style when 230 | # the name regexes allow several styles. 231 | name-group= 232 | 233 | # Regular expression which should only match function or class names that do 234 | # not require a docstring. 235 | no-docstring-rgx=^_ 236 | 237 | # List of decorators that produce properties, such as abc.abstractproperty. Add 238 | # to this list to register other decorators that produce valid properties. 239 | # These decorators are taken in consideration only for invalid-name. 240 | property-classes=abc.abstractproperty 241 | 242 | # Regular expression matching correct type alias names. If left empty, type 243 | # alias names will be checked with the set naming style. 244 | #typealias-rgx= 245 | 246 | # Regular expression matching correct type variable names. If left empty, type 247 | # variable names will be checked with the set naming style. 248 | #typevar-rgx= 249 | 250 | # Naming style matching correct variable names. 251 | variable-naming-style=snake_case 252 | 253 | # Regular expression matching correct variable names. Overrides variable- 254 | # naming-style. If left empty, variable names will be checked with the set 255 | # naming style. 256 | #variable-rgx= 257 | 258 | 259 | [CLASSES] 260 | 261 | # Warn about protected attribute access inside special methods 262 | check-protected-access-in-special-methods=no 263 | 264 | # List of method names used to declare (i.e. assign) instance attributes. 265 | defining-attr-methods=__init__, 266 | __new__, 267 | setUp, 268 | asyncSetUp, 269 | __post_init__ 270 | 271 | # List of member names, which should be excluded from the protected access 272 | # warning. 273 | exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit 274 | 275 | # List of valid names for the first argument in a class method. 276 | valid-classmethod-first-arg=cls 277 | 278 | # List of valid names for the first argument in a metaclass class method. 279 | valid-metaclass-classmethod-first-arg=mcs 280 | 281 | 282 | [DESIGN] 283 | 284 | # List of regular expressions of class ancestor names to ignore when counting 285 | # public methods (see R0903) 286 | exclude-too-few-public-methods= 287 | 288 | # List of qualified class names to ignore when counting class parents (see 289 | # R0901) 290 | ignored-parents= 291 | 292 | # Maximum number of arguments for function / method. 293 | max-args=5 294 | 295 | # Maximum number of attributes for a class (see R0902). 296 | max-attributes=7 297 | 298 | # Maximum number of boolean expressions in an if statement (see R0916). 299 | max-bool-expr=5 300 | 301 | # Maximum number of branch for function / method body. 302 | max-branches=12 303 | 304 | # Maximum number of locals for function / method body. 305 | max-locals=15 306 | 307 | # Maximum number of parents for a class (see R0901). 308 | max-parents=7 309 | 310 | # Maximum number of public methods for a class (see R0904). 311 | max-public-methods=20 312 | 313 | # Maximum number of return / yield for function / method body. 314 | max-returns=6 315 | 316 | # Maximum number of statements in function / method body. 317 | max-statements=50 318 | 319 | # Minimum number of public methods for a class (see R0903). 320 | min-public-methods=2 321 | 322 | 323 | [EXCEPTIONS] 324 | 325 | # Exceptions that will emit a warning when caught. 326 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 327 | 328 | 329 | [FORMAT] 330 | 331 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 332 | expected-line-ending-format= 333 | 334 | # Regexp for a line that is allowed to be longer than the limit. 335 | ignore-long-lines=^\s*(# )??$ 336 | 337 | # Number of spaces of indent required inside a hanging or continued line. 338 | indent-after-paren=4 339 | 340 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 341 | # tab). 342 | indent-string=' ' 343 | 344 | # Maximum number of characters on a single line. 345 | max-line-length=100 346 | 347 | # Maximum number of lines in a module. 348 | max-module-lines=1000 349 | 350 | # Allow the body of a class to be on the same line as the declaration if body 351 | # contains single statement. 352 | single-line-class-stmt=no 353 | 354 | # Allow the body of an if to be on the same line as the test if there is no 355 | # else. 356 | single-line-if-stmt=no 357 | 358 | 359 | [IMPORTS] 360 | 361 | # List of modules that can be imported at any level, not just the top level 362 | # one. 363 | allow-any-import-level= 364 | 365 | # Allow explicit reexports by alias from a package __init__. 366 | allow-reexport-from-package=no 367 | 368 | # Allow wildcard imports from modules that define __all__. 369 | allow-wildcard-with-all=no 370 | 371 | # Deprecated modules which should not be used, separated by a comma. 372 | deprecated-modules= 373 | 374 | # Output a graph (.gv or any supported image format) of external dependencies 375 | # to the given file (report RP0402 must not be disabled). 376 | ext-import-graph= 377 | 378 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 379 | # external) dependencies to the given file (report RP0402 must not be 380 | # disabled). 381 | import-graph= 382 | 383 | # Output a graph (.gv or any supported image format) of internal dependencies 384 | # to the given file (report RP0402 must not be disabled). 385 | int-import-graph= 386 | 387 | # Force import order to recognize a module as part of the standard 388 | # compatibility libraries. 389 | known-standard-library= 390 | 391 | # Force import order to recognize a module as part of a third party library. 392 | known-third-party=enchant 393 | 394 | # Couples of modules and preferred modules, separated by a comma. 395 | preferred-modules= 396 | 397 | 398 | [LOGGING] 399 | 400 | # The type of string formatting that logging methods do. `old` means using % 401 | # formatting, `new` is for `{}` formatting. 402 | logging-format-style=old 403 | 404 | # Logging modules to check that the string format arguments are in logging 405 | # function parameter format. 406 | logging-modules=logging 407 | 408 | 409 | [MESSAGES CONTROL] 410 | 411 | # Only show warnings with the listed confidence levels. Leave empty to show 412 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 413 | # UNDEFINED. 414 | confidence=HIGH, 415 | CONTROL_FLOW, 416 | INFERENCE, 417 | INFERENCE_FAILURE, 418 | UNDEFINED 419 | 420 | # Disable the message, report, category or checker with the given id(s). You 421 | # can either give multiple identifiers separated by comma (,) or put this 422 | # option multiple times (only on the command line, not in the configuration 423 | # file where it should appear only once). You can also use "--disable=all" to 424 | # disable everything first and then re-enable specific checks. For example, if 425 | # you want to run only the similarities checker, you can use "--disable=all 426 | # --enable=similarities". If you want to run only the classes checker, but have 427 | # no Warning level messages displayed, use "--disable=all --enable=classes 428 | # --disable=W". 429 | disable=raw-checker-failed, 430 | bad-inline-option, 431 | locally-disabled, 432 | file-ignored, 433 | suppressed-message, 434 | useless-suppression, 435 | deprecated-pragma, 436 | use-symbolic-message-instead, 437 | use-implicit-booleaness-not-comparison-to-string, 438 | use-implicit-booleaness-not-comparison-to-zero, 439 | fixme, 440 | invalid-name, 441 | missing-function-docstring, 442 | missing-module-docstring, 443 | broad-exception-caught, # As we catch any error to avoid stopping tasks and sending correct apprise notifications 444 | attribute-defined-outside-init, # As we set attributes outside __init__ in many cases 445 | logging-fstring-interpolation, # As we always log at the DEBUG level, so lazy interpolation doesn't change anything 446 | logging-not-lazy, 447 | too-many-branches, 448 | too-many-instance-attributes, 449 | too-many-statements, 450 | too-few-public-methods 451 | 452 | # Enable the message, report, category or checker with the given id(s). You can 453 | # either give multiple identifier separated by comma (,) or put this option 454 | # multiple time (only on the command line, not in the configuration file where 455 | # it should appear only once). See also the "--disable" option for examples. 456 | enable= 457 | 458 | 459 | [METHOD_ARGS] 460 | 461 | # List of qualified names (i.e., library.method) which require a timeout 462 | # parameter e.g. 'requests.api.get,requests.api.post' 463 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 464 | 465 | 466 | [MISCELLANEOUS] 467 | 468 | # List of note tags to take in consideration, separated by a comma. 469 | notes=FIXME, 470 | XXX, 471 | TODO 472 | 473 | # Regular expression of note tags to take in consideration. 474 | notes-rgx= 475 | 476 | 477 | [REFACTORING] 478 | 479 | # Maximum number of nested blocks for function / method body 480 | max-nested-blocks=5 481 | 482 | # Complete name of functions that never returns. When checking for 483 | # inconsistent-return-statements if a never returning function is called then 484 | # it will be considered as an explicit return statement and no message will be 485 | # printed. 486 | never-returning-functions=sys.exit,argparse.parse_error 487 | 488 | # Let 'consider-using-join' be raised when the separator to join on would be 489 | # non-empty (resulting in expected fixes of the type: ``"- " + " - 490 | # ".join(items)``) 491 | suggest-join-with-non-empty-separator=yes 492 | 493 | 494 | [REPORTS] 495 | 496 | # Python expression which should return a score less than or equal to 10. You 497 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 498 | # 'convention', and 'info' which contain the number of messages in each 499 | # category, as well as 'statement' which is the total number of statements 500 | # analyzed. This score is used by the global evaluation report (RP0004). 501 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 502 | 503 | # Template used to display messages. This is a python new-style format string 504 | # used to format the message information. See doc for all details. 505 | msg-template= 506 | 507 | # Set the output format. Available formats are: text, parseable, colorized, 508 | # json2 (improved json format), json (old json format) and msvs (visual 509 | # studio). You can also give a reporter class, e.g. 510 | # mypackage.mymodule.MyReporterClass. 511 | #output-format= 512 | 513 | # Tells whether to display a full report or only the messages. 514 | reports=no 515 | 516 | # Activate the evaluation score. 517 | score=yes 518 | 519 | 520 | [SIMILARITIES] 521 | 522 | # Comments are removed from the similarity computation 523 | ignore-comments=yes 524 | 525 | # Docstrings are removed from the similarity computation 526 | ignore-docstrings=yes 527 | 528 | # Imports are removed from the similarity computation 529 | ignore-imports=yes 530 | 531 | # Signatures are removed from the similarity computation 532 | ignore-signatures=yes 533 | 534 | # Minimum lines number of a similarity. 535 | min-similarity-lines=4 536 | 537 | 538 | [SPELLING] 539 | 540 | # Limits count of emitted suggestions for spelling mistakes. 541 | max-spelling-suggestions=4 542 | 543 | # Spelling dictionary name. No available dictionaries : You need to install 544 | # both the python package and the system dependency for enchant to work. 545 | spelling-dict= 546 | 547 | # List of comma separated words that should be considered directives if they 548 | # appear at the beginning of a comment and should not be checked. 549 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 550 | 551 | # List of comma separated words that should not be checked. 552 | spelling-ignore-words= 553 | 554 | # A path to a file that contains the private dictionary; one word per line. 555 | spelling-private-dict-file= 556 | 557 | # Tells whether to store unknown words to the private dictionary (see the 558 | # --spelling-private-dict-file option) instead of raising a message. 559 | spelling-store-unknown-words=no 560 | 561 | 562 | [STRING] 563 | 564 | # This flag controls whether inconsistent-quotes generates a warning when the 565 | # character used as a quote delimiter is used inconsistently within a module. 566 | check-quote-consistency=no 567 | 568 | # This flag controls whether the implicit-str-concat should generate a warning 569 | # on implicit string concatenation in sequences defined over several lines. 570 | check-str-concat-over-line-jumps=no 571 | 572 | 573 | [TYPECHECK] 574 | 575 | # List of decorators that produce context managers, such as 576 | # contextlib.contextmanager. Add to this list to register other decorators that 577 | # produce valid context managers. 578 | contextmanager-decorators=contextlib.contextmanager 579 | 580 | # List of members which are set dynamically and missed by pylint inference 581 | # system, and so shouldn't trigger E1101 when accessed. Python regular 582 | # expressions are accepted. 583 | generated-members= 584 | 585 | # Tells whether to warn about missing members when the owner of the attribute 586 | # is inferred to be None. 587 | ignore-none=yes 588 | 589 | # This flag controls whether pylint should warn about no-member and similar 590 | # checks whenever an opaque object is returned when inferring. The inference 591 | # can return multiple potential results while evaluating a Python object, but 592 | # some branches might not be evaluated, which results in partial inference. In 593 | # that case, it might be useful to still emit no-member and other checks for 594 | # the rest of the inferred objects. 595 | ignore-on-opaque-inference=yes 596 | 597 | # List of symbolic message names to ignore for Mixin members. 598 | ignored-checks-for-mixins=no-member, 599 | not-async-context-manager, 600 | not-context-manager, 601 | attribute-defined-outside-init 602 | 603 | # List of class names for which member attributes should not be checked (useful 604 | # for classes with dynamically set attributes). This supports the use of 605 | # qualified names. 606 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 607 | 608 | # Show a hint with possible names when a member name was not found. The aspect 609 | # of finding the hint is based on edit distance. 610 | missing-member-hint=yes 611 | 612 | # The minimum edit distance a name should have in order to be considered a 613 | # similar match for a missing member name. 614 | missing-member-hint-distance=1 615 | 616 | # The total number of similar names that should be taken in consideration when 617 | # showing a hint for a missing member. 618 | missing-member-max-choices=1 619 | 620 | # Regex pattern to define which classes are considered mixins. 621 | mixin-class-rgx=.*[Mm]ixin 622 | 623 | # List of decorators that change the signature of a decorated function. 624 | signature-mutators= 625 | 626 | 627 | [VARIABLES] 628 | 629 | # List of additional names supposed to be defined in builtins. Remember that 630 | # you should avoid defining new builtins when possible. 631 | additional-builtins= 632 | 633 | # Tells whether unused global variables should be treated as a violation. 634 | allow-global-unused-variables=yes 635 | 636 | # List of names allowed to shadow builtins 637 | allowed-redefined-builtins= 638 | 639 | # List of strings which can identify a callback function by name. A callback 640 | # name must start or end with one of those strings. 641 | callbacks=cb_, 642 | _cb 643 | 644 | # A regular expression matching the name of dummy variables (i.e. expected to 645 | # not be used). 646 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 647 | 648 | # Argument names that match this expression will be ignored. 649 | ignored-argument-names=_.*|^ignored_|^unused_ 650 | 651 | # Tells whether we should check for unused import in __init__ files. 652 | init-import=no 653 | 654 | # List of qualified module names which can have objects that can redefine 655 | # builtins. 656 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 657 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import importlib 3 | import json 4 | import locale as pylocale 5 | import logging 6 | import random 7 | import re 8 | import shutil 9 | import sys 10 | import time 11 | from argparse import Namespace, ArgumentParser 12 | from copy import deepcopy 13 | from datetime import date 14 | from pathlib import Path 15 | from types import ModuleType 16 | from typing import Any, Self 17 | 18 | import psutil 19 | import pycountry 20 | import requests 21 | import yaml 22 | from apprise import Apprise 23 | from ipapi import ipapi 24 | from ipapi.exceptions import RateLimited 25 | from requests import Session, JSONDecodeError 26 | from requests.adapters import HTTPAdapter 27 | from selenium.common import ( 28 | ElementClickInterceptedException, 29 | ElementNotInteractableException, 30 | NoSuchElementException, 31 | TimeoutException, 32 | ) 33 | from selenium.webdriver.chrome.webdriver import WebDriver 34 | from selenium.webdriver.common.by import By 35 | from selenium.webdriver.remote.webelement import WebElement 36 | from selenium.webdriver.support import expected_conditions 37 | from selenium.webdriver.support.wait import WebDriverWait 38 | from urllib3 import Retry 39 | 40 | from .constants import REWARDS_URL, SEARCH_URL 41 | 42 | PREFER_BING_INFO = False 43 | 44 | 45 | class Config(dict): 46 | """ 47 | A class that extends the built-in dict class to provide additional functionality 48 | (such as nested dictionaries and lists, YAML loading, and attribute access) 49 | to make it easier to work with configuration data. 50 | """ 51 | 52 | def __init__(self, *args, **kwargs): 53 | super().__init__(*args, **kwargs) 54 | for key, value in self.items(): 55 | if isinstance(value, dict): 56 | self[key] = self.__class__(value) 57 | if isinstance(value, list): 58 | for i, v in enumerate(value): 59 | if isinstance(v, dict): 60 | value[i] = self.__class__(v) 61 | 62 | def __or__(self, other): 63 | new = deepcopy(self) 64 | for key in other: 65 | if key in new: 66 | if isinstance(new[key], dict) and isinstance(other[key], dict): 67 | new[key] = new[key] | other[key] 68 | continue 69 | if isinstance(other[key], dict): 70 | new[key] = self.__class__(other[key]) 71 | continue 72 | if isinstance(other[key], list): 73 | new[key] = self.configifyList(other[key]) 74 | continue 75 | new[key] = other[key] 76 | return new 77 | 78 | def __getattribute__(self, item): 79 | if item in self: 80 | return self[item] 81 | return super().__getattribute__(item) 82 | 83 | def __setattr__(self, key, value): 84 | if isinstance(value, dict): 85 | value = self.__class__(value) 86 | if isinstance(value, list): 87 | value = self.configifyList(value) 88 | self[key] = value 89 | 90 | def __getitem__(self, item): 91 | if not isinstance(item, str) or not "." in item: 92 | return super().__getitem__(item) 93 | item: str 94 | items = item.split(".") 95 | found = super().__getitem__(items[0]) 96 | for child_items in items[1:]: 97 | found = found.__getitem__(child_items) 98 | return found 99 | 100 | def __setitem__(self, key, value): 101 | if isinstance(value, dict): 102 | value = self.__class__(value) 103 | if isinstance(value, list): 104 | value = self.configifyList(value) 105 | if not isinstance(key, str) or not "." in key: 106 | super().__setitem__(key, value) 107 | return 108 | item: str 109 | items = key.split(".") 110 | found = super().__getitem__(items[0]) 111 | for item in items[1:-1]: 112 | found = found.__getitem__(item) 113 | found.__setitem__(items[-1], value) 114 | 115 | @classmethod 116 | def fromYaml(cls, path: Path) -> Self: 117 | if not path.exists() or not path.is_file(): 118 | return cls() 119 | with open(path, encoding="utf-8") as f: 120 | yamlContents = yaml.safe_load(f) 121 | if not yamlContents: 122 | return cls() 123 | return cls(yamlContents) 124 | 125 | @classmethod 126 | def configifyList(cls, listToConvert: list) -> list: 127 | new = [None] * len(listToConvert) 128 | for index, item in enumerate(listToConvert): 129 | if isinstance(item, dict): 130 | new[index] = cls(item) 131 | continue 132 | if isinstance(item, list): 133 | new[index] = cls.configifyList(item) 134 | continue 135 | new[index] = item 136 | return new 137 | 138 | @classmethod 139 | def dictifyList(cls, listToConvert: list) -> list: 140 | new = [None] * len(listToConvert) 141 | for index, item in enumerate(listToConvert): 142 | if isinstance(item, cls): 143 | new[index] = item.toDict() 144 | continue 145 | if isinstance(item, list): 146 | new[index] = cls.dictifyList(item) 147 | continue 148 | new[index] = item 149 | return new 150 | 151 | def get(self, item, default=None): 152 | if not isinstance(item, str) or not "." in item: 153 | return super().get(item, default) 154 | item: str 155 | keys = item.split(".") 156 | found = super().get(keys[0], default) 157 | for key in keys[1:]: 158 | found = found.get(key, default) 159 | return found 160 | 161 | def toDict(self) -> dict: 162 | new = {} 163 | for key, value in self.items(): 164 | if isinstance(value, self.__class__): 165 | new[key] = value.toDict() 166 | continue 167 | if isinstance(value, list): 168 | new[key] = self.dictifyList(value) 169 | continue 170 | new[key] = value 171 | return new 172 | 173 | 174 | DEFAULT_CONFIG: Config = Config( 175 | { 176 | "apprise": { 177 | "enabled": True, 178 | "notify": { 179 | "incomplete-activity": True, 180 | "uncaught-exception": True, 181 | "login-code": True, 182 | }, 183 | "summary": "ON_ERROR", 184 | "urls": [], 185 | }, 186 | "browser": { 187 | "geolocation": None, 188 | "language": None, 189 | "visible": False, 190 | "proxy": None, 191 | }, 192 | "rtfr": False, 193 | "logging": { 194 | "format": "%(asctime)s [%(levelname)s] %(message)s", 195 | "level": "INFO", 196 | }, 197 | "retries": {"backoff-factor": 120, "max": 4, "strategy": "EXPONENTIAL"}, 198 | "cooldown": {"min": 300, "max": 600}, 199 | "search": {"type": "both"}, 200 | "accounts": [], 201 | } 202 | ) 203 | 204 | 205 | class Utils: 206 | """ 207 | A class that provides utility functions for Selenium WebDriver interactions. 208 | """ 209 | 210 | def __init__(self, webdriver: WebDriver): 211 | self.webdriver = webdriver 212 | with contextlib.suppress(Exception): 213 | locale = pylocale.getlocale()[0] 214 | pylocale.setlocale(pylocale.LC_NUMERIC, locale) 215 | 216 | def waitUntilVisible( 217 | self, by: str, selector: str, timeToWait: float = 10 218 | ) -> WebElement: 219 | return WebDriverWait(self.webdriver, timeToWait).until( 220 | expected_conditions.visibility_of_element_located((by, selector)) 221 | ) 222 | 223 | def waitUntilClickable( 224 | self, by: str, selector: str, timeToWait: float = 10 225 | ) -> WebElement: 226 | return WebDriverWait(self.webdriver, timeToWait).until( 227 | expected_conditions.element_to_be_clickable((by, selector)) 228 | ) 229 | 230 | def checkIfTextPresentAfterDelay(self, text: str, timeToWait: float = 10) -> bool: 231 | time.sleep(timeToWait) 232 | text_found = re.search(text, self.webdriver.page_source) 233 | return text_found is not None 234 | 235 | def waitUntilQuestionRefresh(self) -> WebElement: 236 | return self.waitUntilVisible(By.CLASS_NAME, "rqECredits", timeToWait=20) 237 | 238 | def waitUntilQuizLoads(self) -> WebElement: 239 | return self.waitUntilVisible(By.XPATH, '//*[@id="rqStartQuiz"]') 240 | 241 | def resetTabs(self) -> None: 242 | curr = self.webdriver.current_window_handle 243 | 244 | for handle in self.webdriver.window_handles: 245 | if handle != curr: 246 | self.webdriver.switch_to.window(handle) 247 | time.sleep(0.5) 248 | self.webdriver.close() 249 | time.sleep(0.5) 250 | 251 | self.webdriver.switch_to.window(curr) 252 | time.sleep(0.5) 253 | self.goToRewards() 254 | 255 | def goToRewards(self) -> None: 256 | self.webdriver.get(REWARDS_URL) 257 | assert ( 258 | self.webdriver.current_url == REWARDS_URL 259 | ), f"{self.webdriver.current_url} {REWARDS_URL}" 260 | 261 | def goToSearch(self) -> None: 262 | self.webdriver.get(SEARCH_URL) 263 | 264 | # Prefer getBingInfo if possible 265 | def getDashboardData(self) -> dict: 266 | self.goToRewards() 267 | time.sleep(5) # fixme Avoid busy wait (if this works) 268 | return self.webdriver.execute_script("return dashboard") 269 | 270 | def getDailySetPromotions(self) -> list[dict]: 271 | return self.getDashboardData()["dailySetPromotions"][ 272 | date.today().strftime("%m/%d/%Y") 273 | ] 274 | 275 | def getMorePromotions(self) -> list[dict]: 276 | return self.getDashboardData()["morePromotions"] 277 | 278 | def getActivities(self) -> list[dict]: 279 | return self.getDailySetPromotions() + self.getMorePromotions() 280 | 281 | def getBingInfo(self) -> Any: 282 | session = makeRequestsSession() 283 | retries = CONFIG.retries.max 284 | backoff_factor = CONFIG.get("retries.backoff-factor") 285 | 286 | for cookie in self.webdriver.get_cookies(): 287 | session.cookies.set(cookie["name"], cookie["value"]) 288 | 289 | for attempt in range(retries): 290 | try: 291 | response = session.get( 292 | "https://www.bing.com/rewards/panelflyout/getuserinfo" 293 | ) 294 | assert ( 295 | response.status_code == requests.codes.ok 296 | ) # pylint: disable=no-member 297 | return response.json() 298 | except (JSONDecodeError, AssertionError) as e: 299 | logging.info(f"Attempt {attempt + 1} failed: {e}") 300 | if attempt < retries - 1: 301 | sleep_time = backoff_factor * (2**attempt) 302 | logging.info(f"Retrying in {sleep_time} seconds...") 303 | time.sleep(sleep_time) 304 | else: 305 | # noinspection PyUnboundLocalVariable 306 | logging.debug(response) 307 | raise 308 | 309 | def isLoggedIn(self) -> bool: 310 | if self.getBingInfo()["isRewardsUser"]: # faster, if it works 311 | return True 312 | self.webdriver.get( 313 | "https://rewards.bing.com/Signin/" 314 | ) # changed site to allow bypassing when M$ blocks access to login.live.com randomly 315 | with contextlib.suppress(TimeoutException): 316 | self.waitUntilVisible( 317 | By.CSS_SELECTOR, 'html[data-role-name="RewardsPortal"]', 10 318 | ) 319 | return True 320 | return False 321 | 322 | def getAccountPoints(self) -> int: 323 | if PREFER_BING_INFO: 324 | return self.getBingInfo()["userInfo"]["balance"] 325 | return self.getDashboardData()["userStatus"]["availablePoints"] 326 | 327 | def getGoalPoints(self) -> int: 328 | if PREFER_BING_INFO: 329 | return self.getBingInfo()["flyoutResult"]["userGoal"]["price"] 330 | return self.getDashboardData()["userStatus"]["redeemGoal"]["price"] 331 | 332 | def getGoalTitle(self) -> str: 333 | if PREFER_BING_INFO: 334 | return self.getBingInfo()["flyoutResult"]["userGoal"]["title"] 335 | return self.getDashboardData()["userStatus"]["redeemGoal"]["title"] 336 | 337 | def tryDismissAllMessages(self) -> None: 338 | byValues = [ 339 | (By.ID, "iLandingViewAction"), 340 | (By.ID, "iShowSkip"), 341 | (By.ID, "iNext"), 342 | (By.ID, "iLooksGood"), 343 | (By.ID, "idSIButton9"), 344 | (By.ID, "bnp_btn_accept"), 345 | (By.ID, "acceptButton"), 346 | (By.CSS_SELECTOR, ".dashboardPopUpPopUpSelectButton"), 347 | ] 348 | for byValue in byValues: 349 | dismissButtons = [] 350 | with contextlib.suppress(NoSuchElementException): 351 | dismissButtons = self.webdriver.find_elements( 352 | by=byValue[0], value=byValue[1] 353 | ) 354 | for dismissButton in dismissButtons: 355 | dismissButton.click() 356 | with contextlib.suppress(NoSuchElementException): 357 | self.webdriver.find_element(By.ID, "cookie-banner").find_element( 358 | By.TAG_NAME, "button" 359 | ).click() 360 | 361 | def switchToNewTab(self, timeToWait: float = 10, closeTab: bool = False) -> None: 362 | time.sleep(timeToWait) 363 | self.webdriver.switch_to.window(window_name=self.webdriver.window_handles[1]) 364 | if closeTab: 365 | self.closeCurrentTab() 366 | 367 | def closeCurrentTab(self) -> None: 368 | self.webdriver.close() 369 | time.sleep(0.5) 370 | self.webdriver.switch_to.window(window_name=self.webdriver.window_handles[0]) 371 | time.sleep(0.5) 372 | 373 | def click(self, element: WebElement) -> None: 374 | try: 375 | WebDriverWait(self.webdriver, 10).until( 376 | expected_conditions.element_to_be_clickable(element) 377 | ).click() 378 | except ( 379 | TimeoutException, 380 | ElementClickInterceptedException, 381 | ElementNotInteractableException, 382 | ): 383 | self.tryDismissAllMessages() 384 | with contextlib.suppress(TimeoutException): 385 | WebDriverWait(self.webdriver, 10).until( 386 | expected_conditions.element_to_be_clickable(element) 387 | ) 388 | element.click() 389 | 390 | 391 | def argumentParser() -> Namespace: 392 | parser = ArgumentParser( 393 | description="A simple bot that uses Selenium to farm M$ Rewards in Python", 394 | epilog="At least one account should be specified," 395 | " either using command line arguments or a configuration file." 396 | "\nAll specified arguments will override the configuration file values.", 397 | ) 398 | parser.add_argument( 399 | "-c", 400 | "--config", 401 | type=str, 402 | default=None, 403 | help="Specify the configuration file path", 404 | ) 405 | parser.add_argument( 406 | "-C", 407 | "--create-config", 408 | action="store_true", 409 | help="Create a fillable configuration file with basic settings" 410 | " and given ones if none exists", 411 | ) 412 | parser.add_argument( 413 | "-v", 414 | "--visible", 415 | action="store_true", 416 | help="Visible browser (Disable headless mode)", 417 | ) 418 | parser.add_argument( 419 | "-l", 420 | "--lang", 421 | type=str, 422 | default=None, 423 | help="Language (ex: en)" 424 | "\nsee https://serpapi.com/google-languages for options", 425 | ) 426 | parser.add_argument( 427 | "-g", 428 | "--geo", 429 | type=str, 430 | default=None, 431 | help="Searching geolocation (ex: US)" 432 | "\nsee https://serpapi.com/google-trends-locations for options (should be uppercase)", 433 | ) 434 | parser.add_argument( 435 | "-em", 436 | "--email", 437 | type=str, 438 | default=None, 439 | help="Email address of the account to run. Only used if a password is given.", 440 | ) 441 | parser.add_argument( 442 | "-pw", 443 | "--password", 444 | type=str, 445 | default=None, 446 | help="Password of the account to run. Only used if an email is given.", 447 | ) 448 | parser.add_argument( 449 | "-p", 450 | "--proxy", 451 | type=str, 452 | default=None, 453 | help="Global Proxy, supports http/https/socks4/socks5" 454 | " (overrides config per-account proxies)" 455 | "\n`(ex: http://user:pass@host:port)`", 456 | ) 457 | parser.add_argument( 458 | "-t", 459 | "--searchtype", 460 | choices=["desktop", "mobile", "both"], 461 | default=None, 462 | help="Set to search in either desktop, mobile or both (default: both)", 463 | ) 464 | parser.add_argument( 465 | "-da", 466 | "--disable-apprise", 467 | action="store_true", 468 | help="Disable Apprise notifications, useful when developing", 469 | ) 470 | parser.add_argument( 471 | "-d", 472 | "--debug", 473 | action="store_true", 474 | help="Set the logging level to DEBUG", 475 | ) 476 | parser.add_argument( 477 | "-r", 478 | "--reset", 479 | action="store_true", 480 | help="Delete the session folder and temporary files and kill" 481 | " all chrome processes. Can help resolve issues.", 482 | ) 483 | return parser.parse_args() 484 | 485 | 486 | def getProjectRoot() -> Path: 487 | return Path(__file__).parent.parent 488 | 489 | 490 | def commandLineArgumentsAsConfig(args: Namespace) -> Config: 491 | config = Config() 492 | if args.visible: 493 | config.browser = Config() 494 | config.browser.visible = True 495 | if args.lang: 496 | if "browser" not in config: 497 | config.browser = Config() 498 | config.browser.language = args.lang 499 | if args.geo: 500 | if "browser" not in config: 501 | config.browser = Config() 502 | config.browser.geolocation = args.geo 503 | if args.proxy: 504 | if "browser" not in config: 505 | config.browser = Config() 506 | config.browser.proxy = args.proxy 507 | if args.disable_apprise: 508 | config.apprise = Config() 509 | config.apprise.enabled = False 510 | if args.debug: 511 | config.logging = Config() 512 | config.logging.level = "DEBUG" 513 | if args.searchtype: 514 | config.search = Config() 515 | config.search.type = args.searchtype 516 | if args.email and args.password: 517 | config.accounts = [ 518 | Config( 519 | email=args.email, 520 | password=args.password, 521 | ) 522 | ] 523 | 524 | return config 525 | 526 | 527 | def setupAccounts(config: Config) -> Config: 528 | def validEmail(email: str) -> bool: 529 | """Validate Email.""" 530 | pattern = r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$" 531 | return bool(re.match(pattern, email)) 532 | 533 | loadedAccounts = [] 534 | for account in config.accounts: 535 | if ( 536 | "email" not in account 537 | or not isinstance(account.email, str) 538 | or not validEmail(account.email) 539 | ): 540 | logging.warning( 541 | f"[CREDENTIALS] Invalid email '{account.get('email', 'No email provided')}'," 542 | f" skipping this account" 543 | ) 544 | continue 545 | if "password" not in account or not isinstance(account["password"], str): 546 | logging.warning("[CREDENTIALS] Invalid password, skipping this account") 547 | continue 548 | logging.info(f"[CREDENTIALS] Account loaded {account.email}") 549 | loadedAccounts.append(account) 550 | 551 | if not loadedAccounts: 552 | noAccountsNotice = """ 553 | [ACCOUNT] No valid account provided. 554 | [ACCOUNT] Please provide a valid account, either using command line arguments or a configuration file. 555 | [ACCOUNT] For command line, please use the following arguments (change the email and password): 556 | [ACCOUNT] `--email youremail@domain.com --password yourpassword` 557 | [ACCOUNT] For configuration file, please generate a configuration file using the `-C` argument, 558 | [ACCOUNT] then edit the generated file by replacing the email and password using yours. 559 | """ 560 | logging.error(noAccountsNotice) 561 | sys.exit(1) 562 | 563 | random.shuffle(loadedAccounts) 564 | config.accounts = loadedAccounts 565 | return config 566 | 567 | 568 | def createEmptyConfig(configPath: Path, config: Config) -> None: 569 | if configPath.is_file(): 570 | logging.error(f"[CONFIG] A file already exists at '{configPath}'") 571 | sys.exit(1) 572 | 573 | emptyConfig = Config( 574 | { 575 | "apprise": {"urls": ["discord://{WebhookID}/{WebhookToken}"]}, 576 | "accounts": [ 577 | { 578 | "email": "Your Email 1", 579 | "password": "Your Password 1", 580 | "totp": "0123 4567 89ab cdef", 581 | "proxy": "http://user:pass@host1:port", 582 | }, 583 | { 584 | "email": "Your Email 2", 585 | "password": "Your Password 2", 586 | "totp": "0123 4567 89ab cdef", 587 | "proxy": "http://user:pass@host2:port", 588 | }, 589 | ], 590 | } 591 | ) 592 | with open(configPath, "w", encoding="utf-8") as configFile: 593 | yaml.dump((emptyConfig | config).toDict(), configFile) 594 | print(f"A configuration file was created at '{configPath}'") 595 | sys.exit() 596 | 597 | 598 | def resetBot(): 599 | """ 600 | Delete the session folder and temporary files and kill all chrome processes. 601 | """ 602 | 603 | sessionPath = getProjectRoot() / "sessions" 604 | if sessionPath.exists(): 605 | print(f"Deleting sessions folder '{sessionPath}'") 606 | shutil.rmtree(sessionPath) 607 | 608 | filesToDeletePaths = ( 609 | getProjectRoot() / "google_trends.bak", 610 | getProjectRoot() / "google_trends.dat", 611 | getProjectRoot() / "google_trends.dir", 612 | getProjectRoot() / "logs" / "previous_points_data.json", 613 | ) 614 | for path in filesToDeletePaths: 615 | print(f"Deleting file '{path}'") 616 | path.unlink(missing_ok=True) 617 | 618 | for proc in psutil.process_iter(["pid", "name"]): 619 | if proc.info["name"] == "chrome.exe": 620 | proc.kill() 621 | 622 | print("All chrome processes killed") 623 | sys.exit() 624 | 625 | 626 | def loadConfig(configFilename="config.yaml") -> Config: 627 | args = argumentParser() 628 | if args.config: 629 | configFile = Path(args.config) 630 | else: 631 | configFile = getProjectRoot() / configFilename 632 | 633 | args_config = commandLineArgumentsAsConfig(args) 634 | 635 | if args.create_config: 636 | createEmptyConfig(configFile, args_config) 637 | 638 | if args.reset: 639 | resetBot() 640 | 641 | config = DEFAULT_CONFIG | Config.fromYaml(configFile) | args_config 642 | 643 | if config.rtfr: 644 | print("Please read the README.md file before using this script. Exiting.") 645 | sys.exit() 646 | 647 | return config 648 | 649 | 650 | def initApprise() -> Apprise: 651 | apprise = Apprise() 652 | 653 | urls = [] 654 | if CONFIG.apprise.enabled: 655 | urls: list[str] = CONFIG.apprise.urls 656 | if not urls: 657 | logging.info("No apprise urls found, not sending notification") 658 | 659 | apprise.add(urls) 660 | return apprise 661 | 662 | 663 | def getAnswerCode(key: str, string: str) -> str: 664 | t = sum(ord(string[i]) for i in range(len(string))) 665 | t += int(key[-2:], 16) 666 | return str(t) 667 | 668 | 669 | def formatNumber(number, num_decimals=2) -> str: 670 | return pylocale.format_string(f"%10.{num_decimals}f", number, grouping=True).strip() 671 | 672 | 673 | def getBrowserConfig(sessionPath: Path) -> dict | None: 674 | configFile = sessionPath / "config.json" 675 | if not configFile.exists(): 676 | return None 677 | with open(configFile, encoding="utf-8") as f: 678 | return json.load(f) 679 | 680 | 681 | def saveBrowserConfig(sessionPath: Path, config: dict) -> None: 682 | configFile = sessionPath / "config.json" 683 | with open(configFile, "w", encoding="utf-8") as f: 684 | json.dump(config, f) 685 | 686 | 687 | from typing import TypeVar 688 | 689 | T = TypeVar("T", bound=Session) 690 | 691 | 692 | def makeRequestsSession(session: T = requests.session()) -> T: 693 | retry = Retry( 694 | total=CONFIG.retries.max, 695 | backoff_factor=CONFIG.get("retries.backoff-factor"), 696 | status_forcelist=[ 697 | 500, 698 | 502, 699 | 503, 700 | 504, 701 | ], 702 | ) 703 | session.mount( 704 | "https://", HTTPAdapter(max_retries=retry) 705 | ) # See https://stackoverflow.com/a/35504626/4164390 to finetune 706 | session.mount( 707 | "http://", HTTPAdapter(max_retries=retry) 708 | ) # See https://stackoverflow.com/a/35504626/4164390 to finetune 709 | return session 710 | 711 | 712 | def cooldown() -> None: 713 | if sys.gettrace(): 714 | logging.info("[DEBUGGER] Debugger is attached, skipping cooldown.") 715 | return 716 | 717 | cooldownTime = random.randint(CONFIG.cooldown.min, CONFIG.cooldown.max) 718 | logging.info(f"[COOLDOWN] Waiting for {cooldownTime} seconds") 719 | time.sleep(cooldownTime) 720 | 721 | 722 | def isValidCountryCode(countryCode: str) -> bool: 723 | """ 724 | Verifies if the given country code is a valid alpha-2 code with or without a region. 725 | 726 | Args: 727 | countryCode (str): The country code to verify. 728 | 729 | Returns: 730 | bool: True if the country code is valid, False otherwise. 731 | """ 732 | if "-" in countryCode: 733 | country, region = countryCode.split("-") 734 | else: 735 | country = countryCode 736 | region = None 737 | 738 | # Check if the country part is a valid alpha-2 code 739 | if not pycountry.countries.get(alpha_2=country): 740 | return False 741 | 742 | # If region is provided, check if it is a valid region code 743 | if region and not pycountry.subdivisions.get(code=f"{country}-{region}"): 744 | return False 745 | 746 | return True 747 | 748 | 749 | def isValidLanguageCode(languageCode: str) -> bool: 750 | """ 751 | Verifies if the given language code is a valid ISO 639-1 or ISO 639-3 code, 752 | and optionally checks the region if provided. 753 | 754 | Args: 755 | languageCode (str): The language code to verify. 756 | 757 | Returns: 758 | bool: True if the language code is valid, False otherwise. 759 | """ 760 | if "-" in languageCode: 761 | language, region = languageCode.split("-") 762 | else: 763 | language = languageCode 764 | region = None 765 | 766 | # Check if the language part is a valid ISO 639-1 or ISO 639-3 code 767 | if not ( 768 | pycountry.languages.get(alpha_2=language) 769 | or pycountry.languages.get(alpha_3=language) 770 | ): 771 | return False 772 | 773 | # If region is provided, check if it is a valid country code 774 | if region and not pycountry.countries.get(alpha_2=region): 775 | return False 776 | 777 | return True 778 | 779 | 780 | def getLanguageCountry() -> tuple[str, str]: 781 | country = CONFIG.browser.geolocation 782 | language = CONFIG.browser.language 783 | 784 | if country and not isValidCountryCode(country): 785 | logging.warning( 786 | f"Invalid country code {country}, attempting to determine country code from IP" 787 | ) 788 | 789 | ipapiLocation = None 790 | if not country or not isValidCountryCode(country): 791 | try: 792 | ipapiLocation = ipapi.location() 793 | country = ipapiLocation["country"] 794 | regionCode = ipapiLocation["region_code"] 795 | if regionCode: 796 | country = country + "-" + regionCode 797 | assert isValidCountryCode(country) 798 | except RateLimited: 799 | logging.warning("Rate limited by ipapi") 800 | 801 | if language and not isValidLanguageCode(language): 802 | logging.warning( 803 | f"Invalid language code {language}, attempting to determine language code from IP" 804 | ) 805 | 806 | if not language or not isValidLanguageCode(language): 807 | try: 808 | if ipapiLocation is None: 809 | ipapiLocation = ipapi.location() 810 | language = ipapiLocation["languages"].split(",")[0] 811 | assert isValidLanguageCode(language) 812 | except RateLimited: 813 | logging.warning("Rate limited by ipapi") 814 | 815 | if not language: 816 | language = "en-US" 817 | logging.warning(f"Not able to figure language returning default: {language}") 818 | 819 | if not country: 820 | country = "US" 821 | logging.warning(f"Not able to figure country returning default: {country}") 822 | 823 | return language, country 824 | 825 | 826 | # todo Could remove this functionality in favor of https://pypi.org/project/translate/ 827 | # That's assuming all activity titles are in English 828 | def load_localized_activities(language: str) -> ModuleType: 829 | try: 830 | search_module = importlib.import_module(f"localized_activities.{language}") 831 | return search_module 832 | except ModuleNotFoundError: 833 | logging.warning(f"No search queries found for language: {language}, defaulting to English (en)") 834 | return importlib.import_module("localized_activities.en") 835 | 836 | CONFIG = loadConfig() 837 | APPRISE = initApprise() 838 | LANGUAGE, COUNTRY = getLanguageCountry() 839 | localized_activities = load_localized_activities( 840 | LANGUAGE.split("-")[0] if "-" in LANGUAGE else LANGUAGE 841 | ) 842 | ACTIVITY_TITLES_TO_QUERIES = localized_activities.title_to_query 843 | IGNORED_ACTIVITIES = localized_activities.ignore 844 | --------------------------------------------------------------------------------