diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 80b236b1..8e8b88d6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,6 +7,14 @@ assignees: '' --- +⚠ REPORTS WITHOUT THIS INFORMATION WILL NOT BE ACCEPTED! PLEASE RESPECT OTHER'S TIME! ⚠ + +Please make a proper bug report. The template is put here for a reason, edit it according to your situation. + +It's not cool to just throw lines of log without any context and comments. You have to be specific if you want people to help you. + +πŸ‘† + **Describe the bug** A clear and concise description of what the bug is. @@ -23,6 +31,24 @@ A clear and concise description of what you expected to happen. **Desktop (please complete the following information):** - OS: [e.g. Windows] - Python version [e.g. 3.x] + - Miner version + - Other relevant software versions + +**Log** +How to provide a DEBUG log: +1. Set +```py +logger_settings=LoggerSettings( + save=True, + console_level=logging.INFO, + file_level=logging.DEBUG, + less=True, +``` +in your runner script (`run.py`). + +2. Start the miner, wait for the error, then stop the miner and post the contents of the log file (`logs\username.log`) to https://gist.github.com/ and post a link here. + +3. Create another gist with your console output, just in case. Paste a link here as well. **Additional context** Add any other context about the problem here. diff --git a/.github/workflows/code-checker.yml b/.github/workflows/code-checker.yml deleted file mode 100644 index 2aaa707b..00000000 --- a/.github/workflows/code-checker.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: code-checker - -on: - push: - branches: [master] - pull_request: - -jobs: - static-check-lint: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: [3.6, 3.7, 3.8] - steps: - - name: Clone Repository - uses: actions/checkout@v3 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4.1.0 - with: - python-version: ${{ matrix.python-version }} - - - name: Install Requirements - run: | - pip3 install --upgrade pip - pip3 install pyflakes - pip3 install black - pip3 install isort - - - name: Detect errors with pyflakes - run: pyflakes TwitchChannelPointsMiner - - - name: Lint with black - run: black TwitchChannelPointsMiner --check --diff - - - name: Lint with isort - run: isort TwitchChannelPointsMiner --profile black --check --diff diff --git a/.github/workflows/deploy-docker.yml b/.github/workflows/deploy-docker.yml deleted file mode 100644 index 297b16ca..00000000 --- a/.github/workflows/deploy-docker.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: deploy-docker - -on: - push: - branches: [master] - workflow_dispatch: - -jobs: - deploy-docker: - name: Deploy Docker Hub - runs-on: ubuntu-latest - steps: - - name: Checkout source - uses: actions/checkout@v3 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2.0.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.0.0 - - - name: Login to DockerHub - uses: docker/login-action@v2.0.0 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Build and push - id: docker_build - uses: docker/build-push-action@v3.1.1 - with: - context: . - push: true - tags: | - tkdalex/twitch-channel-points-miner-v2:latest - platforms: linux/amd64,linux/arm64,linux/arm/v7 - build-args: BUILDX_QEMU_ENV=true - - # File size exceeds the maximum allowed 25000 bytes - # - name: Docker Hub Description - # uses: peter-evans/dockerhub-description@v2 - # with: - # username: ${{ secrets.DOCKER_USERNAME }} - # password: ${{ secrets.DOCKER_TOKEN }} - # repository: tkdalex/twitch-channel-points-miner-v2 - - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.gitignore b/.gitignore index ba8ac244..3b4a9abb 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,8 @@ logs/* screenshots/* htmls/* analytics/* + +# Stats +stats/settings.py +stats/*.html +stats/*.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..be31cde6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: run.py", + "type": "python", + "request": "launch", + "program": "${cwd}/run.py", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/DELETE_PYCACHE.bat b/DELETE_PYCACHE.bat new file mode 100644 index 00000000..37901f48 --- /dev/null +++ b/DELETE_PYCACHE.bat @@ -0,0 +1,5 @@ +@echo off +rmdir /s /q __pycache__ +rmdir /s /q TwitchChannelPointsMiner\__pycache__ +rmdir /s /q TwitchChannelPointsMiner\classes\__pycache__ +rmdir /s /q TwitchChannelPointsMiner\classes\entities\__pycache__ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6bb7068f..9d0a4fac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8-slim-buster +FROM python:3.11-slim-buster ARG BUILDX_QEMU_ENV @@ -6,6 +6,10 @@ WORKDIR /usr/src/app COPY ./requirements.txt ./ +ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1 + +RUN pip install --upgrade pip + RUN apt-get update RUN DEBIAN_FRONTEND=noninteractive apt-get install -qq -y --fix-missing --no-install-recommends \ gcc \ diff --git a/README.md b/README.md index 74352640..369aefac 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@ -![Twitch Channel Points Miner - v2](https://raw.githubusercontent.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/master/assets/banner.png) +![Twitch Channel Points Miner - v2](https://raw.githubusercontent.com/rdavydov/Twitch-Channel-Points-Miner-v2/master/assets/banner.png)

-License +License Python3 -PRsWelcome -GitHub Repo stars -GitHub closed issues -GitHub last commit +PRsWelcome +GitHub Repo stars +GitHub closed issues +GitHub last commit +

+ +

+Docker Version +Docker Stars +Docker Pulls +Docker Images Size AMD64 +Docker Images Size ARM64 +Docker Images Size ARMv7

