├── 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 |
4 |
5 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/main.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 | 
4 | 
5 | 
6 | 
7 | 
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 |
--------------------------------------------------------------------------------