**Credits** @@ -34,6 +43,7 @@ Currently, we have a lot of PRs requests opened, but the time to test and improv - [Cloning](#by-cloning-the-repository) - [pip](#pip) - [Docker](#docker) + - [Replit](#replit) - [Limits](#limits) 5. πŸ”§ [Settings](#settings) - [LoggerSettings](#loggersettings) @@ -56,13 +66,18 @@ If you want to help with this project, please leave a star 🌟 and share it wit If you want to offer me a coffee, I would be grateful ❀️ -Buy Me A Coffee +| | | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------| +|Buy Me A Coffee (rdavydov)|Buy Me A Coffee| +|🀝 rdavydov|🀝 Tkd-Alex| | | | |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------| -| Donate BTC | `36GSMYngiiXYqBMnNwYwZc8n6s67LGn4V5` | -| Donate ETH | `0x3cc331b8AB0634CCcfa3bd57E0C625F7E886cAfa` | -| Donate SOL | `pg8Z2VqMVskSEA77g5QqppaQjehGGCWJfVPw9n91AX1` | +|Donate DOGE | `DAKzncwKkpfPCm1xVU7u2pConpXwX7HS3D` _(DOGE)_ 🀝 rdavydov| +|Donate XMR | `46fzadEigE7B3kyJB6AdiccaTha3SWUdTNnE4FL6YtjCgYMASAyXGkMe1XY4iApv2VDSxBT6d8PTW3vwtNWnfu6W4g4jyJF` _(XMR)_ 🀝 rdavydov| +| Donate BTC | `36GSMYngiiXYqBMnNwYwZc8n6s67LGn4V5` 🀝 Tkd-Alex| +| Donate ETH | `0x3cc331b8AB0634CCcfa3bd57E0C625F7E886cAfa` 🀝 Tkd-Alex| +| Donate SOL | `pg8Z2VqMVskSEA77g5QqppaQjehGGCWJfVPw9n91AX1` 🀝 Tkd-Alex| If you have any issues or you want to contribute, you are welcome! But please before read the [CONTRIBUTING.md](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/blob/master/CONTRIBUTING.md) file. @@ -177,7 +192,7 @@ No browser needed. [#41](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner ``` ## How to use: -First of all please create a run.py file. You can just copy [example.py](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/blob/master/example.py) and modify it according to your needs. +First of all please create a run.py file. You can just copy [example.py](https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2/blob/master/example.py) and modify it according to your needs. ```python # -*- coding: utf-8 -*- @@ -189,7 +204,9 @@ from TwitchChannelPointsMiner.classes.Chat import ChatPresence from TwitchChannelPointsMiner.classes.Discord import Discord from TwitchChannelPointsMiner.classes.Telegram import Telegram from TwitchChannelPointsMiner.classes.Settings import Priority, Events, FollowersOrder -from TwitchChannelPointsMiner.classes.entities.Bet import Strategy, BetSettings, Condition, OutcomeKeys, FilterCondition, DelayMode +from TwitchChannelPointsMiner.classes.entities.Bet import Strategy, Condition, OutcomeKeys +from TwitchChannelPointsMiner.classes.entities.Bet import BetSettings, FilterCondition, DelayMode +from TwitchChannelPointsMiner.classes.entities.Strategy import Strategy, Condition, OutcomeKeys from TwitchChannelPointsMiner.classes.entities.Streamer import Streamer, StreamerSettings twitch_miner = TwitchChannelPointsMiner( @@ -201,9 +218,11 @@ twitch_miner = TwitchChannelPointsMiner( Priority.DROPS, # - When we don't have anymore watch streak to catch, wait until all drops are collected over the streamers Priority.ORDER # - When we have all of the drops claimed and no watch-streak available, use the order priority (POINTS_ASCENDING, POINTS_DESCEDING) ], + enable_analytics=False, # Disables Analytics if False. Disabling it significantly reduces memory consumption logger_settings=LoggerSettings( save=True, # If you want to save logs in a file (suggested) console_level=logging.INFO, # Level of logs - use logging.DEBUG for more info + console_username=False, # Adds a username to every console log line if True. Useful when you have many open consoles with different accounts file_level=logging.DEBUG, # Level of logs - If you think the log file it's too big, use logging.INFO emoji=True, # On Windows, we have a problem printing emoji. Set to false if you have a problem less=False, # If you think that the logs are too verbose, set this to True @@ -233,7 +252,6 @@ twitch_miner = TwitchChannelPointsMiner( bet=BetSettings( strategy=Strategy.SMART, # Choose you strategy! percentage=5, # Place the x% of your channel points - percentage_gap=20, # Gap difference between outcomesA and outcomesB (for SMART strategy) max_points=50000, # If the x percentage of your channel points is gt bet_max_points set this value stealth_mode=True, # If the calculated amount of channel points is GT the highest bet, place the highest value minus 1-2 points Issue #33 delay_mode=DelayMode.FROM_END, # When placing a bet, we will wait until `delay` seconds before the end of the timer @@ -243,7 +261,10 @@ twitch_miner = TwitchChannelPointsMiner( by=OutcomeKeys.TOTAL_USERS, # Where apply the filter. Allowed [PERCENTAGE_USERS, ODDS_PERCENTAGE, ODDS, TOP_POINTS, TOTAL_USERS, TOTAL_POINTS] where=Condition.LTE, # 'by' must be [GT, LT, GTE, LTE] than value value=800 - ) + ), + strategy_settings={ + "percentage_gap": 20 # Gap difference between outcomesA and outcomesB (for SMART stragegy) + } ) ) ) @@ -256,6 +277,8 @@ twitch_miner = TwitchChannelPointsMiner( # For example, if in the mine function you don't provide any value for 'make_prediction' but you have set it on TwitchChannelPointsMiner instance, the script will take the value from here. # If you haven't set any value even in the instance the default one will be used +#twitch_miner.analytics(host="127.0.0.1", port=5000, refresh=5, days_ago=7) # Start the Analytics web-server (replit: host="0.0.0.0") + twitch_miner.mine( [ Streamer("streamer-username01", settings=StreamerSettings(make_predictions=True , follow_raid=False , claim_drops=True , watch_streak=True , bet=BetSettings(strategy=Strategy.SMART , percentage=5 , stealth_mode=True, percentage_gap=20 , max_points=234 , filter_condition=FilterCondition(by=OutcomeKeys.TOTAL_USERS, where=Condition.LTE, value=800 ) ) )), @@ -291,7 +314,7 @@ twitch_miner.mine(followers=True, blacklist=["user1", "user2"]) # Blacklist exa ``` ### By cloning the repository -1. Clone this repository `git clone https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2` +1. Clone this repository `git clone https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2` 2. Install all the requirements `pip install -r requirements.txt` . If you have problems with requirements, make sure to have at least Python3.6. You could also try to create a _virtualenv_ and then install all the requirements ```sh pip install virtualenv @@ -302,13 +325,16 @@ pip install -r requirements.txt Start mining! `python run.py` πŸ₯³ -### pip -Install the package via pip, you will find a stable version - maybe a different version from the master branch. +### pip deprecated +Install the package via pip, you will find a stable version - maybe a different version from the master branch. - `pip install Twitch-Channel-Points-Miner-v2` -- Exceute the run.py file `python run.py` πŸ₯³ +- Exceute the run.py file `python run.py` πŸ₯³ ### Docker +#### Docker Hub +Official Docker images are on https://hub.docker.com/r/rdavidoff/twitch-channel-points-miner-v2 for `linux/amd64`, `linux/arm64` and `linux/arm/v7`. + The following file is mounted : - run.py : this is your starter script with your configuration @@ -326,7 +352,7 @@ version: "3.9" services: miner: - image: tkdalex/twitch-channel-points-miner-v2 + image: rdavidoff/twitch-channel-points-miner-v2 stdin_open: true tty: true environment: @@ -348,22 +374,44 @@ docker run \ -v $(pwd)/logs:/usr/src/app/logs \ -v $(pwd)/run.py:/usr/src/app/run.py:ro \ -p 5000:5000 \ - tkdalex/twitch-channel-points-miner-v2 + rdavidoff/twitch-channel-points-miner-v2 ``` `$(pwd)` Could not work on Windows (cmd), please use the absolute path instead, like: `/path/of/your/cookies:/usr/src/app/cookies`. + +The correct solution for Windows lies in the correct command line: `docker run -v C:\Absolute\Path\To\Twitch-Channel-Points-Miner-v2\run.py:/usr/src/app/run.py:ro rdavidoff/twitch-channel-points-miner-v2`. + +`run.py` MUST be mounted as a volume (`-v`). + If you don't mount the volume for the analytics (or cookies or logs) folder, the folder will be automatically created on the Docker container, and you will lose all the data when it is stopped. -If you don't have a cookie or It's your first time running the script, you will need to login to Twitch and start the container with `-it` args. If you need to run multiple containers you can bind different ports (only if you need also the analytics) and mount dirrent run.py file, like + +If you don't have a cookie or it's your first time running the script, you will need to login to Twitch and start the container with `-it` args. If you need to run multiple containers you can bind different ports (only if you need also the analytics) and mount dirrent run.py file, like + ```sh -docker run --name user1-v $(pwd)/user1.py:/usr/src/app/run.py:ro -p 5001:5000 tkdalex/twitch-channel-points-miner-v2 +docker run --name user1 -v $(pwd)/user1.py:/usr/src/app/run.py:ro -p 5001:5000 rdavidoff/twitch-channel-points-miner-v2 ``` ```sh -docker run --name user2-v $(pwd)/user2.py:/usr/src/app/run.py:ro -p 5002:5000 tkdalex/twitch-channel-points-miner-v2 +docker run --name user2 -v $(pwd)/user2.py:/usr/src/app/run.py:ro -p 5002:5000 rdavidoff/twitch-channel-points-miner-v2 ``` About the *Docker* version; the community has always shown great interest in the Docker version of the project. Especially [@SethFalco](https://github.com/SethFalco) ([#79](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/79)), [@KamushekDev](https://github.com/KamushekDev) ([#300](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/pull/300)), [@au-ee](https://github.com/au-ee) ([#223](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/pull/223)) they showed their ideas. I've decided to merge the PR from [@RakSrinaNa](https://github.com/RakSrinaNa) ([#343](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/pull/343)) because is one of the most active user of the project and the PR was the only one with a Continuous Integration (CI). +### Replit +Run a new Repl with the master branch: + +[![Run on Repl.it](https://replit.com/badge/github/rdavydov/Twitch-Channel-Points-Miner-v2)](https://replit.com/new/github/rdavydov/Twitch-Channel-Points-Miner-v2) + +Official Repl with description and added keep-alive functionality: https://replit.com/@rdavydov/Twitch-Channel-Points-Miner-v2 + +#### Tricks to run 24/7 on Replit for free + +1. Enable Analytics (set `host="0.0.0.0"`) +2. Note down the output URL (in most cases it is `https://Twitch-Channel-Points-Miner-v2..repl.co`) +3. Send an HTTP request to that URL every 5 minutes + +Use a service that can send HTTP requests at regular intervals, such as Uptimerobot. + ### Limits > Twitch has a limit - you can't watch more than two channels at one time. We take the first two streamers from the list as they have the highest priority. @@ -388,6 +436,7 @@ You can combine all priority but keep in mind that use `ORDER` and `POINTS_ASCEN | `save` | bool | True | If you want to save logs in file (suggested) | | `less` | bool | False | Reduce the logging format and message verbosity [#10](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/10) | | `console_level` | level | logging.INFO | Level of logs in terminal - Use logging.DEBUG for more helpful messages. | +| `console_username`| bool | False | Adds a username to every log line in the console if True. [#602](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/602)| | `file_level` | level | logging.DEBUG | Level of logs in file save - If you think the log file it's too big, use logging.INFO | | `emoji` | bool | For Windows is False else True | On Windows, we have a problem printing emoji. Set to false if you have a problem | | `colored` | bool | True | If you want to print colored text [#45](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/45) [#82](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/82) | @@ -502,16 +551,24 @@ Allowed values for `chat` are: |-------------------- |----------------- |--------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `strategy` | Strategy | SMART | Choose your strategy! See above for more info | | `percentage` | int | 5 | Place the x% of your channel points | -| `percentage_gap` | int | 20 | Gap difference between outcomesA and outcomesB (for SMART stragegy) | | `max_points` | int | 50000 | If the x percentage of your channel points is GT bet_max_points set this value | | `stealth_mode` | bool | False | If the calculated amount of channel points is GT the highest bet, place the highest value minus 1-2 points [#33](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/33) | -| `delay_mode` | DelayMode | FROM_END | Define how is calculating the waiting time before placing a bet | -| `delay` | float | 6 | Value to be used to calculate bet delay depending on `delay_mode` value | +| `delay_mode` | DelayMode | FROM_END | Define how is calculating the waiting time before placing a bet | +| `delay` | float | 6 | Value to be used to calculate bet delay depending on `delay_mode` value | +| `strategy_settings` | dict | {} | Settings specific to strategy | +| `only_doubt` | bool | False | Always bet on B (will overwrite strategy bet decision) | +### StrategySettings +| Strategy | Key | Type | Default | Description | +|-------------------- |--------------------- |----------------- |----------- |------------ | +| SMART | `percentage_gap` | int | 20 | Gap difference between outcomesA and outcomesB | +| SMART_HIGH_ODDS | `target_odd` | float | 3 | Bet that much points so the odd will be not less then `target_odd` (default 3) | +| SMART_HIGH_ODDS | `always_bet` | bool | False | Always bet minimum 10 points for stats collecting | #### Bet strategy - **MOST_VOTED**: Select the option most voted based on users count - **HIGH_ODDS**: Select the option with the highest odds +- **SMART_HIGH_ODDS**: Select the option with the highest odds if odd is not less than `target_odd`. Also prevents high bets with very high odds. - **PERCENTAGE**: Select the option with the highest percentage based on odds (It's the same that show Twitch) - Should be the same as select LOWEST_ODDS - **SMART_MONEY**: Select the option with the highest points placed. [#331](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/331) - **SMART**: If the majority in percent chose an option, then follow the other users, otherwise select the option with the highest odds @@ -522,10 +579,12 @@ Here a concrete example: - **MOST_VOTED**: 21 Users have select **'over 7.5'**, instead of 9 'under 7.5' - **HIGH_ODDS**: The highest odd is 2.27 on **'over 7.5'** vs 1.79 on 'under 7.5' +- **SMART_HIGH_ODDS**: No bet because highest odd 2.27 is lower than default target odd (3) +- - If `target_odd` set to 2.1 then bet will be (6888 / (2.1 - 1)) - 5437 = 824. Making final odds 2.1/1.9 - **PERCENTAGE**: The highest percentage is 56% for **'under 7.5'** - **SMART**: Calculate the percentage based on the users. The percentages are: 'over 7.5': 70% and 'under 7.5': 30%. If the difference between the two percentages is higher than `percentage_gap` select the highest percentage, else the highest odds. +- - In this case if percentage_gap = 20 ; 70-30 = 40 > percentage_gap, so the bot will select 'over 7.5' -In this case if percentage_gap = 20 ; 70-30 = 40 > percentage_gap, so the bot will select 'over 7.5' ### FilterCondition | Key | Type | Default | Description | |------------- |------------- |--------- |---------------------------------------------------------------------------------- | @@ -575,7 +634,7 @@ If you want you can toggle the dark theme with the dedicated checkbox. | ----------- | ---------- | | ![Light theme](https://raw.githubusercontent.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/master/assets/chart-analytics-light.png) | ![Dark theme](https://raw.githubusercontent.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/master/assets/chart-analytics-dark.png) | -For use this feature just call the `analytics` method before start mining. Read more at: [#96](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/96) +For use this feature just call the `analytics()` method before start mining. Read more at: [#96](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/96) The chart will be autofreshed each `refresh` minutes. If you want to connect from one to second machine that have that webpanel you have to use `0.0.0.0` instead of `127.0.0.1`. With the `days_ago` arg you can select how many days you want to show by default in your analytics graph. ```python from TwitchChannelPointsMiner import TwitchChannelPointsMiner @@ -584,6 +643,12 @@ twitch_miner.analytics(host="127.0.0.1", port=5000, refresh=5, days_ago=7) # A twitch_miner.mine(followers=True, blacklist=["user1", "user2"]) ``` +### `enable_analytics` option in `twitch_minerfile` toggles Analytics needed for the `analytics()` method + +Disabling Analytics significantly reduces memory consumption and saves some disk space by not creating and writing `/analytics/*.json`. + +Set this option to `True` if you need Analytics. Otherwise set this option to `False` (default value). + ## Migrating from an old repository (the original one): If you already have a `twitch-cookies.pkl` and you don't want to log in again, please create a `cookies/` folder in the current directory and then copy the .pkl file with a new name `your-twitch-username.pkl` ``` @@ -605,34 +670,50 @@ Other useful info can be founded here: You can also follow this [video tutorial](https://www.youtube.com/watch?v=0VkM7NOZkuA). ## Termux -Install the requirements +**0. Install packages to Termux** ``` -pkg install python git rust libjpeg-turbo libcrypt ndk-sysroot clang zlib` +pkg install python git rust libjpeg-turbo libcrypt ndk-sysroot clang zlib LDFLAGS="-L${PREFIX}/lib/" CFLAGS="-I${PREFIX}/include/" pip install --upgrade wheel pillow ``` -**(1 way):** Clone this repository -`git clone https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2` +**1. Clone this repository** + +`git clone https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2` -**(2 way):** Download sources from GitHub and put it into your Termux storage +**2. Go to the miner's directory** -Now you can enter the directory with our miner, type this command: `cd Twitch-Channel-Points-Miner-v2` -Configure your miner on your preferences by typing +**3. Configure your miner on your preferences by typing** + `nano example.py` -When you have configured it now we can rename it (optional): +**4. Rename file name (optional)** + `mv example.py run.py` -We have to also install dependences required to run miner: -`pip install -r requirements.txt` +**5. Install packages** +``` +pip install -r requirements.txt +pip install Twitch-Channel-Points-Miner-v2 +``` -**(3 way):** `pip install Twitch-Channel-Points-Miner-v2` +**6. Run miner!** -Now when we did everything we can run miner: `python run.py` +`python run.py` Read more at [#92](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/92) [#76](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/76) +**Note** +If you can't install `pandas`, please try: + +`MATHLIB="m" pip install pandas` + +If you can't install `cryptography`, please try: + +`export RUSTFLAGS=" -C lto=no" && export CARGO_BUILD_TARGET="$(rustc -vV | sed -n 's|host: ||p')" && pip install cryptography` + +⚠️ Installation of `pandas` and `cryptography` takes a long time. + ## Disclaimer This project comes with no guarantee or warranty. You are responsible for whatever happens from using this project. It is possible to get soft or hard banned by using this project if you are not careful. This is a personal project and is in no way affiliated with Twitch. diff --git a/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py b/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py index b20334fa..282126a1 100644 --- a/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py +++ b/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py @@ -11,7 +11,6 @@ from datetime import datetime from pathlib import Path -from TwitchChannelPointsMiner.classes.AnalyticsServer import AnalyticsServer from TwitchChannelPointsMiner.classes.Chat import ChatPresence, ThreadChat from TwitchChannelPointsMiner.classes.entities.PubsubTopic import PubsubTopic from TwitchChannelPointsMiner.classes.entities.Streamer import ( @@ -44,6 +43,8 @@ logging.getLogger("requests").setLevel(logging.ERROR) logging.getLogger("werkzeug").setLevel(logging.ERROR) logging.getLogger("irc.client").setLevel(logging.ERROR) +logging.getLogger("seleniumwire").setLevel(logging.ERROR) +logging.getLogger("websocket").setLevel(logging.ERROR) logger = logging.getLogger(__name__) @@ -53,6 +54,7 @@ class TwitchChannelPointsMiner: "username", "twitch", "claim_drops_startup", + "enable_analytics", "priority", "streamers", "events_predictions", @@ -72,6 +74,7 @@ def __init__( username: str, password: str = None, claim_drops_startup: bool = False, + enable_analytics: bool = False, # Settings for logging and selenium as you can see. priority: list = [Priority.STREAK, Priority.DROPS, Priority.ORDER], # This settings will be global shared trought Settings class @@ -79,8 +82,20 @@ def __init__( # Default values for all streamers streamer_settings: StreamerSettings = StreamerSettings(), ): - Settings.analytics_path = os.path.join(Path().absolute(), "analytics", username) - Path(Settings.analytics_path).mkdir(parents=True, exist_ok=True) + # Fixes TypeError: 'NoneType' object is not subscriptable + if not username or username == "your-twitch-username": + logger.error( + "Please edit your runner file (usually run.py) and try again.") + logger.error("No username, exiting...") + sys.exit(0) + + # Analytics switch + Settings.enable_analytics = enable_analytics + + if enable_analytics is True: + Settings.analytics_path = os.path.join( + Path().absolute(), "analytics", username) + Path(Settings.analytics_path).mkdir(parents=True, exist_ok=True) self.username = username @@ -92,7 +107,8 @@ def __init__( streamer_settings.bet.default() Settings.streamer_settings = streamer_settings - user_agent = get_user_agent("FIREFOX") + # user_agent = get_user_agent("FIREFOX") + user_agent = get_user_agent("CHROME") self.twitch = Twitch(self.username, user_agent, password) self.claim_drops_startup = claim_drops_startup @@ -115,12 +131,19 @@ def __init__( # Check for the latest version of the script current_version, github_version = check_versions() + + logger.info( + f"Twitch Channel Points Miner v2-{current_version} (fork by rdavydov)") + logger.info( + "https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2") + if github_version == "0.0.0": logger.error( "Unable to detect if you have the latest version of this script" ) elif current_version != github_version: - logger.info(f"You are running the version {current_version} of this script") + logger.info( + f"You are running the version {current_version} of this script") logger.info(f"The latest version on GitHub is: {github_version}") for sign in [signal.SIGINT, signal.SIGSEGV, signal.SIGTERM]: @@ -133,12 +156,19 @@ def analytics( refresh: int = 5, days_ago: int = 7, ): - http_server = AnalyticsServer( - host=host, port=port, refresh=refresh, days_ago=days_ago - ) - http_server.daemon = True - http_server.name = "Analytics Thread" - http_server.start() + # Analytics switch + if Settings.enable_analytics is True: + from TwitchChannelPointsMiner.classes.AnalyticsServer import AnalyticsServer + + http_server = AnalyticsServer( + host=host, port=port, refresh=refresh, days_ago=days_ago + ) + http_server.daemon = True + http_server.name = "Analytics Thread" + http_server.start() + else: + logger.error( + "Can't start analytics(), please set enable_analytics=True") def mine( self, @@ -184,7 +214,8 @@ def run( streamers_dict[username] = streamer if followers is True: - followers_array = self.twitch.get_followers(order=followers_order) + followers_array = self.twitch.get_followers( + order=followers_order) logger.info( f"Load {len(followers_array)} followers from your profile!", extra={"emoji": ":clipboard:"}, @@ -207,7 +238,8 @@ def run( if isinstance(streamers_dict[username], Streamer) is True else Streamer(username) ) - streamer.channel_id = self.twitch.get_channel_id(username) + streamer.channel_id = self.twitch.get_channel_id( + username) streamer.settings = set_default_settings( streamer.settings, Settings.streamer_settings ) @@ -249,7 +281,8 @@ def run( # If we have at least one streamer with settings = claim_drops True # Spawn a thread for sync inventory and dashboard if ( - at_least_one_value_in_settings_is(self.streamers, "claim_drops", True) + at_least_one_value_in_settings_is( + self.streamers, "claim_drops", True) is True ): self.sync_campaigns_thread = threading.Thread( @@ -275,6 +308,13 @@ def run( # Subscribe to community-points-user. Get update for points spent or gains user_id = self.twitch.twitch_login.get_user_id() + # print(f"!!!!!!!!!!!!!! USER_ID: {user_id}") + + # Fixes 'ERR_BADAUTH' + if not user_id: + logger.error("No user_id, exiting...") + self.end(0, 0) + self.ws_pool.submit( PubsubTopic( "community-points-user-v1", @@ -301,7 +341,8 @@ def run( if streamer.settings.make_predictions is True: self.ws_pool.submit( - PubsubTopic("predictions-channel-v1", streamer=streamer) + PubsubTopic("predictions-channel-v1", + streamer=streamer) ) refresh_context = time.time() @@ -318,7 +359,8 @@ def run( logger.info( f"#{index} - The last PING was sent more than 10 minutes ago. Reconnecting to the WebSocket..." ) - WebSocketsPool.handle_reconnection(self.ws_pool.ws[index]) + WebSocketsPool.handle_reconnection( + self.ws_pool.ws[index]) if ((time.time() - refresh_context) // 60) >= 30: refresh_context = time.time() diff --git a/TwitchChannelPointsMiner/__init__.py b/TwitchChannelPointsMiner/__init__.py index 420102cd..11d7d3c0 100644 --- a/TwitchChannelPointsMiner/__init__.py +++ b/TwitchChannelPointsMiner/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -__version__ = "2.1.1" +__version__ = "1.6.0" from .TwitchChannelPointsMiner import TwitchChannelPointsMiner __all__ = [ diff --git a/TwitchChannelPointsMiner/classes/Settings.py b/TwitchChannelPointsMiner/classes/Settings.py index 80624083..6190ffc7 100644 --- a/TwitchChannelPointsMiner/classes/Settings.py +++ b/TwitchChannelPointsMiner/classes/Settings.py @@ -20,7 +20,7 @@ def __str__(self): # Empty object shared between class class Settings(object): - __slots__ = ["logger", "streamer_settings"] + __slots__ = ["logger", "streamer_settings", "enable_analytics"] class Events(Enum): @@ -47,4 +47,4 @@ def __str__(self): @classmethod def get(cls, key): - return getattr(cls, str(key)) if str(key) in dir(cls) else None + return getattr(cls, str(key)) if str(key) in dir(cls) else None \ No newline at end of file diff --git a/TwitchChannelPointsMiner/classes/Twitch.py b/TwitchChannelPointsMiner/classes/Twitch.py index 8b831d1c..9f7025c8 100644 --- a/TwitchChannelPointsMiner/classes/Twitch.py +++ b/TwitchChannelPointsMiner/classes/Twitch.py @@ -9,12 +9,24 @@ import os import random import re +import string import time +from datetime import datetime from pathlib import Path -from secrets import token_hex +from secrets import choice, token_hex + +import json +import pickle +from base64 import urlsafe_b64decode + +from hashlib import sha256 +from base64 import urlsafe_b64decode import requests +from undetected_chromedriver import ChromeOptions +import seleniumwire.undetected_chromedriver.v2 as uc + from TwitchChannelPointsMiner.classes.entities.Campaign import Campaign from TwitchChannelPointsMiner.classes.entities.Drop import Drop from TwitchChannelPointsMiner.classes.Exceptions import ( @@ -28,7 +40,12 @@ Settings, ) from TwitchChannelPointsMiner.classes.TwitchLogin import TwitchLogin -from TwitchChannelPointsMiner.constants import CLIENT_ID, GQLOperations +from TwitchChannelPointsMiner.constants import ( + CLIENT_ID, + CLIENT_VERSION, + URL, + GQLOperations, +) from TwitchChannelPointsMiner.utils import ( _millify, create_chunks, @@ -37,22 +54,108 @@ logger = logging.getLogger(__name__) +HEX_CHARS = '0123456789abcdef' +difficulty_1 = 10 +subchallengeCount = 2 +platformInputs = "tp-v2-input" + + +def get_time_now() -> int: + time_now = f'{time.time()}' + return int(time_now.replace('.', '')[:13]) + + +def random_string(k=32): + return ''.join(random.choices(HEX_CHARS, k=k)) + + +def string_to_sha256(string_to: str): + return sha256(string_to.encode()).hexdigest() + + +def get_hash_difficulty(hash_string): + return 4503599627370496 / (int(f'0x{hash_string[:13]}', 16) + 1) + + +def prof_work(config, _id, work_time): + f_list = [] + difficulty = config['difficulty'] / config['subchallengeCount'] + start_string = string_to_sha256( + platformInputs + ',\x20' + str(work_time) + ',\x20' + _id) + for _ in range(config['subchallengeCount']): + d = 1 + while True: + start_string2 = string_to_sha256(str(d) + ',\x20' + start_string) + if (get_hash_difficulty(start_string2) >= difficulty): + f_list.append(d) + start_string = start_string2 + break + d += 1 + return { + 'answers': f_list, + 'finalHash': start_string + } + + +def get_kpsdk_cd(): + config = { + "platformInputs": "tp-v2-input", + "difficulty": 10, + "subchallengeCount": 2 + } + t0 = time.perf_counter() + _id = random_string() + work_time = get_time_now() - 527 + prof_of_work = prof_work(config, _id, work_time) + t1 = time.perf_counter_ns() + + duration = round(1000 * (t1 - t0)) / 1000 + return { + 'answers': prof_of_work['answers'], + 'rst': time.perf_counter_ns(), + 'st': time.perf_counter_ns(), + 'd': duration, + 'id': _id, + 'workTime': work_time, + } + class Twitch(object): - __slots__ = ["cookies_file", "user_agent", "twitch_login", "running"] + __slots__ = [ + "cookies_file", + "user_agent", + "twitch_login", + "running", + "device_id", + "integrity", + "integrity_expire", + "client_session", + "client_version", + "twilight_build_id_pattern", + ] def __init__(self, username, user_agent, password=None): cookies_path = os.path.join(Path().absolute(), "cookies") Path(cookies_path).mkdir(parents=True, exist_ok=True) self.cookies_file = os.path.join(cookies_path, f"{username}.pkl") self.user_agent = user_agent + self.device_id = "".join( + choice(string.ascii_letters + string.digits) for _ in range(32) + ) self.twitch_login = TwitchLogin( - CLIENT_ID, username, self.user_agent, password=password + CLIENT_ID, self.device_id, username, self.user_agent, password=password ) self.running = True + self.integrity = None + self.integrity_expire = 0 + self.client_session = token_hex(16) + self.client_version = CLIENT_VERSION + self.twilight_build_id_pattern = re.compile( + r"window\.__twilightBuildID=\"([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12})\";" + ) def login(self): - if os.path.isfile(self.cookies_file) is False: + if not os.path.isfile(self.cookies_file): if self.twitch_login.login_flow(): self.twitch_login.save_cookies(self.cookies_file) else: @@ -95,18 +198,27 @@ def update_stream(self, streamer): def get_spade_url(self, streamer): try: - headers = {"User-Agent": self.user_agent} - main_page_request = requests.get(streamer.streamer_url, headers=headers) + # fixes AttributeError: 'NoneType' object has no attribute 'group' + # headers = {"User-Agent": self.user_agent} + from TwitchChannelPointsMiner.constants import USER_AGENTS + # headers = {"User-Agent": USER_AGENTS["Linux"]["FIREFOX"]} + headers = {"User-Agent": USER_AGENTS["Windows"]["CHROME"]} + + main_page_request = requests.get( + streamer.streamer_url, headers=headers) response = main_page_request.text + # logger.info(response) regex_settings = "(https://static.twitchcdn.net/config/settings.*?js)" settings_url = re.search(regex_settings, response).group(1) settings_request = requests.get(settings_url, headers=headers) response = settings_request.text regex_spade = '"spade_url":"(.*?)"' - streamer.stream.spade_url = re.search(regex_spade, response).group(1) + streamer.stream.spade_url = re.search( + regex_spade, response).group(1) except requests.exceptions.RequestException as e: - logger.error(f"Something went wrong during extraction of 'spade_url': {e}") + logger.error( + f"Something went wrong during extraction of 'spade_url': {e}") def get_broadcast_id(self, streamer): json_data = copy.deepcopy(GQLOperations.WithIsStreamLiveQuery) @@ -120,7 +232,8 @@ def get_broadcast_id(self, streamer): raise StreamerIsOfflineException def get_stream_info(self, streamer): - json_data = copy.deepcopy(GQLOperations.VideoPlayerStreamInfoOverlayChannel) + json_data = copy.deepcopy( + GQLOperations.VideoPlayerStreamInfoOverlayChannel) json_data["variables"] = {"channel": streamer.username} response = self.post_gql_request(json_data) if response != {}: @@ -192,7 +305,8 @@ def update_raid(self, streamer, raid): logger.info( f"Joining raid from {streamer} to {raid.target_login}!", - extra={"emoji": ":performing_arts:", "event": Events.JOIN_RAID}, + extra={"emoji": ":performing_arts:", + "event": Events.JOIN_RAID}, ) def viewer_is_mod(self, streamer): @@ -231,7 +345,11 @@ def post_gql_request(self, json_data): headers={ "Authorization": f"OAuth {self.twitch_login.get_auth_token()}", "Client-Id": CLIENT_ID, + "Client-Integrity": self.post_integrity(), + "Client-Session-Id": self.client_session, + "Client-Version": self.update_client_version(), "User-Agent": self.user_agent, + "X-Device-Id": self.device_id, }, ) logger.debug( @@ -244,6 +362,143 @@ def post_gql_request(self, json_data): ) return {} + # Request for Integrity Token + # Twitch needs Authorization, Client-Id, X-Device-Id to generate JWT which is used for authorize gql requests + # Regenerate Integrity Token 5 minutes before expire + def post_integrity(self): + if ( + self.integrity_expire - datetime.now().timestamp() * 1000 > 5 * 60 * 1000 + and self.integrity is not None + ): + return self.integrity + try: + # is_bad_bot becomes true when I use headless mode. + HEADLESS = False + + options = uc.ChromeOptions() + if HEADLESS is True: + options.add_argument('--headless') + options.add_argument('--log-level=3') + options.add_argument('--disable-web-security') + options.add_argument('--allow-running-insecure-content') + options.add_argument('--lang=en') + options.add_argument('--no-sandbox') + options.add_argument('--disable-gpu') + # options.add_argument("--user-agent=\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36\"") + # options.add_argument("--window-size=1920,1080") + # options.set_capability("detach", True) + + driver = uc.Chrome( + options=options, use_subprocess=True # , executable_path=EXECUTABLE_PATH + ) + driver.minimize_window() + driver.get('https://www.twitch.tv/robots.txt') + cookies = pickle.load(open(self.cookies_file, "rb")) + for cookie in cookies: + driver.add_cookie(cookie) + driver.get('https://www.twitch.tv/settings/profile') + + # Print request headers + # for request in driver.requests: + # logger.info(request.url) # <--------------- Request url + # logger.info(request.headers) # <----------- Request headers + # logger.info(request.response.headers) # <-- Response headers + + # Set correct user agent + selenium_user_agent = driver.execute_script( + "return navigator.userAgent;") + + self.user_agent = selenium_user_agent + + session = requests.Session() + kpsdk_cd = json.dumps(get_kpsdk_cd()) + + for cookie in driver.get_cookies(): + session.cookies.set(cookie['name'], cookie['value'], + domain=cookie['domain']) + if cookie["name"] == "KP_UIDz": + if cookie["value"] is not None: + kpsdk_ct = cookie["value"] + else: + logger.error("Can't extract kpsdk_ct") + + headers = { + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.9,pt;q=0.8', + "Authorization": f"OAuth {self.twitch_login.get_auth_token()}", + "Client-Id": CLIENT_ID, + "Client-Session-Id": self.client_session, + "Client-Version": self.update_client_version(), + 'Origin': 'https://www.twitch.tv', + 'Referer': 'https://www.twitch.tv/', + "User-Agent": self.user_agent, + # "User-Agent": selenium_user_agent, + "X-Device-Id": self.device_id, + 'x-kpsdk-cd': kpsdk_cd, + 'x-kpsdk-ct': kpsdk_ct + } + + response = session.post( + GQLOperations.integrity_url, + json={}, + headers=headers + ) + + driver.close() + driver.quit() + + integrity_json = response.json() + + self.integrity = integrity_json.get("token", None) + # logger.info(f"integrity: {self.integrity}") + + if self.isBadBot(self.integrity) is True: + logger.error( + "Uh-oh, Twitch has detected this miner as a \"Bad Bot\"") + + self.integrity_expire = integrity_json.get("expiration", 0) + # logger.info(f"integrity_expire: {self.integrity_expire}") + return self.integrity + except requests.exceptions.RequestException as e: + logger.error(f"Error with post_integrity: {e}") + return self.integrity + + # verify the integrity token's contents for the "is_bad_bot" flag + def isBadBot(self, integrity): + stripped_token: str = self.integrity.split('.')[2] + "==" + messy_json: str = urlsafe_b64decode( + stripped_token.encode()).decode(errors="ignore") + match = re.search(r'(.+)(?<="}).+$', messy_json) + if match is None: + # raise MinerException("Unable to parse the integrity token") + logger.info("Unable to parse the integrity token. Don't worry.") + return + decoded_header = json.loads(match.group(1)) + # logger.info(f"decoded_header: {decoded_header}") + if decoded_header.get("is_bad_bot", "false") != "false": + return True + else: + return False + + def update_client_version(self): + try: + response = requests.get(URL) + if response.status_code != 200: + logger.debug( + f"Error with update_client_version: {response.status_code}" + ) + return self.client_version + matcher = re.search(self.twilight_build_id_pattern, response.text) + if not matcher: + logger.debug("Error with update_client_version: no match") + return self.client_version + self.client_version = matcher.group(1) + logger.debug(f"Client version: {self.client_version}") + return self.client_version + except requests.exceptions.RequestException as e: + logger.error(f"Error with update_client_version: {e}") + return self.client_version + def send_minute_watched_events(self, streamers, priority, chunk_size=3): while self.running: try: @@ -270,11 +525,13 @@ def send_minute_watched_events(self, streamers, priority, chunk_size=3): streamers_watching += streamers_index[:2] elif ( - prior in [Priority.POINTS_ASCENDING, Priority.POINTS_DESCEDING] + prior in [Priority.POINTS_ASCENDING, + Priority.POINTS_DESCEDING] and len(streamers_watching) < 2 ): items = [ - {"points": streamers[index].channel_points, "index": index} + {"points": streamers[index].channel_points, + "index": index} for index in streamers_index ] items = sorted( @@ -284,7 +541,8 @@ def send_minute_watched_events(self, streamers, priority, chunk_size=3): True if prior == Priority.POINTS_DESCEDING else False ), ) - streamers_watching += [item["index"] for item in items][:2] + streamers_watching += [item["index"] + for item in items][:2] elif prior == Priority.STREAK and len(streamers_watching) < 2: """ @@ -300,7 +558,8 @@ def send_minute_watched_events(self, streamers, priority, chunk_size=3): and ( streamers[index].offline_at == 0 or ( - (time.time() - streamers[index].offline_at) + (time.time() - + streamers[index].offline_at) // 60 ) > 30 @@ -326,7 +585,8 @@ def send_minute_watched_events(self, streamers, priority, chunk_size=3): ] streamers_with_multiplier = sorted( streamers_with_multiplier, - key=lambda x: streamers[x].total_points_multiplier(), + key=lambda x: streamers[x].total_points_multiplier( + ), reverse=True, ) streamers_watching += streamers_with_multiplier[:2] @@ -395,10 +655,12 @@ def send_minute_watched_events(self, streamers, priority, chunk_size=3): ) except requests.exceptions.ConnectionError as e: - logger.error(f"Error while trying to send minute watched: {e}") + logger.error( + f"Error while trying to send minute watched: {e}") self.__check_connection_handler(chunk_size) except requests.exceptions.Timeout as e: - logger.error(f"Error while trying to send minute watched: {e}") + logger.error( + f"Error while trying to send minute watched: {e}") self.__chuncked_sleep( next_iteration - time.time(), chunk_size=chunk_size @@ -407,7 +669,8 @@ def send_minute_watched_events(self, streamers, priority, chunk_size=3): if streamers_watching == []: self.__chuncked_sleep(60, chunk_size=chunk_size) except Exception: - logger.error("Exception raised in send minute watched", exc_info=True) + logger.error( + "Exception raised in send minute watched", exc_info=True) # === CHANNEL POINTS / PREDICTION === # # Load the amount of current points for a channel, check if a bonus is available @@ -425,11 +688,12 @@ def load_channel_points_context(self, streamer): streamer.activeMultipliers = community_points["activeMultipliers"] if community_points["availableClaim"] is not None: - self.claim_bonus(streamer, community_points["availableClaim"]["id"]) + self.claim_bonus( + streamer, community_points["availableClaim"]["id"]) def make_predictions(self, event): decision = event.bet.calculate(event.streamer.channel_points) - selector_index = 0 if decision["choice"] == "A" else 1 + # selector_index = 0 if decision["choice"] == "A" else 1 logger.info( f"Going to complete bet for {event}", @@ -458,7 +722,8 @@ def make_predictions(self, event): else: if decision["amount"] >= 10: logger.info( - f"Place {_millify(decision['amount'])} channel points on: {event.bet.get_outcome(selector_index)}", + # f"Place {_millify(decision['amount'])} channel points on: {event.bet.get_outcome(selector_index)}", + f"Place {_millify(decision['amount'])} channel points on: {event.bet.get_outcome(decision['choice'])}", extra={ "emoji": ":four_leaf_clover:", "event": Events.BET_GENERAL, @@ -521,7 +786,8 @@ def claim_bonus(self, streamer, claim_id): # === CAMPAIGNS / DROPS / INVENTORY === # def __get_campaign_ids_from_streamer(self, streamer): - json_data = copy.deepcopy(GQLOperations.DropsHighlightService_AvailableDrops) + json_data = copy.deepcopy( + GQLOperations.DropsHighlightService_AvailableDrops) json_data["variables"] = {"channelID": streamer.channel_id} response = self.post_gql_request(json_data) try: @@ -549,7 +815,8 @@ def __get_drops_dashboard(self, status=None): response = self.post_gql_request(GQLOperations.ViewerDropsDashboard) campaigns = response["data"]["currentUser"]["dropCampaigns"] if status is not None: - campaigns = list(filter(lambda x: x["status"] == status.upper(), campaigns)) + campaigns = list( + filter(lambda x: x["status"] == status.upper(), campaigns)) return campaigns def __get_campaigns_details(self, campaigns): @@ -558,7 +825,8 @@ def __get_campaigns_details(self, campaigns): for chunk in chunks: json_data = [] for campaign in chunk: - json_data.append(copy.deepcopy(GQLOperations.DropCampaignDetails)) + json_data.append(copy.deepcopy( + GQLOperations.DropCampaignDetails)) json_data[-1]["variables"] = { "dropID": campaign["id"], "channelLogin": f"{self.twitch_login.get_user_id()}", @@ -589,7 +857,8 @@ def __sync_campaigns(self, campaigns): campaigns[i].sync_drops( progress["timeBasedDrops"], self.claim_drop ) - campaigns[i].clear_drops() # Remove all the claimed drops + # Remove all the claimed drops + campaigns[i].clear_drops() break return campaigns @@ -599,7 +868,8 @@ def claim_drop(self, drop): ) json_data = copy.deepcopy(GQLOperations.DropsPage_ClaimDropRewards) - json_data["variables"] = {"input": {"dropInstanceID": drop.drop_instance_id}} + json_data["variables"] = { + "input": {"dropInstanceID": drop.drop_instance_id}} response = self.post_gql_request(json_data) try: # response["data"]["claimDropRewards"] can be null and respose["data"]["errors"] != [] diff --git a/TwitchChannelPointsMiner/classes/TwitchLogin.py b/TwitchChannelPointsMiner/classes/TwitchLogin.py index d257a856..f11fe808 100644 --- a/TwitchChannelPointsMiner/classes/TwitchLogin.py +++ b/TwitchChannelPointsMiner/classes/TwitchLogin.py @@ -9,20 +9,34 @@ import pickle import browser_cookie3 + import requests from TwitchChannelPointsMiner.classes.Exceptions import ( BadCredentialsException, WrongCookiesException, ) -from TwitchChannelPointsMiner.constants import GQLOperations +from TwitchChannelPointsMiner.constants import CLIENT_ID, GQLOperations logger = logging.getLogger(__name__) +"""def interceptor(request) -> str: + if ( + request.method == 'POST' + and request.url == 'https://passport.twitch.tv/protected_login' + ): + import json + body = request.body.decode('utf-8') + data = json.loads(body) + data['client_id'] = CLIENT_ID + request.body = json.dumps(data).encode('utf-8') + del request.headers['Content-Length'] + request.headers['Content-Length'] = str(len(request.body))""" class TwitchLogin(object): __slots__ = [ "client_id", + "device_id", "token", "login_check_result", "session", @@ -32,15 +46,17 @@ class TwitchLogin(object): "user_id", "email", "cookies", + "shared_cookies" ] - def __init__(self, client_id, username, user_agent, password=None): + def __init__(self, client_id, device_id, username, user_agent, password=None): self.client_id = client_id + self.device_id = device_id self.token = None self.login_check_result = False self.session = requests.session() self.session.headers.update( - {"Client-ID": self.client_id, "User-Agent": user_agent} + { "Client-ID": self.client_id, "X-Device-Id": self.device_id, "User-Agent": user_agent } ) self.username = username self.password = password @@ -48,6 +64,7 @@ def __init__(self, client_id, username, user_agent, password=None): self.email = None self.cookies = [] + self.shared_cookies = [] def login_flow(self): logger.info("You'll have to login to Twitch!") @@ -57,8 +74,9 @@ def login_flow(self): "undelete_user": False, "remember_me": True, } - - use_backup_flow = False + # login-fix + #use_backup_flow = False + use_backup_flow = True for attempt in range(0, 25): password = ( @@ -118,12 +136,21 @@ def login_flow(self): # If the user didn't load the password from run.py we can just ask for it again. break - elif err_code == 1000: + # login-fix + #elif err_code == 1000: + elif err_code in [1000, 5022, 5023, 5024]: logger.info( "Console login unavailable (CAPTCHA solving required)." ) use_backup_flow = True break + # https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2/issues/46 +# elif err_code == 5023: +# logger.error( +# "Probably an automatic temporary ban from Twitch. Please wait 24 hours till they lift the ban from the account and try again." +# ) +# use_backup_flow = True +# break else: logger.error(f"Unknown error: {login_response}") raise NotImplementedError( @@ -138,7 +165,7 @@ def login_flow(self): break if use_backup_flow: - self.set_token(self.login_flow_backup()) + self.set_token(self.login_flow_backup(password)) return self.check_login() return False @@ -148,10 +175,74 @@ def set_token(self, new_token): self.session.headers.update({"Authorization": f"Bearer {self.token}"}) def send_login_request(self, json_data): - response = self.session.post("https://passport.twitch.tv/login", json=json_data) + #response = self.session.post("https://passport.twitch.tv/protected_login", json=json_data) + response = self.session.post("https://passport.twitch.tv/login", json=json_data, headers={ + 'Accept': 'application/vnd.twitchtv.v3+json', + 'Accept-Encoding': 'gzip', + 'Accept-Language': 'en-US', + 'Content-Type': 'application/json; charset=UTF-8', + 'Host': 'passport.twitch.tv' + },) return response.json() - def login_flow_backup(self): + def login_flow_backup(self, password = None): + """Backup OAuth Selenium login + from undetected_chromedriver import ChromeOptions + import seleniumwire.undetected_chromedriver.v2 as uc + from selenium.webdriver.common.by import By + from time import sleep + + HEADLESS = False + + options = uc.ChromeOptions() + if HEADLESS is True: + options.add_argument('--headless') + options.add_argument('--log-level=3') + options.add_argument('--disable-web-security') + options.add_argument('--allow-running-insecure-content') + options.add_argument('--lang=en') + options.add_argument('--no-sandbox') + options.add_argument('--disable-gpu') + #options.add_argument("--user-agent=\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36\"") + #options.add_argument("--window-size=1920,1080") + #options.set_capability("detach", True) + + logger.info('Now a browser window will open, it will login with your data.') + driver = uc.Chrome( + options=options, use_subprocess=True#, executable_path=EXECUTABLE_PATH + ) + driver.request_interceptor = interceptor + driver.get('https://www.twitch.tv/login') + + driver.find_element(By.ID, 'login-username').send_keys(self.username) + driver.find_element(By.ID, 'password-input').send_keys(password) + sleep(0.3) + driver.execute_script( + 'document.querySelector("#root > div > div.scrollable-area > div.simplebar-scroll-content > div > div > div > div.Layout-sc-nxg1ff-0.gZaqky > form > div > div:nth-child(3) > button > div > div").click()' + ) + + logger.info( + 'Enter your verification code in the browser and wait for the Twitch website to load, then press Enter here.' + ) + input() + + logger.info("Extracting cookies...") + self.cookies = driver.get_cookies() + #print(self.cookies) + #driver.close() + driver.quit() + self.username = self.get_cookie_value("login") + #print(f"self.username: {self.username}") + + if not self.username: + logger.error("Couldn't extract login, probably bad cookies.") + return False + + return self.get_cookie_value("auth-token")""" + + #logger.error("Backup login flow is not available. Use a VPN or wait a while to avoid the CAPTCHA.") + #return False + """Backup OAuth login flow in case manual captcha solving is required""" browser = input( "What browser do you use? Chrome (1), Firefox (2), Other (3): " @@ -168,10 +259,13 @@ def login_flow_backup(self): if browser == "1": # chrome cookie_jar = browser_cookie3.chrome(domain_name=twitch_domain) else: - cookie_jar = browser_cookie3.firefox(domain_name=twitch_domain) + cookie_jar = browser_cookie3.firefox(domain_name=twitch_domain) + #logger.info(f"cookie_jar: {cookie_jar}") cookies_dict = requests.utils.dict_from_cookiejar(cookie_jar) + #logger.info(f"cookies_dict: {cookies_dict}") self.username = cookies_dict.get("login") - return cookies_dict.get("auth-token") + self.shared_cookies = cookies_dict + return cookies_dict.get("auth-token") def check_login(self): if self.login_check_result: @@ -183,15 +277,20 @@ def check_login(self): return self.login_check_result def save_cookies(self, cookies_file): - cookies_dict = self.session.cookies.get_dict() - cookies_dict["auth-token"] = self.token - if "persistent" not in cookies_dict: # saving user id cookies - cookies_dict["persistent"] = self.user_id + #cookies_dict = self.session.cookies.get_dict() + #print(f"cookies_dict2pickle: {cookies_dict}") + #cookies_dict["auth-token"] = self.token + #if "persistent" not in cookies_dict: # saving user id cookies + # cookies_dict["persistent"] = self.user_id + # old way saves only 'auth-token' and 'persistent' self.cookies = [] + cookies_dict = self.shared_cookies + #print(f"cookies_dict2pickle: {cookies_dict}") for cookie_name, value in cookies_dict.items(): self.cookies.append({"name": cookie_name, "value": value}) - pickle.dump(self.cookies, open(cookies_file, "wb")) + #print(f"cookies2pickle: {self.cookies}") + pickle.dump(self.cookies, open(cookies_file, "wb")) def get_cookie_value(self, key): for cookie in self.cookies: diff --git a/TwitchChannelPointsMiner/classes/WebSocketsPool.py b/TwitchChannelPointsMiner/classes/WebSocketsPool.py index 43ad9e86..22c35cde 100644 --- a/TwitchChannelPointsMiner/classes/WebSocketsPool.py +++ b/TwitchChannelPointsMiner/classes/WebSocketsPool.py @@ -9,7 +9,7 @@ from TwitchChannelPointsMiner.classes.entities.EventPrediction import EventPrediction from TwitchChannelPointsMiner.classes.entities.Message import Message from TwitchChannelPointsMiner.classes.entities.Raid import Raid -from TwitchChannelPointsMiner.classes.Settings import Events +from TwitchChannelPointsMiner.classes.Settings import Events, Settings from TwitchChannelPointsMiner.classes.TwitchWebSocket import TwitchWebSocket from TwitchChannelPointsMiner.constants import WEBSOCKET from TwitchChannelPointsMiner.utils import ( @@ -19,7 +19,6 @@ logger = logging.getLogger(__name__) - class WebSocketsPool: __slots__ = ["ws", "twitch", "streamers", "events_predictions"] @@ -178,11 +177,13 @@ def on_message(ws, message): if message.type in ["points-earned", "points-spent"]: balance = message.data["balance"]["balance"] ws.streamers[streamer_index].channel_points = balance - ws.streamers[streamer_index].persistent_series( - event_type=message.data["point_gain"]["reason_code"] - if message.type == "points-earned" - else "Spent" - ) + # Analytics switch + if Settings.enable_analytics is True: + ws.streamers[streamer_index].persistent_series( + event_type=message.data["point_gain"]["reason_code"] + if message.type == "points-earned" + else "Spent" + ) if message.type == "points-earned": earned = message.data["point_gain"]["total_points"] @@ -198,9 +199,11 @@ def on_message(ws, message): ws.streamers[streamer_index].update_history( reason_code, earned ) - ws.streamers[streamer_index].persistent_annotations( - reason_code, f"+{earned} - {reason_code}" - ) + # Analytics switch + if Settings.enable_analytics is True: + ws.streamers[streamer_index].persistent_annotations( + reason_code, f"+{earned} - {reason_code}" + ) elif message.type == "claim-available": ws.twitch.claim_bonus( ws.streamers[streamer_index], @@ -358,16 +361,20 @@ def on_message(ws, message): ) if event_prediction.result["type"] != "LOSE": - ws.streamers[streamer_index].persistent_annotations( - event_prediction.result["type"], - f"{ws.events_predictions[event_id].title}", - ) + # Analytics switch + if Settings.enable_analytics is True: + ws.streamers[streamer_index].persistent_annotations( + event_prediction.result["type"], + f"{ws.events_predictions[event_id].title}", + ) elif message.type == "prediction-made": event_prediction.bet_confirmed = True - ws.streamers[streamer_index].persistent_annotations( - "PREDICTION_MADE", - f"Decision: {event_prediction.bet.decision['choice']} - {event_prediction.title}", - ) + # Analytics switch + if Settings.enable_analytics is True: + ws.streamers[streamer_index].persistent_annotations( + "PREDICTION_MADE", + f"Decision: {event_prediction.bet.decision['choice']} - {event_prediction.title}", + ) except Exception: logger.error( f"Exception raised for topic: {message.topic} and message: {message}", @@ -382,4 +389,4 @@ def on_message(ws, message): WebSocketsPool.handle_reconnection(ws) elif response["type"] == "PONG": - ws.last_pong = time.time() + ws.last_pong = time.time() \ No newline at end of file diff --git a/TwitchChannelPointsMiner/classes/entities/Bet.py b/TwitchChannelPointsMiner/classes/entities/Bet.py index b2d9c595..60d4a242 100644 --- a/TwitchChannelPointsMiner/classes/entities/Bet.py +++ b/TwitchChannelPointsMiner/classes/entities/Bet.py @@ -1,21 +1,15 @@ import copy from enum import Enum, auto -from random import uniform from millify import millify -from TwitchChannelPointsMiner.utils import char_decision_as_index, float_round - - -class Strategy(Enum): - MOST_VOTED = auto() - HIGH_ODDS = auto() - PERCENTAGE = auto() - SMART_MONEY = auto() - SMART = auto() - - def __str__(self): - return self.name +from TwitchChannelPointsMiner.classes.entities.Strategy import ( + OutcomeKeys, + Strategy, + StrategySettings, +) +#from TwitchChannelPointsMiner.utils import char_decision_as_index, float_round +from TwitchChannelPointsMiner.utils import float_round class Condition(Enum): @@ -28,20 +22,6 @@ def __str__(self): return self.name -class OutcomeKeys(object): - # Real key on Bet dict [''] - PERCENTAGE_USERS = "percentage_users" - ODDS_PERCENTAGE = "odds_percentage" - ODDS = "odds" - TOP_POINTS = "top_points" - # Real key on Bet dict [''] - Sum() - TOTAL_USERS = "total_users" - TOTAL_POINTS = "total_points" - # This key does not exist - DECISION_USERS = "decision_users" - DECISION_POINTS = "decision_points" - - class DelayMode(Enum): FROM_START = auto() FROM_END = auto() @@ -71,43 +51,46 @@ class BetSettings(object): __slots__ = [ "strategy", "percentage", - "percentage_gap", "max_points", + "only_doubt", "minimum_points", "stealth_mode", "filter_condition", "delay", "delay_mode", + "strategy_settings", ] def __init__( self, strategy: Strategy = None, percentage: int = None, - percentage_gap: int = None, max_points: int = None, + only_doubt: bool = None, minimum_points: int = None, stealth_mode: bool = None, filter_condition: FilterCondition = None, delay: float = None, delay_mode: DelayMode = None, + strategy_settings: StrategySettings = None, ): self.strategy = strategy self.percentage = percentage - self.percentage_gap = percentage_gap self.max_points = max_points + self.only_doubt = only_doubt self.minimum_points = minimum_points self.stealth_mode = stealth_mode self.filter_condition = filter_condition self.delay = delay self.delay_mode = delay_mode + self.strategy_settings = StrategySettings( + strategy=strategy, **strategy_settings + ).get_instance() def default(self): self.strategy = self.strategy if self.strategy is not None else Strategy.SMART self.percentage = self.percentage if self.percentage is not None else 5 - self.percentage_gap = ( - self.percentage_gap if self.percentage_gap is not None else 20 - ) + self.only_doubt = self.only_doubt if self.only_doubt is not None else False self.max_points = self.max_points if self.max_points is not None else 50000 self.minimum_points = ( self.minimum_points if self.minimum_points is not None else 0 @@ -119,9 +102,12 @@ def default(self): self.delay_mode = ( self.delay_mode if self.delay_mode is not None else DelayMode.FROM_END ) + self.strategy_settings = ( + self.strategy_settings if self.strategy_settings is not None else {} + ) def __repr__(self): - return f"BetSettings(strategy={self.strategy}, percentage={self.percentage}, percentage_gap={self.percentage_gap}, max_points={self.max_points}, minimum_points={self.minimum_points}, stealth_mode={self.stealth_mode})" + return f"BetSettings(strategy={self.strategy}, percentage={self.percentage}, max_points={self.max_points}, minimum_points={self.minimum_points}, stealth_mode={self.stealth_mode})" class Bet(object): @@ -154,30 +140,32 @@ def update_outcomes(self, outcomes): top_points = outcomes[index]["top_predictors"][0]["points"] self.outcomes[index][OutcomeKeys.TOP_POINTS] = top_points - self.total_users = ( - self.outcomes[0][OutcomeKeys.TOTAL_USERS] - + self.outcomes[1][OutcomeKeys.TOTAL_USERS] - ) - self.total_points = ( - self.outcomes[0][OutcomeKeys.TOTAL_POINTS] - + self.outcomes[1][OutcomeKeys.TOTAL_POINTS] - ) + # Inefficient, but otherwise outcomekeys are represented wrong + self.total_points = 0 + self.total_users = 0 + for index in range(0, len(self.outcomes)): + self.total_users += self.outcomes[index][OutcomeKeys.TOTAL_USERS] + self.total_points += self.outcomes[index][OutcomeKeys.TOTAL_POINTS] if ( self.total_users > 0 - and self.outcomes[0][OutcomeKeys.TOTAL_POINTS] > 0 - and self.outcomes[1][OutcomeKeys.TOTAL_POINTS] > 0 + and self.total_points > 0 ): for index in range(0, len(self.outcomes)): self.outcomes[index][OutcomeKeys.PERCENTAGE_USERS] = float_round( - (100 * self.outcomes[index][OutcomeKeys.TOTAL_USERS]) - / self.total_users + (100 * self.outcomes[index][OutcomeKeys.TOTAL_USERS]) / self.total_users ) self.outcomes[index][OutcomeKeys.ODDS] = float_round( - self.total_points / self.outcomes[index][OutcomeKeys.TOTAL_POINTS] + #self.total_points / max(self.outcomes[index][OutcomeKeys.TOTAL_POINTS], 1) + 0 + if self.outcomes[index][OutcomeKeys.TOTAL_POINTS] == 0 + else self.total_points / self.outcomes[index][OutcomeKeys.TOTAL_POINTS] ) self.outcomes[index][OutcomeKeys.ODDS_PERCENTAGE] = float_round( - 100 / self.outcomes[index][OutcomeKeys.ODDS] + #100 / max(self.outcomes[index][OutcomeKeys.ODDS], 1) + 0 + if self.outcomes[index][OutcomeKeys.ODDS] == 0 + else 100 / self.outcomes[index][OutcomeKeys.ODDS] ) self.__clear_outcomes() @@ -186,7 +174,8 @@ def __repr__(self): return f"Bet(total_users={millify(self.total_users)}, total_points={millify(self.total_points)}), decision={self.decision})\n\t\tOutcome A({self.get_outcome(0)})\n\t\tOutcome B({self.get_outcome(1)})" def get_decision(self, parsed=False): - decision = self.outcomes[0 if self.decision["choice"] == "A" else 1] + #decision = self.outcomes[0 if self.decision["choice"] == "A" else 1] + decision = self.outcomes[self.decision["choice"]] return decision if parsed is False else Bet.__parse_outcome(decision) @staticmethod @@ -221,82 +210,11 @@ def __clear_outcomes(self): if key not in self.outcomes[index]: self.outcomes[index][key] = 0 - def __return_choice(self, key) -> str: - return "A" if self.outcomes[0][key] > self.outcomes[1][key] else "B" - def skip(self) -> bool: - if self.settings.filter_condition is not None: - # key == by , condition == where - key = self.settings.filter_condition.by - condition = self.settings.filter_condition.where - value = self.settings.filter_condition.value - - fixed_key = ( - key - if key not in [OutcomeKeys.DECISION_USERS, OutcomeKeys.DECISION_POINTS] - else key.replace("decision", "total") - ) - if key in [OutcomeKeys.TOTAL_USERS, OutcomeKeys.TOTAL_POINTS]: - compared_value = ( - self.outcomes[0][fixed_key] + self.outcomes[1][fixed_key] - ) - else: - outcome_index = char_decision_as_index(self.decision["choice"]) - compared_value = self.outcomes[outcome_index][fixed_key] - - # Check if condition is satisfied - if condition == Condition.GT: - if compared_value > value: - return False, compared_value - elif condition == Condition.LT: - if compared_value < value: - return False, compared_value - elif condition == Condition.GTE: - if compared_value >= value: - return False, compared_value - elif condition == Condition.LTE: - if compared_value <= value: - return False, compared_value - return True, compared_value # Else skip the bet - else: - return False, 0 # Default don't skip the bet + return Strategy(self.outcomes, self.settings).get_instance().skip() def calculate(self, balance: int) -> dict: - self.decision = {"choice": None, "amount": 0, "id": None} - if self.settings.strategy == Strategy.MOST_VOTED: - self.decision["choice"] = self.__return_choice(OutcomeKeys.TOTAL_USERS) - elif self.settings.strategy == Strategy.HIGH_ODDS: - self.decision["choice"] = self.__return_choice(OutcomeKeys.ODDS) - elif self.settings.strategy == Strategy.PERCENTAGE: - self.decision["choice"] = self.__return_choice(OutcomeKeys.ODDS_PERCENTAGE) - elif self.settings.strategy == Strategy.SMART_MONEY: - self.decision["choice"] = self.__return_choice(OutcomeKeys.TOP_POINTS) - elif self.settings.strategy == Strategy.SMART: - difference = abs( - self.outcomes[0][OutcomeKeys.PERCENTAGE_USERS] - - self.outcomes[1][OutcomeKeys.PERCENTAGE_USERS] - ) - self.decision["choice"] = ( - self.__return_choice(OutcomeKeys.ODDS) - if difference < self.settings.percentage_gap - else self.__return_choice(OutcomeKeys.TOTAL_USERS) - ) - - if self.decision["choice"] is not None: - index = char_decision_as_index(self.decision["choice"]) - self.decision["id"] = self.outcomes[index]["id"] - self.decision["amount"] = min( - int(balance * (self.settings.percentage / 100)), - self.settings.max_points, - ) - if ( - self.settings.stealth_mode is True - and self.decision["amount"] - >= self.outcomes[index][OutcomeKeys.TOP_POINTS] - ): - reduce_amount = uniform(1, 5) - self.decision["amount"] = ( - self.outcomes[index][OutcomeKeys.TOP_POINTS] - reduce_amount - ) - self.decision["amount"] = int(self.decision["amount"]) + self.decision = ( + Strategy(self.outcomes, self.settings).get_instance().calculate(balance) + ) return self.decision diff --git a/TwitchChannelPointsMiner/classes/entities/Strategy.py b/TwitchChannelPointsMiner/classes/entities/Strategy.py new file mode 100644 index 00000000..ed917699 --- /dev/null +++ b/TwitchChannelPointsMiner/classes/entities/Strategy.py @@ -0,0 +1,155 @@ +from enum import Enum, auto +from importlib import import_module +from random import uniform + +def char_decision_as_index(char): + return 0 if char == "A" else 1 + + +class Condition(Enum): + GT = auto() + LT = auto() + GTE = auto() + LTE = auto() + + def __str__(self): + return self.name + + +class OutcomeKeys(object): + # Real key on Bet dict [''] + PERCENTAGE_USERS = "percentage_users" + ODDS_PERCENTAGE = "odds_percentage" + ODDS = "odds" + TOP_POINTS = "top_points" + # Real key on Bet dict [''] - Sum() + TOTAL_USERS = "total_users" + TOTAL_POINTS = "total_points" + # This key does not exist + DECISION_USERS = "decision_users" + DECISION_POINTS = "decision_points" + + +class Strategy(object): + MOST_VOTED = "MostVoted" + HIGH_ODDS = "HighOdds" + SMART_HIGH_ODDS = "SmartHighOdds" + PERCENTAGE = "Percentage" + SMART = "Smart" + + def __init__(self, outcomes: list, settings: object): + self.outcomes = outcomes + self.decision: dict = {} + self.settings = settings + + def get_instance(self): + strategy_module = import_module( + f".{self.settings.strategy}", package=f"{__package__}.strategies" + ) + subclass = getattr(strategy_module, self.settings.strategy) + return subclass(self.outcomes, self.settings) + + def return_choice(self, key) -> str: + return "A" if self.outcomes[0][key] > self.outcomes[1][key] else "B" + + def calculate_before(self): + self.decision = {"choice": None, "amount": 0, "id": None} + + def calculate_middle(self): + pass + + def calculate_after(self, balance: int): + if self.settings.only_doubt: + self.decision["choice"] = "B" + if self.decision["choice"] is not None: + index = char_decision_as_index(self.decision["choice"]) + self.decision["id"] = self.outcomes[index]["id"] + amounts = [ + self.decision["amount"], + int(balance * (self.settings.percentage / 100)), + self.settings.max_points, + ] + self.decision["amount"] = min(x for x in amounts if x != 0) + if ( + self.settings.stealth_mode is True + and self.decision["amount"] + >= self.outcomes[index][OutcomeKeys.TOP_POINTS] + ): + reduce_amount = uniform(1, 5) + # check by + # grep -r --include=*.log "Bet won't be placed as the amount -[0-9] is less than the minimum required 10" . + self.decision["amount"] = ( + self.outcomes[index][OutcomeKeys.TOP_POINTS] - reduce_amount + ) + if self.decision["amount"] < 10: self.decision["amount"] = 10 + self.decision["amount"] = int(self.decision["amount"]) + + def calculate(self, balance: int) -> dict: + self.calculate_before() + self.calculate_middle() + self.calculate_after(balance) + return self.decision + + def skip_before(self): + pass + + def skip_middle(self): + pass + + def skip_after(self): + if self.settings.filter_condition is not None: + self.decision.setdefault("choice", None) # for tests + # key == by , condition == where + key = self.settings.filter_condition.by + condition = self.settings.filter_condition.where + value = self.settings.filter_condition.value + + fixed_key = ( + key + if key not in [OutcomeKeys.DECISION_USERS, OutcomeKeys.DECISION_POINTS] + else key.replace("decision", "total") + ) + if key in [OutcomeKeys.TOTAL_USERS, OutcomeKeys.TOTAL_POINTS]: + compared_value = ( + self.outcomes[0][fixed_key] + self.outcomes[1][fixed_key] + ) + else: + outcome_index = char_decision_as_index(self.decision["choice"]) + compared_value = self.outcomes[outcome_index][fixed_key] + + # Check if condition is satisfied + if condition == Condition.GT: + if compared_value > value: + return False, compared_value + elif condition == Condition.LT: + if compared_value < value: + return False, compared_value + elif condition == Condition.GTE: + if compared_value >= value: + return False, compared_value + elif condition == Condition.LTE: + if compared_value <= value: + return False, compared_value + return True, compared_value # Else skip the bet + else: + return False, 0 # Default don't skip the bet + + def skip(self) -> bool: + skip_results = [self.skip_before(), self.skip_middle(), self.skip_after()] + return next(item for item in skip_results if item is not None) + + def __str__(self): + return self.name + + +class StrategySettings(object): + def __init__(self, strategy: Strategy = None, **kwargs): + strategy_module = import_module( + f".{strategy}", package=f"{__package__}.strategies" + ) + subclass = getattr(strategy_module, f"{strategy}Settings") + self.instance = subclass(**kwargs) + self.instance.default() + + def get_instance(self): + return self.instance diff --git a/TwitchChannelPointsMiner/classes/entities/strategies/HighOdds.py b/TwitchChannelPointsMiner/classes/entities/strategies/HighOdds.py new file mode 100644 index 00000000..4fdaa7a5 --- /dev/null +++ b/TwitchChannelPointsMiner/classes/entities/strategies/HighOdds.py @@ -0,0 +1,6 @@ +from TwitchChannelPointsMiner.classes.entities.Strategy import OutcomeKeys, Strategy + + +class HighOdds(Strategy): + def calculate_middle(self): + self.decision["choice"] = self.return_choice(OutcomeKeys.ODDS) diff --git a/TwitchChannelPointsMiner/classes/entities/strategies/MostVoted.py b/TwitchChannelPointsMiner/classes/entities/strategies/MostVoted.py new file mode 100644 index 00000000..f455a958 --- /dev/null +++ b/TwitchChannelPointsMiner/classes/entities/strategies/MostVoted.py @@ -0,0 +1,6 @@ +from TwitchChannelPointsMiner.classes.entities.Strategy import OutcomeKeys, Strategy + + +class MostVoted(Strategy): + def calculate_middle(self): + self.decision["choice"] = self.return_choice(OutcomeKeys.TOTAL_USERS) diff --git a/TwitchChannelPointsMiner/classes/entities/strategies/Percentage.py b/TwitchChannelPointsMiner/classes/entities/strategies/Percentage.py new file mode 100644 index 00000000..7f2bb292 --- /dev/null +++ b/TwitchChannelPointsMiner/classes/entities/strategies/Percentage.py @@ -0,0 +1,6 @@ +from TwitchChannelPointsMiner.classes.entities.Strategy import OutcomeKeys, Strategy + + +class Percentage(Strategy): + def calculate_middle(self): + self.decision["choice"] = self.return_choice(OutcomeKeys.ODDS_PERCENTAGE) diff --git a/TwitchChannelPointsMiner/classes/entities/strategies/Smart.py b/TwitchChannelPointsMiner/classes/entities/strategies/Smart.py new file mode 100644 index 00000000..773d7d80 --- /dev/null +++ b/TwitchChannelPointsMiner/classes/entities/strategies/Smart.py @@ -0,0 +1,31 @@ +from TwitchChannelPointsMiner.classes.entities.Strategy import OutcomeKeys, Strategy + + +class Smart(Strategy): + def calculate_middle(self): + difference = abs( + self.outcomes[0][OutcomeKeys.PERCENTAGE_USERS] + - self.outcomes[1][OutcomeKeys.PERCENTAGE_USERS] + ) + self.decision["choice"] = ( + self.return_choice(OutcomeKeys.ODDS) + if difference < self.settings.strategy_settings.percentage_gap + else self.return_choice(OutcomeKeys.TOTAL_USERS) + ) + + +class SmartSettings(object): + __slots__ = [ + "percentage_gap", + ] + + def __init__( + self, + percentage_gap: float = None, + ): + self.percentage_gap = percentage_gap + + def default(self): + self.percentage_gap = ( + self.percentage_gap if self.percentage_gap is not None else 20 + ) diff --git a/TwitchChannelPointsMiner/classes/entities/strategies/SmartHighOdds.py b/TwitchChannelPointsMiner/classes/entities/strategies/SmartHighOdds.py new file mode 100644 index 00000000..88ae0b83 --- /dev/null +++ b/TwitchChannelPointsMiner/classes/entities/strategies/SmartHighOdds.py @@ -0,0 +1,99 @@ +import logging + +from TwitchChannelPointsMiner.classes.entities.Strategy import OutcomeKeys, Strategy, char_decision_as_index +from TwitchChannelPointsMiner.classes.Settings import Settings + + +logger = logging.getLogger(__name__) + + +class SmartHighOdds(Strategy): + def both_odds_too_low(self) -> bool: + return ( + self.outcomes[0][OutcomeKeys.ODDS] + <= self.settings.strategy_settings.target_odd + and self.outcomes[1][OutcomeKeys.ODDS] + <= self.settings.strategy_settings.target_odd + ) + + def is_only_doubt(self) -> bool: + return ( + self.outcomes[1][OutcomeKeys.ODDS] + <= self.settings.strategy_settings.target_odd + and self.settings.only_doubt + ) + + def log_skip(self, string) -> str: + logger.info( + string, + extra={ + "emoji": ":pushpin:", + "color": Settings.logger.color_palette.BET_GENERAL, + }, + ) + + def skip_middle(self): + if self.settings.strategy_settings.always_bet: + self.log_skip("always_bet activated") + return False, 0 + + if ( + self.outcomes[0][OutcomeKeys.TOTAL_POINTS] > 0 + and self.outcomes[1][OutcomeKeys.TOTAL_POINTS] == 0 + ): + self.log_skip("No bet on B") + return False, 0 + if ( + self.outcomes[0][OutcomeKeys.TOTAL_POINTS] == 0 + and self.outcomes[1][OutcomeKeys.TOTAL_POINTS] > 0 + ): + if not self.settings.only_doubt: + self.log_skip("No bet on A") + return False, 0 + + if self.both_odds_too_low() or self.is_only_doubt(): + if self.both_odds_too_low(): + self.log_skip("Odd is too low") + elif self.is_only_doubt(): + self.log_skip("Odd is too low and only_doubt activated") + self.log_skip(f"Target odd: {self.settings.strategy_settings.target_odd}") + return True, 0 # Skip + + def calculate_sho_bet(self, index): + low_odd_points = self.outcomes[1 - index][OutcomeKeys.TOTAL_POINTS] + high_odd_points = self.outcomes[index][OutcomeKeys.TOTAL_POINTS] + if self.both_odds_too_low() or self.is_only_doubt(): + return 10 + elif high_odd_points <= 50: # in case no one bet + return 50 + else: + target_odd = self.settings.strategy_settings.target_odd + if self.outcomes[index][OutcomeKeys.ODDS] > (target_odd * 2): + # don't bet too much if odds is too high + target_odd = self.outcomes[index][OutcomeKeys.ODDS] / 2 + return int((low_odd_points / (target_odd - 1)) - high_odd_points) + + def calculate_middle(self): + self.decision["choice"] = self.return_choice(OutcomeKeys.ODDS) + if self.decision["choice"] is not None: + index = char_decision_as_index(self.decision["choice"]) + self.decision["amount"] = int(self.calculate_sho_bet(index)) + + +class SmartHighOddsSettings(object): + __slots__ = [ + "target_odd", + "always_bet", + ] + + def __init__( + self, + target_odd: float = None, + always_bet: bool = None, + ): + self.target_odd = target_odd + self.always_bet = always_bet + + def default(self): + self.target_odd = self.target_odd if self.target_odd is not None else 3 + self.always_bet = self.always_bet if self.always_bet is not None else False diff --git a/TwitchChannelPointsMiner/constants.py b/TwitchChannelPointsMiner/constants.py index c502433a..0adfbac1 100644 --- a/TwitchChannelPointsMiner/constants.py +++ b/TwitchChannelPointsMiner/constants.py @@ -3,29 +3,37 @@ IRC = "irc.chat.twitch.tv" IRC_PORT = 6667 WEBSOCKET = "wss://pubsub-edge.twitch.tv/v1" -CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko" +CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko" # Browser +# CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp" # Android App +# CLIENT_ID = "851cqzxpb9bqu9z6galo155du" # iOS App DROP_ID = "c2542d6d-cd10-4532-919b-3d19f30a768b" +CLIENT_VERSION = "32d439b2-bd5b-4e35-b82a-fae10b04da70" USER_AGENTS = { "Windows": { - "CHROME": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.104 Safari/537.36", + 'CHROME': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36", "FIREFOX": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0", }, "Linux": { "CHROME": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36", "FIREFOX": "Mozilla/5.0 (X11; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0", }, + "Android": { + # "App": "Dalvik/2.1.0 (Linux; U; Android 7.1.2; SM-G975N Build/N2G48C) tv.twitch.android.app/13.4.1/1304010" + "App": "Dalvik/2.1.0 (Linux; U; Android 7.1.2; SM-G977N Build/LMY48Z) tv.twitch.android.app/14.3.2/1403020" + } } BRANCH = "master" GITHUB_url = ( - "https://raw.githubusercontent.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/" + "https://raw.githubusercontent.com/rdavydov/Twitch-Channel-Points-Miner-v2/" + BRANCH ) class GQLOperations: url = "https://gql.twitch.tv/gql" + integrity_url = "https://gql.twitch.tv/integrity" WithIsStreamLiveQuery = { "operationName": "WithIsStreamLiveQuery", "extensions": { diff --git a/TwitchChannelPointsMiner/logger.py b/TwitchChannelPointsMiner/logger.py index 45ec3252..ac76cc05 100644 --- a/TwitchChannelPointsMiner/logger.py +++ b/TwitchChannelPointsMiner/logger.py @@ -62,13 +62,14 @@ class LoggerSettings: "save", "less", "console_level", + "console_username", "file_level", "emoji", "colored", "color_palette", "auto_clear", "telegram", - "discord", + "discord" ] def __init__( @@ -76,6 +77,7 @@ def __init__( save: bool = True, less: bool = False, console_level: int = logging.INFO, + console_username: bool = False, file_level: int = logging.DEBUG, emoji: bool = platform.system() != "Windows", colored: bool = False, @@ -87,6 +89,7 @@ def __init__( self.save = save self.less = less self.console_level = console_level + self.console_username = console_username self.file_level = file_level self.emoji = emoji self.colored = colored @@ -111,7 +114,7 @@ def format(self, record): and record.emoji_is_present is False ): record.msg = emoji.emojize( - f"{record.emoji} {record.msg.strip()}", language="alias" + f"{record.emoji} {record.msg.strip()}", use_aliases=True ) record.emoji_is_present = True @@ -169,14 +172,17 @@ def configure_loggers(username, settings): # Send log messages to another thread through the queue root_logger.addHandler(queue_handler) + # Adding a username to the format based on settings + console_username = "" if settings.console_username is False else f"[{username}] " + console_handler = logging.StreamHandler() console_handler.setLevel(settings.console_level) console_handler.setFormatter( GlobalFormatter( fmt=( - "%(asctime)s - %(levelname)s - [%(funcName)s]: %(message)s" + "%(asctime)s - %(levelname)s - [%(funcName)s]: " + console_username + "%(message)s" if settings.less is False - else "%(asctime)s - %(message)s" + else "%(asctime)s - " + console_username + "%(message)s" ), datefmt=( "%d/%m/%y %H:%M:%S" if settings.less is False else "%d/%m %H:%M:%S" diff --git a/TwitchChannelPointsMiner/utils.py b/TwitchChannelPointsMiner/utils.py index ea0b6515..6a4bd9e1 100644 --- a/TwitchChannelPointsMiner/utils.py +++ b/TwitchChannelPointsMiner/utils.py @@ -32,7 +32,8 @@ def float_round(number, ndigits=2): def server_time(message_data): return ( - datetime.fromtimestamp(message_data["server_time"], timezone.utc).isoformat() + datetime.fromtimestamp( + message_data["server_time"], timezone.utc).isoformat() + "Z" if message_data is not None and "server_time" in message_data else datetime.fromtimestamp(time.time(), timezone.utc).isoformat() + "Z" @@ -53,12 +54,16 @@ def create_nonce(length=30) -> str: nonce += char return nonce +# for mobile-token + def get_user_agent(browser: str) -> str: try: return USER_AGENTS[platform.system()][browser] except KeyError: - return USER_AGENTS["Linux"]["FIREFOX"] + # return USER_AGENTS["Linux"]["FIREFOX"] + return USER_AGENTS["Windows"]["CHROME"] + # return USER_AGENTS["Android"]["App"] def remove_emoji(string: str) -> str: @@ -137,8 +142,8 @@ def set_default_settings(settings, defaults): ) -def char_decision_as_index(char): - return 0 if char == "A" else 1 +'''def char_decision_as_index(char): + return 0 if char == "A" else 1''' def internet_connection_available(host="8.8.8.8", port=53, timeout=3): @@ -155,7 +160,7 @@ def percentage(a, b): def create_chunks(lst, n): - return [lst[i : (i + n)] for i in range(0, len(lst), n)] # noqa: E203 + return [lst[i: (i + n)] for i in range(0, len(lst), n)] # noqa: E203 def download_file(name, fpath): diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..e69de29b diff --git a/example.py b/example.py index 9e4393a8..da0635d9 100644 --- a/example.py +++ b/example.py @@ -8,7 +8,8 @@ from TwitchChannelPointsMiner.classes.Discord import Discord from TwitchChannelPointsMiner.classes.Telegram import Telegram from TwitchChannelPointsMiner.classes.Settings import Priority, Events, FollowersOrder -from TwitchChannelPointsMiner.classes.entities.Bet import Strategy, BetSettings, Condition, OutcomeKeys, FilterCondition, DelayMode +from TwitchChannelPointsMiner.classes.entities.Bet import BetSettings, FilterCondition, DelayMode +from TwitchChannelPointsMiner.classes.entities.Strategy import Strategy, Condition, OutcomeKeys from TwitchChannelPointsMiner.classes.entities.Streamer import Streamer, StreamerSettings twitch_miner = TwitchChannelPointsMiner( @@ -20,9 +21,11 @@ Priority.DROPS, # - When we don't have anymore watch streak to catch, wait until all drops are collected over the streamers Priority.ORDER # - When we have all of the drops claimed and no watch-streak available, use the order priority (POINTS_ASCENDING, POINTS_DESCEDING) ], + enable_analytics=False, # Disables Analytics if False. Disabling it significantly reduces memory consumption logger_settings=LoggerSettings( save=True, # If you want to save logs in a file (suggested) console_level=logging.INFO, # Level of logs - use logging.DEBUG for more info + console_username=False, # Adds a username to every console log line if True. Useful when you have many open consoles with different accounts file_level=logging.DEBUG, # Level of logs - If you think the log file it's too big, use logging.INFO emoji=True, # On Windows, we have a problem printing emoji. Set to false if you have a problem less=False, # If you think that the logs are too verbose, set this to True @@ -52,7 +55,7 @@ bet=BetSettings( strategy=Strategy.SMART, # Choose you strategy! percentage=5, # Place the x% of your channel points - percentage_gap=20, # Gap difference between outcomesA and outcomesB (for SMART strategy) + only_doubt=False, # Will only doubt (bet on B). If set to True will overwrite strategy bet decision max_points=50000, # If the x percentage of your channel points is gt bet_max_points set this value stealth_mode=True, # If the calculated amount of channel points is GT the highest bet, place the highest value minus 1-2 points Issue #33 delay_mode=DelayMode.FROM_END, # When placing a bet, we will wait until `delay` seconds before the end of the timer @@ -62,7 +65,10 @@ by=OutcomeKeys.TOTAL_USERS, # Where apply the filter. Allowed [PERCENTAGE_USERS, ODDS_PERCENTAGE, ODDS, TOP_POINTS, TOTAL_USERS, TOTAL_POINTS] where=Condition.LTE, # 'by' must be [GT, LT, GTE, LTE] than value value=800 - ) + ), + strategy_settings={ + "percentage_gap": 20 # Gap difference between outcomesA and outcomesB (for SMART stragegy) + } ) ) ) @@ -75,6 +81,8 @@ # For example, if in the mine function you don't provide any value for 'make_prediction' but you have set it on TwitchChannelPointsMiner instance, the script will take the value from here. # If you haven't set any value even in the instance the default one will be used +#twitch_miner.analytics(host="127.0.0.1", port=5000, refresh=5, days_ago=7) # Start the Analytics web-server + twitch_miner.mine( [ Streamer("streamer-username01", settings=StreamerSettings(make_predictions=True , follow_raid=False , claim_drops=True , watch_streak=True , bet=BetSettings(strategy=Strategy.SMART , percentage=5 , stealth_mode=True, percentage_gap=20 , max_points=234 , filter_condition=FilterCondition(by=OutcomeKeys.TOTAL_USERS, where=Condition.LTE, value=800 ) ) )), @@ -91,4 +99,4 @@ ], # Array of streamers (order = priority) followers=False, # Automatic download the list of your followers followers_order=FollowersOrder.ASC # Sort the followers list by follow date. ASC or DESC -) +) \ No newline at end of file diff --git a/pickle_view.py b/pickle_view.py new file mode 100644 index 00000000..b77a577b --- /dev/null +++ b/pickle_view.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python + +# Simple script to view contents of a cookie file stored in a pickle format + +import pickle +import sys + +if __name__ == '__main__': + argv = sys.argv + if len(argv) <= 1: + print("Specify a pickle file as a parameter, e.g. cookies/user.pkl") + else: + print(pickle.load(open(argv[1], 'rb'))) \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..91f0d1da --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = -q diff --git a/requirements.txt b/requirements.txt index 776b3e24..9e67bfe2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ requests websocket-client -browser_cookie3 pillow python-dateutil emoji @@ -10,3 +9,7 @@ colorama flask irc pandas +browser_cookie3 +selenium +selenium-wire +undetected_chromedriver diff --git a/setup.py b/setup.py index 6b04fd79..8626e5d4 100644 --- a/setup.py +++ b/setup.py @@ -17,18 +17,17 @@ def read(fname): setuptools.setup( name="Twitch-Channel-Points-Miner-v2", version=metadata["version"], - author="Tkd-Alex (Alessandro Maggio)", + author="Tkd-Alex (Alessandro Maggio) and rdavydov (Roman Davydov)", author_email="alex.tkd.alex@gmail.com", description="A simple script that will watch a stream for you and earn the channel points.", license="GPLv3+", keywords="python bot streaming script miner twtich channel-points", - url="https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2", + url="https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2", packages=setuptools.find_packages(), include_package_data=True, install_requires=[ "requests", "websocket-client", - "browser_cookie3", "pillow", "python-dateutil", "emoji", @@ -38,6 +37,10 @@ def read(fname): "flask", "irc", "pandas", + "browser_cookie3", + "selenium", + "selenium-wire", + "undetected_chromedriver" ], long_description=read("README.md"), long_description_content_type="text/markdown", diff --git a/stats/README.md b/stats/README.md new file mode 100644 index 00000000..23731507 --- /dev/null +++ b/stats/README.md @@ -0,0 +1,12 @@ +# How it works +![image](https://user-images.githubusercontent.com/6566370/137048948-55c8f003-312b-46b6-8137-1e82dbe52ef7.png) +# Install requirements: +``` +pip install -r requirements.txt +``` +Rename `settings.py.example` to `settings.py` and change settings. +# Run script +``` +python stats.py +``` +To exit script press `q`. diff --git a/stats/requirements.txt b/stats/requirements.txt new file mode 100644 index 00000000..6c829d21 --- /dev/null +++ b/stats/requirements.txt @@ -0,0 +1,4 @@ +humanize==3.5.0 +prettytable==2.1.0 +culour==0.1 +humanfriendly==9.1 diff --git a/stats/settings.py.example b/stats/settings.py.example new file mode 100644 index 00000000..2fb7efcd --- /dev/null +++ b/stats/settings.py.example @@ -0,0 +1,17 @@ +settings = { + # your twitch nickname + "username": "xqcow", + # stats saved as html table, make soft link to this file in directory + # with http access and view your stats online like 1.1.1.1/stats.html + "html_filename": "stats.html", + # stats saved as json data + "json_filename": "stats.json", + # columns in table that show points earned, in hours + "columns": [3, 8, 24, 48, 24 * 7, 24 * 7 * 2], + # blacklisted streamers if you don't wan't them to be present in table + "blacklist": ["forsen"], + # how often to update data in table (in seconds) + "refresh_rate": 60, + "debug": False, + "bugged_terminal": True, +} diff --git a/stats/stats.py b/stats/stats.py new file mode 100644 index 00000000..12f75540 --- /dev/null +++ b/stats/stats.py @@ -0,0 +1,194 @@ +import curses +import json +import re +import time +from datetime import datetime, timedelta +from os import listdir +from os.path import isfile, join +from pathlib import Path + +import culour +import humanfriendly +import humanize +from prettytable import PrettyTable, prettytable +from settings import settings + + +def timestamp_format(entry): + entry["x"] = int(entry["x"] / 1000) + return entry + + +def repeat_to_length(s, wanted): + return (s * (wanted // len(s) + 1))[:wanted] + + +def days_filter(x, hours): + yesterday = datetime.today() - timedelta(hours=hours) + return datetime.fromtimestamp(x["x"]) > yesterday + + +def get_points_from_data(data, hours, current_points): + def list_filter(x): + return days_filter(x, hours) + + data_list = list(filter(list_filter, data["series"])) + data_list = data_list[0] if data_list else None + if data_list: + return current_points - data_list["y"] + else: + return 0 + + +class COLORS(object): + MAGENTA = "\033[95m" + BLUE = "\033[94m" + GREEN = "\033[92m" + YELLOW = "\033[93m" + RED = "\033[91m" + END = "\033[0m" + BOLD = "\033[1m" + UNDERLINE = "\033[4m" + + +def make_green(text): + return f"{COLORS.GREEN}{text}{COLORS.END}" + + +def make_red(text): + return f"{COLORS.RED}{text}{COLORS.END}" + + +def set_colors(array): + final_array = [] + for k, row in enumerate(array): + if k == len(array) - 1: + final_array.append(row) + continue + final_row = [] + for i, cell in enumerate(row): + + def check_points(points): + if points == 0: + return points + elif points > 0: + return make_green(points) + elif points < 0: + return make_red(points) + + if i == 0 or i == 1: + final_row.append(cell) + elif i < len(settings["columns"]) + 2: + final_row.append(check_points(cell)) + else: + final_row.append(cell) + final_array.append(final_row) + return final_array + + +def get_total_row(final_array): + array = [] + for i in range(len(settings["columns"]) + 3): + if i == 0: + array.append("Total") + elif i >= len(settings["columns"]) + 2: + array.append("") + else: + array.append(humanize.intcomma(sum(int(row[i]) for row in final_array))) + return array + + +def get_array(): + mypath = f"../analytics/{settings['username']}" + onlyfiles = [f for f in listdir(mypath) if isfile(join(mypath, f))] + final_array = [] + for streamer in onlyfiles: + txt = Path(f"{mypath}/{streamer}").read_text() + try: + data = json.loads(txt) + except: # noqa E722 + continue + streamer = re.sub(r"\.json$", "", streamer) + if streamer in settings["blacklist"]: + continue + data["series"] = list(map(timestamp_format, data["series"])) + last_entry = data["series"][-1] + changes = [] + for column in settings["columns"]: + changes.append(get_points_from_data(data, column, last_entry["y"])) + date = datetime.fromtimestamp(last_entry["x"]) + # six_minutes_ago = datetime.today() - timedelta(minutes=5) + # if not no_colors and date > six_minutes_ago: + # streamer = make_green(streamer) + date = humanize.naturaltime(date) + final_array.append([streamer, last_entry["y"]] + changes + [date]) + final_array = sorted(final_array, key=lambda x: x[1], reverse=True) + final_array.append(get_total_row(final_array)) + return final_array + + +def header(): + result = ["Streamer", "Points"] + for column in settings["columns"]: + result.append(humanfriendly.format_timespan(timedelta(hours=column))) + result.append("Updated") + return result + + +def get_table(): + array = get_array() + + if settings["json_filename"]: + f = open(settings["json_filename"], "w") + f.write(json.dumps(array)) + f.close() + + if settings["html_filename"]: + x = PrettyTable(hrules=prettytable.ALL) + x.field_names = header() + for streamer in array: + x.add_row(streamer) + x.align = "l" + x.format = True + f = open(settings["html_filename"], "w") + f.write(x.get_html_string()) + f.close() + + final_array = set_colors(array) + x = PrettyTable(hrules=prettytable.ALL) + x.field_names = header() + for streamer in final_array: + x.add_row(streamer) + x.align = "l" + + return x.get_string().splitlines() + + +def stats(window): + window.nodelay(1) + curses.curs_set(0) + cycle = 0 + table = get_table() + while window.getch() not in [ord(x) for x in ["q", "Q"]]: + cycle += 1 + if cycle == settings["refresh_rate"] * 10: + table = get_table() + cycle = 0 + height, width = window.getmaxyx() + if len(table) < height: + height = len(table) + for column in range(height): + if settings["bugged_terminal"] and table[column][0] == "+": + window.addstr( + column, 0, repeat_to_length("+------", len(table[column])) + ) + else: + if settings["debug"]: + f = open("log.txt", "a") + f.write(table[column] + "\n") + f.close() + culour.addstr(window, column, 0, table[column]) + time.sleep(0.1) + + +curses.wrapper(stats) diff --git a/tests/test_bet.py b/tests/test_bet.py new file mode 100644 index 00000000..935581f7 --- /dev/null +++ b/tests/test_bet.py @@ -0,0 +1,269 @@ +from TwitchChannelPointsMiner.classes.entities.Bet import ( + Bet, + BetSettings, + FilterCondition, + DelayMode +) +from TwitchChannelPointsMiner.classes.entities.Strategy import ( + Strategy, + StrategySettings, + Condition, + OutcomeKeys +) +import pytest +from TwitchChannelPointsMiner.logger import LoggerSettings +from TwitchChannelPointsMiner.classes.Settings import Settings + +@pytest.fixture +def bet_settings(): + settings = BetSettings( + strategy=Strategy.SMART_HIGH_ODDS, + percentage=50, + only_doubt=False, + max_points=50000, + stealth_mode=False, + delay_mode=DelayMode.FROM_END, + delay=6, + strategy_settings={ + "target_odd": 2.1 + } + ) + return settings + + +@pytest.fixture +def outcomes(): + outcomes = [ + { + "percentage_users": 50, + "odds_percentage": 60, + "odds": 1.67, + "top_points": 600, + "total_users": 1, + "total_points": 600, + "decision_users": 1, + "decision_points": 1, + "id": 1 + }, + { + "percentage_users": 50, + "odds_percentage": 40, + "odds": 2.5, + "top_points": 400, + "total_users": 1, + "total_points": 400, + "decision_users": 1, + "decision_points": 1, + "id": 2 + } + ] + return outcomes + + +def test_settings(bet_settings, outcomes): + bet = Bet(outcomes, bet_settings) + calc = bet.calculate(1000) + assert calc["choice"] == "B" + assert calc["amount"] == 145 + assert bet.decision == {"amount": 145, "choice": "B", "id": 2} # important + + +def test_settings2(bet_settings, outcomes): + outcomes[1]["odds"] = 12 + outcomes[0]["odds"] = 1.09 + outcomes[0]["top_points"] = 4400 + outcomes[0]["total_points"] = 4400 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["amount"] == 480 + + +def test_settings3(bet_settings, outcomes): + outcomes[1]["odds"] = 13 + outcomes[0]["odds"] = 1.08 + outcomes[1]["top_points"] = 50 + outcomes[1]["total_points"] = 50 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["amount"] == 50 + + +def test_settings4(bet_settings, outcomes): + outcomes[1]["odds"] = 2 + outcomes[0]["odds"] = 2 + outcomes[1]["top_points"] = 600 + outcomes[1]["total_points"] = 600 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["amount"] == 10 + + +def test_settings5(bet_settings, outcomes): + outcomes = [outcomes[1], outcomes[0]] + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "A" + assert bet["amount"] == 145 + + +def test_update_outcomes(bet_settings, outcomes): + bet = Bet(outcomes, bet_settings) + outcomes[0]["top_points"] = 0 + outcomes[0]["top_predictors"] = [{"points": 100}, {"points": 200}] + outcomes[1]["top_predictors"] = [{"points": 100}, {"points": 300}] + outcomes[0]["total_users"] = 1 + outcomes[1]["total_users"] = 3 + outcomes[0]["total_points"] = 800 + outcomes[1]["total_points"] = 200 + bet.update_outcomes(outcomes) + assert bet.outcomes[0]["top_points"] == 200 + assert bet.outcomes[1]["top_points"] == 300 + assert bet.outcomes[0]["percentage_users"] == 25 + assert bet.outcomes[1]["percentage_users"] == 75 + assert bet.outcomes[0]["odds"] == 1.25 + assert bet.outcomes[1]["odds"] == 5 + assert bet.outcomes[0]["odds_percentage"] == 80 + assert bet.outcomes[1]["odds_percentage"] == 20 + + +def test_stealth_mode(bet_settings, outcomes): + bet_settings.stealth_mode = True + outcomes[1]["top_points"] = 80 + for x in range(10): + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["amount"] >= 75 + assert bet["amount"] <= 79 + + +def test_always_bet(bet_settings, outcomes): + Settings.logger = LoggerSettings() + outcomes[1]["odds"] = 2 + outcomes[0]["odds"] = 2 + skip = Bet(outcomes, bet_settings).skip() + assert skip == (True, 0) + bet_settings.strategy_settings.always_bet = True + skip = Bet(outcomes, bet_settings).skip() + assert skip == (False, 0) + + +def test_most_voted(bet_settings, outcomes): + bet_settings.strategy = Strategy.MOST_VOTED + bet_settings.percentage = 20 + outcomes[0]["total_users"] = 1 + outcomes[1]["total_users"] = 2 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "B" + assert bet["amount"] == 200 + outcomes[0]["total_users"] = 2 + outcomes[1]["total_users"] = 1 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "A" + + +def test_high_odds(bet_settings, outcomes): + bet_settings.strategy = Strategy.HIGH_ODDS + bet_settings.percentage = 20 + outcomes[0]["odds"] = 2 + outcomes[1]["odds"] = 3 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "B" + assert bet["amount"] == 200 + outcomes[0]["odds"] = 3 + outcomes[1]["odds"] = 2 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "A" + + +def test_percentage(bet_settings, outcomes): + bet_settings.strategy = Strategy.PERCENTAGE + bet_settings.percentage = 20 + outcomes[0]["odds_percentage"] = 2 + outcomes[1]["odds_percentage"] = 3 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "B" + assert bet["amount"] == 200 + outcomes[0]["odds_percentage"] = 3 + outcomes[1]["odds_percentage"] = 2 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "A" + + +def test_smart(bet_settings, outcomes): + bet_settings = BetSettings( + strategy=Strategy.SMART, + strategy_settings={ + "percentage_gap": 1 + } + ) + bet_settings.default() + outcomes[0]["percentage_users"] = 30 + outcomes[1]["percentage_users"] = 70 + outcomes[0]["total_users"] = 30 + outcomes[1]["total_users"] = 70 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "B" + assert bet["amount"] == 50 + outcomes[0]["percentage_users"] = 60 + outcomes[1]["percentage_users"] = 40 + outcomes[0]["total_users"] = 60 + outcomes[1]["total_users"] = 40 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "A" + + +def test_smart2(bet_settings, outcomes): + bet_settings = BetSettings( + strategy=Strategy.SMART, + strategy_settings={ + "percentage_gap": 99 + } + ) + bet_settings.default() + outcomes[0]["percentage_users"] = 30 + outcomes[1]["percentage_users"] = 70 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "B" + assert bet["amount"] == 50 + outcomes[0]["percentage_users"] = 60 + outcomes[1]["percentage_users"] = 40 + outcomes[0]["odds"] = 2 + outcomes[1]["odds"] = 1 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "A" + + +def test_only_doubt(bet_settings, outcomes): + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "B" + assert bet["amount"] == 145 + outcomes[1]["odds"] = 1.5 + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "A" + assert bet["amount"] == 10 + bet_settings.only_doubt = True + bet = Bet(outcomes, bet_settings).calculate(1000) + assert bet["choice"] == "B" + assert bet["amount"] == 10 + + +def test_skip(bet_settings, outcomes): + bet_settings.filter_condition = FilterCondition( + by=OutcomeKeys.ODDS, + where=Condition.GT, + value=2.4 + ) + skip = Bet(outcomes, bet_settings).skip() + assert skip == (False, 2.5) + + +def test_skip2(bet_settings, outcomes): + bet_settings.filter_condition = FilterCondition( + by=OutcomeKeys.ODDS, + where=Condition.GT, + value=2.6 + ) + skip = Bet(outcomes, bet_settings).skip() + assert skip == (True, 2.5) + + +def test_skip3(bet_settings, outcomes): + Settings.logger = LoggerSettings() + bet_settings.strategy_settings.target_odd = 2.5 + skip = Bet(outcomes, bet_settings).skip() + assert skip == (True, 0)