├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ └── testing.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── README.md ├── Taskfile.yml ├── bot.dev.env ├── bot.py ├── common ├── .gitignore ├── __init__.py ├── core │ ├── __init__.py │ └── dataclass_json.py ├── env_var.py ├── rabbitmq_connector.py ├── redis_connector.py ├── rpc │ ├── __init__.py │ ├── message.py │ ├── message_channel.py │ ├── queue_factory.py │ ├── queue_listener.py │ ├── queue_publisher.py │ ├── queue_reader.py │ └── rabbitmq_client.py └── tracer.py ├── csmoney_parser.dev.env ├── csmoney_parser.py ├── docker-compose.yml ├── images ├── bot.png └── sell_history.jpg ├── logging.yaml ├── poetry.lock ├── price_monitoring ├── __init__.py ├── async_runner.py ├── common.py ├── constants.py ├── decorators.py ├── features │ ├── __init__.py │ └── overpay │ │ ├── __init__.py │ │ ├── base_price_filler.py │ │ ├── csmoney │ │ ├── __init__.py │ │ ├── abstract_base_price_fetcher.py │ │ ├── base_price_fetcher.py │ │ └── overpay_calculator.py │ │ ├── generate_list.py │ │ ├── overpay_reference.py │ │ ├── overpay_sort.py │ │ ├── storage │ │ ├── __init__.py │ │ ├── abstract_base_price.py │ │ ├── abstract_overpay.py │ │ ├── redis_base_price.py │ │ └── redis_overpay.py │ │ └── worker │ │ ├── __init__.py │ │ └── overpay_extractor.py ├── logs.py ├── models │ ├── __init__.py │ ├── csmoney.py │ └── steam.py ├── parsers │ ├── __init__.py │ ├── abstract_parser.py │ ├── csmoney │ │ ├── __init__.py │ │ ├── csmoney_parser.py │ │ ├── parser │ │ │ ├── __init__.py │ │ │ ├── _name_patcher.py │ │ │ ├── abstract_parser.py │ │ │ └── parser.py │ │ └── task_scheduler │ │ │ ├── __init__.py │ │ │ └── redis_task_scheduler.py │ └── steam │ │ ├── __init__.py │ │ ├── name_resolver │ │ ├── __init__.py │ │ ├── abstract_name_resolver.py │ │ ├── memory_cached_name_resolver.py │ │ ├── name_resolver.py │ │ └── redis_cached_name_resolver.py │ │ ├── parser │ │ ├── __init__.py │ │ ├── abstract_sell_history_parser.py │ │ ├── abstract_steam_orders_parser.py │ │ ├── steam_orders_parser.py │ │ └── steam_sell_history_parser.py │ │ ├── skin_scheduler │ │ ├── __init__.py │ │ ├── abstract_skin_scheduler.py │ │ ├── redis_skin_scheduler.py │ │ └── scheduler_filler.py │ │ ├── steam_order_parser.py │ │ └── steam_sell_history_parser.py ├── queues │ ├── __init__.py │ ├── abstract_csmoney_result_queue.py │ ├── abstract_market_name_queue.py │ ├── abstract_steam_order_queue.py │ ├── abstract_steam_sell_history_queue.py │ └── rabbitmq │ │ ├── __init__.py │ │ ├── csmoney_result_queue.py │ │ ├── market_name_queue.py │ │ ├── steam_result_queue.py │ │ └── steam_sell_history_queue.py ├── storage │ ├── __init__.py │ ├── csmoney │ │ ├── __init__.py │ │ ├── abstract_csmoney_item_storage.py │ │ └── redis_csmoney_item_storage.py │ ├── proxy │ │ ├── __init__.py │ │ ├── abstract_proxy_storage.py │ │ └── redis_proxy_storage.py │ └── steam │ │ ├── __init__.py │ │ ├── abstract_steam_orders_storage.py │ │ ├── abstract_steam_sell_history_storage.py │ │ ├── redis_steam_orders_storage.py │ │ └── redis_steam_sell_history_storage.py ├── telegram │ ├── __init__.py │ ├── bot │ │ ├── __init__.py │ │ ├── abstract_bot.py │ │ ├── abstract_command.py │ │ ├── abstract_settings.py │ │ ├── abstract_whitelist.py │ │ ├── aiogram_bot.py │ │ ├── commands │ │ │ ├── __init__.py │ │ │ ├── offers.py │ │ │ ├── set_limit.py │ │ │ ├── set_min_price.py │ │ │ └── settings.py │ │ ├── notification_formatter.py │ │ ├── redis_settings.py │ │ └── redis_whitelist.py │ ├── fresh_filter │ │ ├── __init__.py │ │ ├── abstract_filter.py │ │ └── redis_filter.py │ ├── models.py │ ├── offer_provider │ │ ├── __init__.py │ │ ├── abstract_offer_provider.py │ │ ├── chain_provider.py │ │ ├── redis_provider.py │ │ ├── redis_sell_history_provider.py │ │ └── settings_based_provider.py │ ├── offers │ │ ├── __init__.py │ │ ├── base_item_offer.py │ │ ├── steam_orders_offer.py │ │ └── steam_sell_history_offer.py │ ├── runner │ │ ├── __init__.py │ │ ├── abstract_runner.py │ │ └── runner_impl.py │ └── steam_fee.py ├── types.py └── worker │ ├── __init__.py │ ├── processing │ ├── __init__.py │ ├── abstract_csmoney_item_processor.py │ ├── abstract_steam_processor.py │ ├── abstract_steam_sell_history_processor.py │ ├── csmoney_item_processor.py │ ├── market_name_extractor.py │ ├── sell_history │ │ ├── __init__.py │ │ └── analyzer.py │ ├── steam_sell_history_processor.py │ └── steam_skin_processor.py │ └── worker.py ├── prod.env ├── proxy_http ├── __init__.py ├── aiohttp_addons │ ├── __init__.py │ └── aihttp_socks_connector.py ├── aiohttp_session_factory.py ├── async_proxies_concurrent_limiter.py ├── decorators.py └── proxy.py ├── pyproject.toml ├── steam_parser.dev.env ├── steam_parser.py ├── tests ├── __init__.py ├── conftest.py ├── features │ ├── __init__.py │ └── overpay │ │ ├── __init__.py │ │ ├── csmoney │ │ ├── __init__.py │ │ ├── test_base_price_fetcher.py │ │ └── test_overpay_calculator.py │ │ ├── storage │ │ ├── __init__.py │ │ ├── test_redis_base_price.py │ │ └── test_redis_overpay.py │ │ ├── test_base_price_filler.py │ │ ├── test_generate_list.py │ │ ├── test_overpay_reference.py │ │ └── test_overpay_sort.py ├── models │ ├── __init__.py │ └── test_steam.py ├── parsers │ ├── __init__.py │ ├── csmoney │ │ ├── __init__.py │ │ ├── parser │ │ │ ├── __init__.py │ │ │ ├── data │ │ │ │ ├── item_1.json │ │ │ │ ├── item_with_stack_1.json │ │ │ │ ├── item_with_stack_2.json │ │ │ │ └── item_without_full_name_1.json │ │ │ ├── test_name_patcher.py │ │ │ └── test_parser.py │ │ ├── task_scheduler │ │ │ ├── __init__.py │ │ │ └── test_task_scheduler.py │ │ └── test_csmoney_parser.py │ └── steam │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── name_resolver │ │ ├── __init__.py │ │ ├── response_of_non_existed_item.txt │ │ ├── response_with_name_id.txt │ │ ├── test_memory_cached_name_resolver.py │ │ ├── test_name_resolver.py │ │ └── test_redis_cached_name_resolver.py │ │ ├── parser │ │ ├── __init__.py │ │ ├── test_steam_orders_parser.py │ │ └── test_steam_sell_history_parser.py │ │ ├── skin_scheduler │ │ ├── __init__.py │ │ ├── test_redis_skin_scheduler.py │ │ └── test_scheduler_filler.py │ │ ├── test_steam_parser.py │ │ └── test_steam_sell_history_parser.py ├── queues │ ├── __init__.py │ └── rabbitmq │ │ ├── __init__.py │ │ ├── test_csmoney_result_queue.py │ │ ├── test_market_name_queue.py │ │ ├── test_steam_result_queue.py │ │ └── test_steam_sell_history_queue.py ├── storage │ ├── __init__.py │ ├── csmoney │ │ ├── __init__.py │ │ └── test_redis_csmoney_item_storage.py │ ├── proxy │ │ ├── __init__.py │ │ └── test_redis_proxy_storage.py │ └── steam │ │ ├── __init__.py │ │ ├── test_redis_steam_orders_storage.py │ │ └── test_redis_steam_sell_history_storage.py ├── telegram │ ├── __init__.py │ ├── bot │ │ ├── __init__.py │ │ ├── commands │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ ├── test_offers.py │ │ │ ├── test_set_limit.py │ │ │ ├── test_set_min_price.py │ │ │ └── test_settings.py │ │ ├── test_aiogram_bot.py │ │ ├── test_notification_formatter.py │ │ ├── test_redis_settings.py │ │ └── test_redis_whitelist.py │ ├── fresh_filter │ │ ├── __init__.py │ │ └── test_redis_filter.py │ ├── offer_provider │ │ ├── __init__.py │ │ ├── test_chain_provider.py │ │ ├── test_redis_provider.py │ │ ├── test_redis_sell_history_provider.py │ │ └── test_settings_based_provider.py │ ├── offers │ │ ├── __init__.py │ │ ├── test_base_item_offer.py │ │ ├── test_steam_orders_offer.py │ │ └── test_steam_sell_history_offer.py │ ├── runner │ │ ├── __init__.py │ │ └── test_runner_impl.py │ ├── test_models.py │ └── test_steam_fee.py └── worker │ ├── __init__.py │ ├── processing │ ├── __init__.py │ ├── sell_history │ │ ├── __init__.py │ │ ├── test_analyzer.py │ │ ├── test_data.json │ │ ├── test_data_10.json │ │ ├── test_data_2.json │ │ ├── test_data_2_expected.json │ │ ├── test_data_3.json │ │ ├── test_data_4.json │ │ ├── test_data_5.json │ │ ├── test_data_6.json │ │ ├── test_data_7.json │ │ ├── test_data_8.json │ │ ├── test_data_9.json │ │ ├── test_data_9_expected.json │ │ └── test_data_expected.json │ ├── test_csmoney_item_processor.py │ ├── test_market_name_extractor.py │ ├── test_overpay_extractor.py │ ├── test_steam_sell_history_processor.py │ └── test_steam_skin_processor.py │ └── test_worker.py ├── utils ├── __init__.py ├── create_csmoney_tasks.env ├── create_csmoney_tasks.py ├── upload_proxies.env └── upload_proxies.py ├── worker.dev.env └── worker.py /.dockerignore: -------------------------------------------------------------------------------- 1 | utils_mount -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" # See documentation for possible values 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Testing of the price monitoring 5 | 6 | on: [push] 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python 3.10 16 | uses: actions/setup-python@v3 17 | with: 18 | python-version: "3.10" 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install poetry 23 | poetry install --extras linux 24 | - name: Coverage and test with pytest 25 | run: | 26 | poetry run coverage run --source=price_monitoring --branch -m pytest tests/ 27 | poetry run coverage report -m 28 | - name: Lint with pylint 29 | if: ${{ success() || failure() }} 30 | run: | 31 | poetry run pylint --rcfile pyproject.toml --fail-under 8 price_monitoring utils bot.py csmoney_parser.py steam_parser.py worker.py 32 | - name: Run mypy 33 | if: ${{ success() || failure() }} 34 | continue-on-error: true 35 | run: | 36 | poetry run mypy --ignore-missing-imports price_monitoring bot.py csmoney_parser.py steam_parser.py worker.py 37 | - name: Run black 38 | if: ${{ success() || failure() }} 39 | continue-on-error: true 40 | run: | 41 | poetry run black --check proxy_http price_monitoring tests utils bot.py csmoney_parser.py steam_parser.py worker.py 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | venv 3 | .pytest_cache 4 | .coverage -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: mypy 5 | name: mypy 6 | entry: mypy price_monitoring bot.py csmoney_parser.py steam_parser.py worker.py 7 | language: system 8 | types: [ python ] 9 | pass_filenames: false 10 | args: 11 | [ 12 | --config-file=pyproject.toml, 13 | --ignore-missing-imports, 14 | ] 15 | - repo: local 16 | hooks: 17 | - id: black 18 | name: black 19 | entry: black price_monitoring utils bot.py csmoney_parser.py steam_parser.py worker.py 20 | language: system 21 | types: [ python ] 22 | - repo: local 23 | hooks: 24 | - id: pylint 25 | name: pylint 26 | entry: pylint 27 | language: system 28 | types: [ python ] 29 | args: 30 | [ 31 | "--rcfile=pyproject.toml", 32 | "--fail-under=8", 33 | "price_monitoring", 34 | "utils", 35 | "bot.py", 36 | "csmoney_parser.py", 37 | "steam_parser.py", 38 | "worker.py" 39 | ] 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | WORKDIR /root/fw3 3 | RUN apt update &&\ 4 | apt install screen locales -y &&\ 5 | echo "LC_ALL=en_US.UTF-8" >> /etc/environment &&\ 6 | echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen &&\ 7 | echo "LANG=en_US.UTF-8" > /etc/locale.conf &&\ 8 | locale-gen en_US.UTF-8 9 | ENV PYTHONPATH "/root/fw3/" 10 | ENV PYTHONUTF8 1 11 | ENV TZ=Europe/Minsk 12 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 13 | RUN pip install poetry 14 | COPY poetry.lock pyproject.toml ./ 15 | RUN poetry install --no-dev --extras linux 16 | COPY . ./ 17 | CMD ["bash"] -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/Taskfile.yml -------------------------------------------------------------------------------- /bot.dev.env: -------------------------------------------------------------------------------- 1 | WORKER_REDIS_HOST=localhost 2 | WORKER_REDIS_PORT=6379 3 | WORKER_REDIS_DB=0 4 | WORKER_REDIS_PASSWORD=25a7edbfdd5bc5d7f8ce 5 | 6 | CACHE_REDIS_HOST=localhost 7 | CACHE_REDIS_PORT=6379 8 | CACHE_REDIS_DB=2 9 | CACHE_REDIS_PASSWORD=25a7edbfdd5bc5d7f8ce 10 | 11 | TELEGRAM_REDIS_HOST=localhost 12 | TELEGRAM_REDIS_PORT=6379 13 | TELEGRAM_REDIS_DB=1 14 | TELEGRAM_REDIS_PASSWORD=25a7edbfdd5bc5d7f8ce 15 | TELEGRAM_API_TOKEN= 16 | TELEGRAM_WHITELIST= -------------------------------------------------------------------------------- /common/.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | venv/ 3 | build/ 4 | dist/ 5 | *.egg-info -------------------------------------------------------------------------------- /common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/common/__init__.py -------------------------------------------------------------------------------- /common/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/common/core/__init__.py -------------------------------------------------------------------------------- /common/core/dataclass_json.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import orjson 3 | 4 | 5 | # noinspection PyUnresolvedReferences 6 | class JsonMixin: 7 | @classmethod 8 | def load(cls, d: Dict): 9 | return cls.Schema().load(d) 10 | 11 | @classmethod 12 | def loads(cls, s: str): 13 | return cls.Schema().loads(s) 14 | 15 | @classmethod 16 | def load_bytes(cls, b: bytes): 17 | return cls.Schema().loads(b.decode()) 18 | 19 | def dump(self) -> Dict: 20 | return self.Schema().dump(self) 21 | 22 | def dumps(self) -> str: 23 | return self.Schema().dumps(self) 24 | 25 | def dump_bytes(self) -> bytes: 26 | return self.Schema().dumps(self).encode() 27 | 28 | 29 | # noinspection PyUnresolvedReferences 30 | class FastJsonMixin: 31 | """This mixin does not support nested fields""" 32 | @classmethod 33 | def load(cls, d: Dict): 34 | return cls(**d) 35 | 36 | @classmethod 37 | def loads(cls, s: str): 38 | return cls(**orjson.loads(s)) 39 | 40 | @classmethod 41 | def load_bytes(cls, b: bytes): 42 | return cls(**orjson.loads(b)) 43 | 44 | def dump(self) -> Dict: 45 | return self.Schema().dump(self) 46 | 47 | def dumps(self) -> str: 48 | return self.Schema().dumps(self) 49 | 50 | def dump_bytes(self) -> bytes: 51 | return self.Schema().dumps(self).encode() 52 | -------------------------------------------------------------------------------- /common/env_var.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional, List 3 | 4 | 5 | class EnvVar: 6 | @staticmethod 7 | def get(key: str, default=None) -> Optional[str]: 8 | return os.getenv(key, default) 9 | 10 | @staticmethod 11 | def get_many(keys: List[str]) -> List[Optional[str]]: 12 | return [os.getenv(key, None) for key in keys] 13 | -------------------------------------------------------------------------------- /common/rabbitmq_connector.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from .rpc.rabbitmq_client import RabbitMQClient 4 | 5 | 6 | class RabbitmqConnector: 7 | @staticmethod 8 | def create(host: str, 9 | port: str, 10 | login: str, 11 | password: str, 12 | connection_name: Optional[str] = None) -> RabbitMQClient: 13 | return RabbitMQClient(host=host, 14 | port=int(port), 15 | login=login, 16 | password=password, 17 | connection_name=connection_name) 18 | 19 | @staticmethod 20 | async def connect(host: str, 21 | port: str, 22 | login: str, 23 | password: str, 24 | connection_name: Optional[str] = None): 25 | return await RabbitMQClient(host=host, 26 | port=int(port), 27 | login=login, 28 | password=password, 29 | connection_name=connection_name).connect() 30 | -------------------------------------------------------------------------------- /common/redis_connector.py: -------------------------------------------------------------------------------- 1 | from aioredis import Redis 2 | 3 | 4 | class RedisConnector: 5 | @staticmethod 6 | def create(host: str, port: str, db: str, password: str): 7 | return Redis(host=host, port=int(port), db=int(db), password=password) 8 | -------------------------------------------------------------------------------- /common/rpc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/common/rpc/__init__.py -------------------------------------------------------------------------------- /common/rpc/message.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from marshmallow_dataclass import add_schema 4 | 5 | from ..core.dataclass_json import JsonMixin 6 | 7 | 8 | @add_schema 9 | @dataclass 10 | class Message(JsonMixin): 11 | type_: str 12 | body: str 13 | 14 | def get_body(self, cls): 15 | return cls.load_bytes(self.body) 16 | -------------------------------------------------------------------------------- /common/rpc/queue_factory.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Optional, Callable 3 | 4 | from .queue_listener import QueueListener 5 | from .queue_publisher import QueuePublisher 6 | from .queue_reader import QueueReader 7 | from .rabbitmq_client import RabbitMQClient 8 | 9 | 10 | class QueueFactory: 11 | @staticmethod 12 | async def connect_reader(name: str, 13 | client: RabbitMQClient, 14 | passive: bool = False, 15 | message_ttl: Optional[timedelta] = None 16 | ) -> QueueReader: 17 | channel = await client.create_channel() 18 | return await QueueReader(name=name, 19 | channel=channel, 20 | passive=passive, 21 | message_ttl=message_ttl 22 | ).connect() 23 | 24 | @staticmethod 25 | async def connect_listener(name: str, 26 | client: RabbitMQClient, 27 | on_msg: Optional[Callable], 28 | passive: bool = False, 29 | message_ttl: Optional[timedelta] = None 30 | ) -> QueueListener: 31 | channel = await client.create_channel() 32 | return await QueueListener(name=name, 33 | channel=channel, 34 | on_msg=on_msg, 35 | passive=passive, 36 | message_ttl=message_ttl 37 | ).connect() 38 | 39 | @staticmethod 40 | async def connect_publisher(name: str, 41 | client: RabbitMQClient, 42 | passive: bool = False, 43 | message_ttl: Optional[timedelta] = None 44 | ) -> QueuePublisher: 45 | channel = await client.create_channel() 46 | return await QueuePublisher(name=name, 47 | channel=channel, 48 | passive=passive, 49 | message_ttl=message_ttl 50 | ).connect() 51 | -------------------------------------------------------------------------------- /common/rpc/queue_listener.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Optional, Callable 3 | 4 | from aio_pika import Channel 5 | 6 | 7 | class QueueListener: 8 | def __init__(self, name: str, 9 | channel: Channel, 10 | on_msg: Optional[Callable], 11 | passive: bool = False, 12 | message_ttl: Optional[timedelta] = None): 13 | self._name = name 14 | self._channel = channel 15 | self._on_msg = on_msg 16 | self._passive = passive 17 | self._queue = None 18 | self._message_ttl = message_ttl 19 | 20 | async def connect(self): 21 | arguments = {} 22 | if self._message_ttl: 23 | arguments['x-message-ttl'] = int(self._message_ttl.total_seconds()) 24 | self._queue = await self._channel.declare_queue( 25 | name=self._name, 26 | passive=self._passive, 27 | arguments=arguments 28 | ) 29 | await self._queue.consume(self._on_msg) 30 | return self 31 | -------------------------------------------------------------------------------- /common/rpc/queue_publisher.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Optional 3 | 4 | from aio_pika import Channel, Message 5 | 6 | 7 | class QueuePublisher: 8 | def __init__(self, 9 | name: str, 10 | channel: Channel, 11 | passive: bool = False, 12 | message_ttl: Optional[timedelta] = None): 13 | self.name = name 14 | self.channel = channel 15 | self.passive = passive 16 | self.message_ttl = message_ttl 17 | 18 | async def connect(self): 19 | arguments = {} 20 | if self.message_ttl: 21 | arguments['x-message-ttl'] = int(self.message_ttl.total_seconds()) 22 | await self.channel.declare_queue( 23 | name=self.name, 24 | passive=self.passive, 25 | arguments=arguments 26 | ) 27 | return self 28 | 29 | async def publish(self, 30 | body: bytes, 31 | timeout: float = 30, 32 | message_ttl: Optional[timedelta] = None 33 | ) -> None: 34 | expiration = (message_ttl.total_seconds() * 1000) if message_ttl else None 35 | await self.channel.default_exchange.publish( 36 | Message(body, expiration=expiration), 37 | routing_key=self.name, 38 | timeout=timeout 39 | ) 40 | -------------------------------------------------------------------------------- /common/rpc/queue_reader.py: -------------------------------------------------------------------------------- 1 | import asyncio.exceptions 2 | from asyncio import QueueEmpty 3 | from datetime import timedelta 4 | from typing import Optional 5 | 6 | from aio_pika import Channel 7 | 8 | 9 | class QueueReader: 10 | def __init__(self, name: str, 11 | channel: Channel, 12 | passive: bool = False, 13 | message_ttl: Optional[timedelta] = None): 14 | self._name = name 15 | self._channel = channel 16 | self._passive = passive 17 | self._queue = None 18 | self._message_ttl = message_ttl 19 | 20 | async def connect(self): 21 | arguments = {} 22 | if self._message_ttl: 23 | arguments['x-message-ttl'] = int(self._message_ttl.total_seconds()) 24 | self._queue = await self._channel.declare_queue( 25 | name=self._name, 26 | passive=self._passive, 27 | arguments=arguments 28 | ) 29 | return self 30 | 31 | async def read(self, timeout: int = 5) -> Optional[bytes]: 32 | try: 33 | msg = await self._queue.get(timeout=timeout, no_ack=True) 34 | return msg.body 35 | except (QueueEmpty, asyncio.exceptions.TimeoutError): 36 | pass 37 | -------------------------------------------------------------------------------- /common/rpc/rabbitmq_client.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from aio_pika import connect_robust, Channel 4 | 5 | 6 | class RabbitMQClient: 7 | def __init__(self, 8 | host: str, 9 | port: int, 10 | login: str, 11 | password: str, 12 | connection_name: Optional[str] = None): 13 | self.host = host 14 | self.port = port 15 | self.login = login 16 | self.password = password 17 | self.connection_name = connection_name 18 | self.connection = None 19 | 20 | async def connect(self): 21 | # https://github.com/mosquito/aio-pika/issues/301 22 | # client_properties = {} 23 | # if self.connection_name: 24 | # client_properties['connection_name'] = self.connection_name 25 | self.connection = await connect_robust( 26 | host=self.host, 27 | port=self.port, 28 | login=self.login, 29 | password=self.password, 30 | # client_properties={"client_properties": client_properties} 31 | ) 32 | return self 33 | 34 | async def create_channel(self) -> Channel: 35 | return await self.connection.channel() 36 | -------------------------------------------------------------------------------- /csmoney_parser.dev.env: -------------------------------------------------------------------------------- 1 | REDIS_HOST=localhost 2 | REDIS_PORT=6379 3 | REDIS_DB=1 4 | REDIS_PASSWORD=25a7edbfdd5bc5d7f8ce 5 | RABBITMQ_HOST=localhost 6 | RABBITMQ_PORT=5672 7 | RABBITMQ_LOGIN=b89064ff8c4016862f05 8 | RABBITMQ_PASSWORD=1933952ebd3fca7d973e -------------------------------------------------------------------------------- /csmoney_parser.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from common.rabbitmq_connector import RabbitmqConnector 4 | from common.redis_connector import RedisConnector 5 | from common.rpc.queue_factory import QueueFactory 6 | from price_monitoring.async_runner import async_run 7 | from price_monitoring.common import create_limiter 8 | from price_monitoring.constants import QueueNames, RedisKeys 9 | from price_monitoring.parsers.csmoney import CsmoneyParser 10 | from price_monitoring.parsers.csmoney.parser import CsmoneyParserImpl 11 | from price_monitoring.parsers.csmoney.task_scheduler import RedisTaskScheduler 12 | from price_monitoring.queues.rabbitmq import CsmoneyWriter 13 | from price_monitoring.storage.proxy import RedisProxyStorage 14 | 15 | redis_credentials = { 16 | "host": os.getenv("REDIS_HOST"), 17 | "port": os.getenv("REDIS_PORT"), 18 | "db": os.getenv("REDIS_DB"), 19 | "password": os.getenv("REDIS_PASSWORD"), 20 | } 21 | 22 | 23 | rabbitmq_credentials = { 24 | "host": os.getenv("RABBITMQ_HOST"), 25 | "port": os.getenv("RABBITMQ_PORT"), 26 | "login": os.getenv("RABBITMQ_LOGIN"), 27 | "password": os.getenv("RABBITMQ_PASSWORD"), 28 | } 29 | 30 | 31 | async def main(): 32 | redis = RedisConnector.create(**redis_credentials) 33 | rabbitmq = await RabbitmqConnector.connect(**rabbitmq_credentials) 34 | 35 | csmoney_result_queue = CsmoneyWriter( 36 | await QueueFactory.connect_publisher(QueueNames.CSMONEY_RESULT, rabbitmq) 37 | ) 38 | 39 | storage = RedisProxyStorage(redis, RedisKeys.CSMONEY_PROXIES) 40 | proxies = await storage.get_all() 41 | 42 | csmoney_limiter = create_limiter(proxies) 43 | 44 | parser = CsmoneyParser( 45 | impl=CsmoneyParserImpl(csmoney_limiter), 46 | result_queue=csmoney_result_queue, 47 | task_scheduler=RedisTaskScheduler(redis), 48 | ) 49 | 50 | await parser.run() 51 | 52 | 53 | if __name__ == "__main__": 54 | async_run(main()) 55 | -------------------------------------------------------------------------------- /images/bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/images/bot.png -------------------------------------------------------------------------------- /images/sell_history.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/images/sell_history.jpg -------------------------------------------------------------------------------- /logging.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | disable_existing_loggers: true 3 | 4 | 5 | 6 | formatters: 7 | standard: 8 | format: "%(asctime)s - %(module)s - %(levelname)s - %(message)s" 9 | json: 10 | (): 'json_logging.JSONLogFormatter' 11 | 12 | handlers: 13 | console: 14 | class: logging.StreamHandler 15 | level: DEBUG 16 | formatter: standard 17 | stream: ext://sys.stdout 18 | 19 | root: 20 | level: INFO 21 | handlers: [console] 22 | propogate: yes 23 | -------------------------------------------------------------------------------- /price_monitoring/__init__.py: -------------------------------------------------------------------------------- 1 | from price_monitoring.logs import setup_logging 2 | 3 | setup_logging("logging.yaml") 4 | -------------------------------------------------------------------------------- /price_monitoring/async_runner.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | try: 4 | from asyncio import WindowsSelectorEventLoopPolicy 5 | except ImportError: 6 | WindowsSelectorEventLoopPolicy = None # linux version of Python doesn't contain this policy 7 | import platform 8 | from typing import Any, Coroutine 9 | 10 | try: 11 | import uvloop 12 | except ImportError: 13 | uvloop = None # uvloop doesn't support Windows 14 | 15 | 16 | def async_run(func: Coroutine[Any, Any, Any]): 17 | if platform.system() == "Windows": 18 | asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy()) 19 | else: 20 | # noinspection PyUnresolvedReferences 21 | uvloop.install() 22 | asyncio.run(func) 23 | -------------------------------------------------------------------------------- /price_monitoring/common.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | from typing import Sequence 3 | 4 | from random_user_agent.params import SoftwareName, OperatingSystem 5 | from random_user_agent.user_agent import UserAgent 6 | 7 | from proxy_http.aiohttp_session_factory import AiohttpSessionFactory 8 | from proxy_http.async_proxies_concurrent_limiter import AsyncSessionConcurrentLimiter 9 | from proxy_http.proxy import Proxy 10 | 11 | 12 | user_agent_rotator = UserAgent( 13 | software_names=[SoftwareName.CHROME.value], 14 | operating_systems=[OperatingSystem.WINDOWS.value], 15 | limit=1000, 16 | ) 17 | 18 | 19 | def _create_headers(): 20 | return { 21 | "User-Agent": user_agent_rotator.get_random_user_agent(), 22 | "Accept": "*/*", 23 | "Accept-Language": "ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3", 24 | "Accept-Encoding": "gzip, deflate, br", 25 | # 'X-Requested-With': 'XMLHttpRequest', 26 | "Connection": "keep-alive", 27 | # 'Referer': referer, 28 | "Sec-Fetch-Dest": "empty", 29 | "Sec-Fetch-Mode": "cors", 30 | "Sec-Fetch-Site": "same-origin", 31 | } 32 | 33 | 34 | def create_limiter(proxies: Sequence[Proxy]): 35 | return AsyncSessionConcurrentLimiter( 36 | [ 37 | AiohttpSessionFactory.create_session_with_proxy(proxy, headers=_create_headers()) 38 | for proxy in proxies 39 | ], 40 | time(), 41 | ) 42 | -------------------------------------------------------------------------------- /price_monitoring/constants.py: -------------------------------------------------------------------------------- 1 | class QueueNames: 2 | CSMONEY_RESULT = "csmoney_result" 3 | STEAM_MARKET_NAME = "steam_market_name" 4 | STEAM_RESULT = "steam_result" 5 | STEAM_SELL_HISTORY_RESULT = "steam_sell_history_result" 6 | 7 | 8 | class RedisKeys: 9 | STEAM_SKIN_SCHEDULE = "steam_skin_schedule" 10 | STEAM_SKIN_HISTORY_SCHEDULE = "steam_skin_history_schedule" 11 | STEAM_PROXIES = "steam_proxies" 12 | CSMONEY_PROXIES = "csmoney_proxies" 13 | CSMONEY_UNLOCKED_ITEMS = "prices:csmoney:unlocked:" 14 | CSMONEY_LOCKED_ITEMS = "prices:csmoney:locked:" 15 | 16 | 17 | class TelegramRedisKeys: 18 | WHITELIST_KEY = "telegram:whitelist" 19 | SETTINGS_KEY = "telegram:settings" 20 | -------------------------------------------------------------------------------- /price_monitoring/decorators.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import timeit 4 | from functools import wraps 5 | 6 | _INFINITE_RUN = True # used in unit-tests 7 | 8 | 9 | def async_infinite_loop(logger: logging.Logger): 10 | def decorator(func): 11 | @wraps(func) 12 | async def wrapper(*args, **kwargs): 13 | await _cycle(args, kwargs) 14 | while _INFINITE_RUN: # pragma: no cover 15 | await _cycle(args, kwargs) 16 | 17 | async def _cycle(args, kwargs): 18 | try: 19 | await func(*args, **kwargs) 20 | except Exception as exc: 21 | logger.exception(exc) 22 | await asyncio.sleep(0) 23 | 24 | return wrapper 25 | 26 | return decorator 27 | 28 | 29 | def timer(logger: logging.Logger, level: int = logging.INFO): 30 | def decorator(func): 31 | @wraps(func) 32 | async def wrapper(*args, **kwargs): 33 | start_time = timeit.default_timer() 34 | result = await func(*args, **kwargs) 35 | elapsed = round(timeit.default_timer() - start_time, 3) 36 | logger.log(level, f'Function "{func.__name__}" took {elapsed} seconds to complete.') 37 | return result 38 | 39 | return wrapper 40 | 41 | return decorator 42 | -------------------------------------------------------------------------------- /price_monitoring/features/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/price_monitoring/features/__init__.py -------------------------------------------------------------------------------- /price_monitoring/features/overpay/__init__.py: -------------------------------------------------------------------------------- 1 | from .base_price_filler import fill_base_price_storage 2 | from .generate_list import generate_list, adjust_float 3 | from .overpay_reference import OverpayReference 4 | from .overpay_sort import sort_name_by_lowest_profit, sort_each_name_by_profit 5 | 6 | __all__ = [ 7 | "fill_base_price_storage", 8 | "generate_list", 9 | "adjust_float", 10 | "OverpayReference", 11 | "sort_name_by_lowest_profit", 12 | "sort_each_name_by_profit", 13 | ] 14 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/base_price_filler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import Iterable 4 | 5 | from .csmoney import AbstractBasePriceFetcher 6 | from .storage import AbstractBasePriceStorage 7 | from ...models.csmoney import CsmoneyItemOverpay 8 | 9 | GROUP_SIZE = 10 # wiki.cs.money endpoint allows to request several name_ids once 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def _grouper(iterable: Iterable, size: int): 15 | assert size > 0 16 | result = [] 17 | subarray = [] 18 | for i, obj in enumerate(iterable, start=1): 19 | subarray.append(obj) 20 | if i % size == 0: 21 | result.append(subarray) 22 | subarray = [] 23 | if subarray: 24 | result.append(subarray) 25 | return result 26 | 27 | 28 | async def fill_base_price_storage( 29 | overpays: list[CsmoneyItemOverpay], 30 | base_price_storage: AbstractBasePriceStorage, 31 | base_price_fetcher: AbstractBasePriceFetcher, 32 | ): 33 | existed_market_names = (await base_price_storage.get_all()).keys() 34 | new_name_ids = { 35 | overpay.name_id: overpay.market_name 36 | for overpay in overpays 37 | if overpay.market_name not in existed_market_names 38 | } 39 | 40 | tasks = [] 41 | for group in _grouper(new_name_ids, GROUP_SIZE): 42 | try: 43 | base_prices = await base_price_fetcher.get(group) 44 | except ValueError: 45 | logger.warning(f"Failed to get base prices for {group}") 46 | continue 47 | for name_id, base_price in base_prices.items(): 48 | market_name = new_name_ids[name_id] 49 | tasks.append( 50 | asyncio.create_task(base_price_storage.update_item(market_name, base_price)) 51 | ) 52 | 53 | await asyncio.gather(*tasks) 54 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/csmoney/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_base_price_fetcher import AbstractBasePriceFetcher 2 | from .base_price_fetcher import BasePriceFetcher 3 | from .overpay_calculator import compute_accept_price 4 | 5 | __all__ = [ 6 | "AbstractBasePriceFetcher", 7 | "BasePriceFetcher", 8 | "compute_accept_price", 9 | ] 10 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/csmoney/abstract_base_price_fetcher.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from ....types import NameId 4 | 5 | 6 | class AbstractBasePriceFetcher(ABC): 7 | @abstractmethod 8 | async def get(self, name_ids: list[NameId]) -> dict[NameId, float]: 9 | pass 10 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/csmoney/overpay_calculator.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | from decimal import Decimal 3 | 4 | 5 | def _floor2(number: Decimal) -> Decimal: 6 | return number.quantize(Decimal("1.00"), decimal.ROUND_FLOOR) 7 | 8 | 9 | def compute_accept_price(base_price: float, overpay: float, commission: float = 0.07) -> float: 10 | base_price = Decimal(str(base_price)) 11 | overpay = Decimal(str(overpay)) 12 | commission = Decimal(str(commission)) 13 | 14 | multiplier = Decimal(1) - commission 15 | 16 | new_base_price = _floor2(base_price * multiplier) 17 | new_overpay = _floor2(overpay * multiplier) 18 | accept_price = _floor2(new_base_price + new_overpay) 19 | return float(accept_price) 20 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/generate_list.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | import urllib.parse 3 | 4 | from .overpay_reference import OverpayReference 5 | 6 | _FLOAT_ADJUST = decimal.Decimal("1.01") 7 | _FLOAT_ROUND = decimal.Decimal("1." + "0" * 5) 8 | 9 | 10 | def adjust_float(float_: str) -> str: 11 | number = decimal.Decimal(float_) * _FLOAT_ADJUST 12 | number = number.quantize(_FLOAT_ROUND, decimal.ROUND_FLOOR) 13 | return str(number.normalize()) 14 | 15 | 16 | def generate_list(overpays: list[OverpayReference]) -> list[str]: 17 | def _generate(overpay: OverpayReference) -> str: 18 | quoted_name = urllib.parse.quote(overpay.market_name) 19 | adjusted_float = adjust_float(overpay.float_) 20 | return ( 21 | f"https://steamcommunity.com/market/listings/730/" 22 | f"{quoted_name} {adjusted_float} ${overpay.sell_price}" 23 | ) 24 | 25 | return [_generate(x) for x in overpays if x.float_ < "0.3"] 26 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/overpay_reference.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from marshmallow_dataclass import add_schema 4 | 5 | from common.core.dataclass_json import JsonMixin 6 | from .csmoney import compute_accept_price 7 | from ...types import MarketName 8 | 9 | 10 | @add_schema 11 | @dataclass 12 | class OverpayReference(JsonMixin): 13 | market_name: MarketName 14 | float_: str 15 | overpay: float 16 | base_price: float 17 | sell_price: float 18 | 19 | def compute_accept_price(self) -> float: 20 | return compute_accept_price(self.base_price, self.overpay) 21 | 22 | def compute_profit(self) -> float: 23 | return compute_accept_price(self.base_price, self.overpay) - self.sell_price 24 | 25 | def compute_perc_profit(self) -> float: 26 | return round(self.compute_profit() / self.sell_price * 100, 2) 27 | 28 | def __str__(self): 29 | return ( 30 | f"{self.market_name:70} {self.float_:20} {self.compute_accept_price():10.2f} " 31 | f"{self.sell_price:10.2f} {self.compute_perc_profit():10.2f}%" 32 | ) 33 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/overpay_sort.py: -------------------------------------------------------------------------------- 1 | from typing import TypeAlias 2 | 3 | from .overpay_reference import OverpayReference 4 | from ...types import MarketName 5 | 6 | OverpayTuple: TypeAlias = tuple[MarketName, list[OverpayReference]] 7 | SkinOverpays: TypeAlias = dict[MarketName, list[OverpayReference]] 8 | SkinOverpay: TypeAlias = dict[MarketName, OverpayReference] 9 | 10 | 11 | def _sort_key(overpay: OverpayReference): 12 | return overpay.compute_perc_profit() 13 | 14 | 15 | def _sort_key_for_list_max(tuple_: OverpayTuple) -> float: 16 | return max(map(_sort_key, tuple_[1])) 17 | 18 | 19 | def _sort_key_for_list_min(tuple_: OverpayTuple) -> float: 20 | return min(map(_sort_key, tuple_[1])) 21 | 22 | 23 | def sort_each_name_by_profit(dict_: SkinOverpays) -> SkinOverpays: 24 | # noinspection PyTypeChecker 25 | sorted_names = sorted(dict_.items(), key=_sort_key_for_list_max, reverse=True) 26 | sorted_each_name = {k: sorted(v, key=_sort_key, reverse=True) for k, v in sorted_names} 27 | return sorted_each_name 28 | 29 | 30 | def sort_name_by_lowest_profit(dict_: SkinOverpays) -> SkinOverpay: 31 | # noinspection PyTypeChecker 32 | sorted_names = sorted(dict_.items(), key=_sort_key_for_list_min, reverse=True) 33 | sorted_each_name = {k: sorted(v, key=_sort_key, reverse=True)[-1] for k, v in sorted_names} 34 | return sorted_each_name 35 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_base_price import AbstractBasePriceStorage 2 | from .abstract_overpay import AbstractOverpayStorage 3 | from .redis_base_price import RedisBasePriceStorage 4 | from .redis_overpay import RedisOverpayStorage 5 | 6 | __all__ = [ 7 | "AbstractBasePriceStorage", 8 | "AbstractOverpayStorage", 9 | "RedisBasePriceStorage", 10 | "RedisOverpayStorage", 11 | ] 12 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/storage/abstract_base_price.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from ....types import MarketName 4 | 5 | 6 | class AbstractBasePriceStorage(ABC): 7 | @abstractmethod 8 | async def update_item(self, market_name: MarketName, base_price: float): 9 | pass 10 | 11 | @abstractmethod 12 | async def get_all(self) -> dict[MarketName, float]: 13 | pass 14 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/storage/abstract_overpay.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from ....models.csmoney import CsmoneyItemOverpay 4 | 5 | 6 | class AbstractOverpayStorage(ABC): 7 | @abstractmethod 8 | async def add_overpay(self, item_overpay: CsmoneyItemOverpay): 9 | pass 10 | 11 | @abstractmethod 12 | async def get_all(self) -> list[CsmoneyItemOverpay]: 13 | pass 14 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/storage/redis_base_price.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from aioredis import Redis 4 | 5 | from .abstract_base_price import AbstractBasePriceStorage 6 | from ....types import MarketName 7 | 8 | _BASE_PRICE_TTL = timedelta(hours=12) 9 | 10 | 11 | def _pattern() -> str: 12 | return "base_price:csmoney:*" 13 | 14 | 15 | def _key(market_name: str) -> str: 16 | return f"base_price:csmoney:{market_name}" 17 | 18 | 19 | def _extract_market_name(key: str) -> MarketName: 20 | market_name = key.rsplit(":", 1)[1] 21 | return market_name 22 | 23 | 24 | class RedisBasePriceStorage(AbstractBasePriceStorage): 25 | def __init__(self, redis: Redis): 26 | self._redis = redis 27 | 28 | async def update_item(self, market_name: MarketName, base_price: float): 29 | key = _key(market_name) 30 | await self._redis.set(key, str(base_price), ex=_BASE_PRICE_TTL) 31 | 32 | async def get_all(self) -> dict[MarketName, float]: 33 | pattern = _pattern() 34 | keys = await self._redis.keys(pattern) 35 | values = await self._redis.mget(keys) 36 | result = {} 37 | for key, value in zip(keys, values): 38 | if not value: 39 | continue # pragma: no cover 40 | base_price = float(value.decode()) 41 | market_name = _extract_market_name(key.decode()) 42 | result[market_name] = base_price 43 | return result 44 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/storage/redis_overpay.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from aioredis import Redis 4 | 5 | from .abstract_overpay import AbstractOverpayStorage 6 | from ....models.csmoney import CsmoneyItemOverpay 7 | 8 | _OVERPAY_ITEM_TTL = timedelta(days=7) 9 | 10 | 11 | def _pattern() -> str: 12 | return "overpay:csmoney:*:*" 13 | 14 | 15 | def _key(market_name: str, float_: str) -> str: 16 | return f"overpay:csmoney:{market_name}:{float_}" 17 | 18 | 19 | class RedisOverpayStorage(AbstractOverpayStorage): 20 | def __init__(self, redis: Redis): 21 | self._redis = redis 22 | 23 | async def add_overpay(self, item_overpay: CsmoneyItemOverpay): 24 | key = _key(item_overpay.market_name, item_overpay.float_) 25 | data = item_overpay.dump_bytes() 26 | await self._redis.set(key, data, ex=_OVERPAY_ITEM_TTL) 27 | 28 | async def get_all(self) -> list[CsmoneyItemOverpay]: 29 | pattern = _pattern() 30 | keys = await self._redis.keys(pattern) 31 | values = await self._redis.mget(keys) 32 | result = [CsmoneyItemOverpay.load_bytes(value) for value in values if value] 33 | return result 34 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/worker/__init__.py: -------------------------------------------------------------------------------- 1 | from .overpay_extractor import OverpayExtractor 2 | 3 | __all__ = ["OverpayExtractor"] 4 | -------------------------------------------------------------------------------- /price_monitoring/features/overpay/worker/overpay_extractor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from ..storage import AbstractOverpayStorage 5 | from ....models.csmoney import CsmoneyItemPack, CsmoneyItemOverpay, CsmoneyItemCategory 6 | from ....worker.processing import AbstractCsmoneyItemProcessor 7 | 8 | 9 | _IGNORE_CATEGORIES = {CsmoneyItemCategory.KNIFE, CsmoneyItemCategory.GLOVE} 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class OverpayExtractor(AbstractCsmoneyItemProcessor): 14 | def __init__(self, overpay_storage: AbstractOverpayStorage): 15 | self._overpay_storage = overpay_storage 16 | 17 | async def process(self, pack: CsmoneyItemPack) -> None: 18 | await asyncio.gather( 19 | *[ 20 | self._overpay_storage.add_overpay( 21 | CsmoneyItemOverpay( 22 | market_name=item.name, 23 | float_=item.float_, 24 | name_id=item.name_id, 25 | overpay=item.overpay_float, 26 | ) 27 | ) 28 | for item in pack.items 29 | if item.overpay_float and item.float_ 30 | if item.type_ not in _IGNORE_CATEGORIES 31 | ] 32 | ) 33 | logger.info(f"Checked {len(pack.items)} cs.money items for overpay") 34 | -------------------------------------------------------------------------------- /price_monitoring/logs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | import os 4 | 5 | import yaml 6 | 7 | 8 | def setup_logging( 9 | default_path="logging.yaml", default_level=logging.INFO, env_key="LOG_CFG" 10 | ): # pragma: no cover 11 | path = default_path 12 | value = os.getenv(env_key, None) 13 | if value: 14 | path = value 15 | if os.path.exists(path): 16 | with open(path, "rt", encoding="utf8") as f: 17 | try: 18 | config = yaml.safe_load(f.read()) 19 | logging.config.dictConfig(config) 20 | except Exception as exc: 21 | print(exc) 22 | print("Error in Logging Configuration. Using default configs") 23 | logging.basicConfig(level=default_level) 24 | else: 25 | logging.basicConfig(level=default_level) 26 | print("Failed to load configuration file. Using default configs") 27 | -------------------------------------------------------------------------------- /price_monitoring/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/price_monitoring/models/__init__.py -------------------------------------------------------------------------------- /price_monitoring/models/csmoney.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from enum import Enum 4 | from typing import Optional, List 5 | 6 | from marshmallow_dataclass import add_schema 7 | 8 | from common.core.dataclass_json import JsonMixin 9 | from ..types import MarketName, NameId 10 | 11 | 12 | class CsmoneyItemCategory(Enum): 13 | KEY = 1 14 | KNIFE = 2 15 | RIFLE = 3 16 | SNIPER_RIFLE = 4 17 | PISTOL = 5 18 | SMG = 6 19 | SHOTGUN = 7 20 | MACHINE_GUN = 8 21 | PIN = 9 22 | STICKER = 10 23 | MUSIC_KIT = 11 24 | CASE = 12 25 | GLOVE = 13 26 | GRAFFITI = 14 27 | NAMETAG = 16 28 | AGENT = 18 29 | PATCH = 19 30 | ZEUS = 20 31 | 32 | 33 | @add_schema 34 | @dataclass 35 | class CsmoneyItem(JsonMixin): 36 | name: MarketName 37 | price: float # price including overpay 38 | asset_id: str 39 | name_id: NameId 40 | type_: CsmoneyItemCategory 41 | float_: Optional[str] = None 42 | unlock_timestamp: Optional[datetime] = None 43 | overpay_float: Optional[float] = None 44 | 45 | 46 | @add_schema 47 | @dataclass 48 | class CsmoneyItemPack(JsonMixin): 49 | items: List[CsmoneyItem] = field(default_factory=list) 50 | 51 | 52 | @add_schema 53 | @dataclass 54 | class CsmoneyTask(JsonMixin): 55 | # note that an offset parameter should be removed from a URL 56 | # e.g.: https://inventories.cs.money/5.0/load_bots_inventory/730?limit=60&withStack=true 57 | url: str 58 | 59 | 60 | @add_schema 61 | @dataclass 62 | class CsmoneyItemOverpay(JsonMixin): 63 | market_name: MarketName 64 | name_id: NameId 65 | float_: str 66 | overpay: float 67 | -------------------------------------------------------------------------------- /price_monitoring/models/steam.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from marshmallow_dataclass import add_schema 4 | 5 | from common.core.dataclass_json import JsonMixin, FastJsonMixin 6 | from ..types import MarketName 7 | 8 | 9 | @add_schema 10 | @dataclass 11 | class SteamSkinHistogram(JsonMixin): 12 | market_name: MarketName 13 | response: dict 14 | 15 | 16 | @add_schema 17 | @dataclass 18 | class MarketNamePack(JsonMixin): 19 | items: list[MarketName] = field(default_factory=list) 20 | 21 | 22 | @add_schema 23 | @dataclass 24 | class SteamSellHistory(JsonMixin): 25 | market_name: MarketName 26 | encoded_data: str 27 | 28 | 29 | @add_schema 30 | @dataclass 31 | class SkinSellHistory(FastJsonMixin): 32 | market_name: MarketName 33 | is_stable: bool 34 | sold_per_week: int 35 | summary: dict[float, float] # key: price; value: percentage cover 36 | 37 | def __init__( 38 | self, 39 | market_name: MarketName, 40 | is_stable: bool, 41 | sold_per_week: int, 42 | summary: dict[float, float] | dict[str, float], 43 | ): 44 | self.market_name = market_name 45 | self.is_stable = is_stable 46 | self.sold_per_week = sold_per_week 47 | self.summary = {float(k): v for k, v in summary.items()} 48 | 49 | def get(self, max_level: float) -> float | None: 50 | last = None 51 | for price, coverage in self.summary.items(): 52 | if max_level <= coverage: 53 | last = price 54 | else: 55 | break 56 | return last 57 | -------------------------------------------------------------------------------- /price_monitoring/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_parser import AbstractParser 2 | 3 | __all__ = [ 4 | "AbstractParser", 5 | ] 6 | -------------------------------------------------------------------------------- /price_monitoring/parsers/abstract_parser.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | 4 | class AbstractParser(ABC): 5 | @abstractmethod 6 | async def run(self) -> None: 7 | ... 8 | -------------------------------------------------------------------------------- /price_monitoring/parsers/csmoney/__init__.py: -------------------------------------------------------------------------------- 1 | from .csmoney_parser import CsmoneyParser 2 | 3 | __all__ = ["CsmoneyParser"] 4 | -------------------------------------------------------------------------------- /price_monitoring/parsers/csmoney/csmoney_parser.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from .parser import AbstractCsmoneyParser 5 | from .task_scheduler import RedisTaskScheduler 6 | from ..abstract_parser import AbstractParser 7 | from ...decorators import async_infinite_loop 8 | from ...queues import AbstractCsmoneyWriter 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class CsmoneyParser(AbstractParser): 14 | def __init__( 15 | self, 16 | impl: AbstractCsmoneyParser, 17 | result_queue: AbstractCsmoneyWriter, 18 | task_scheduler: RedisTaskScheduler, 19 | ): 20 | self._impl = impl 21 | self._result_queue = result_queue 22 | self._task_scheduler = task_scheduler 23 | 24 | @async_infinite_loop(logger) 25 | async def run(self) -> None: 26 | csm_task = await self._task_scheduler.get_task() 27 | if not csm_task: 28 | await asyncio.sleep(5) 29 | return 30 | 31 | try: 32 | logger.info("Start to work with a task") 33 | task = asyncio.create_task(self._impl.parse(csm_task.url, self._result_queue)) 34 | while not task.done(): 35 | await self._task_scheduler.renew_task_lock(csm_task) 36 | logger.info("Lock for task successfully renewed") 37 | await asyncio.sleep(10) 38 | is_failed = task.cancelled() or (task.exception() is not None) 39 | if task.exception(): 40 | logger.exception( 41 | "Got an exception while parsing cs.money", exc_info=task.exception() 42 | ) 43 | await self._task_scheduler.release_task(csm_task, not is_failed) 44 | logging.info(f"Lock for task successfully released as {is_failed=}") 45 | except Exception as exc: 46 | await self._task_scheduler.release_task(csm_task, False) 47 | logger.exception("Lock for task successfully released as failed", exc_info=exc) 48 | raise exc 49 | -------------------------------------------------------------------------------- /price_monitoring/parsers/csmoney/parser/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_parser import AbstractCsmoneyParser 2 | from .parser import CsmoneyParserImpl 3 | 4 | __all__ = [ 5 | "AbstractCsmoneyParser", 6 | "CsmoneyParserImpl", 7 | ] 8 | -------------------------------------------------------------------------------- /price_monitoring/parsers/csmoney/parser/_name_patcher.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from ....types import MarketName 4 | 5 | _PATTERN = r" Doppler ((Phase \d+)|Sapphire|Ruby|Black Pearl|Emerald)" 6 | 7 | 8 | def patch_market_name(market_name: str) -> MarketName: 9 | return re.sub(_PATTERN, " Doppler", market_name) 10 | -------------------------------------------------------------------------------- /price_monitoring/parsers/csmoney/parser/abstract_parser.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from ....queues import AbstractCsmoneyWriter 4 | 5 | 6 | class MaxAttemptsReachedError(Exception): 7 | pass 8 | 9 | 10 | class AbstractCsmoneyParser(ABC): 11 | @abstractmethod 12 | async def parse( 13 | self, url: str, result_queue: AbstractCsmoneyWriter, max_attempts: int = 10 14 | ) -> None: 15 | ... 16 | -------------------------------------------------------------------------------- /price_monitoring/parsers/csmoney/task_scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | from .redis_task_scheduler import RedisTaskScheduler 2 | 3 | __all__ = [ 4 | "RedisTaskScheduler", 5 | ] 6 | -------------------------------------------------------------------------------- /price_monitoring/parsers/csmoney/task_scheduler/redis_task_scheduler.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import hashlib 3 | 4 | from aioredis import Redis 5 | 6 | from ....models.csmoney import CsmoneyTask 7 | 8 | _SET_KEY = "csmoney_task_schedule" 9 | _POSTPONE = datetime.timedelta(minutes=2) 10 | _LOCK_DURATION = datetime.timedelta(seconds=120) 11 | 12 | 13 | def _lock_key(market_name: str) -> str: 14 | return f"csmoney_task_lock:{hashlib.sha256(market_name.encode()).hexdigest()}" 15 | 16 | 17 | class RenewFailedError(Exception): 18 | pass 19 | 20 | 21 | class RedisTaskScheduler: 22 | def __init__(self, redis: Redis): 23 | self._redis = redis 24 | 25 | async def append_task(self, task: CsmoneyTask) -> None: 26 | unixtime = datetime.datetime.now().timestamp() 27 | await self._redis.zadd(name=_SET_KEY, mapping={task.dumps(): unixtime}, nx=True) 28 | 29 | async def delete_task(self, task: CsmoneyTask) -> None: 30 | await self._redis.zrem(_SET_KEY, task.dumps()) 31 | 32 | async def clear(self) -> None: 33 | await self._redis.delete(_SET_KEY) 34 | 35 | async def get_task(self) -> CsmoneyTask | None: 36 | unixtime = datetime.datetime.now().timestamp() 37 | tasks = await self._redis.zrangebyscore( 38 | name=_SET_KEY, min="-inf", max=unixtime, start=0, num=10 39 | ) # 10 is enough 40 | for task in tasks: 41 | task = task.decode() 42 | is_success = await self._redis.set( 43 | name=_lock_key(task), nx=True, ex=_LOCK_DURATION, value=1 44 | ) 45 | if is_success: 46 | return CsmoneyTask.loads(task) 47 | return None 48 | 49 | async def renew_task_lock(self, task: CsmoneyTask) -> None: 50 | if not await self._redis.set( 51 | name=_lock_key(task.dumps()), xx=True, ex=_LOCK_DURATION, value=1 52 | ): 53 | raise RenewFailedError() 54 | 55 | async def release_task(self, task: CsmoneyTask, is_success: bool) -> None: 56 | data = task.dumps() 57 | if is_success: 58 | unixtime = (datetime.datetime.now() + _POSTPONE).timestamp() 59 | await self._redis.zadd(name=_SET_KEY, mapping={data: unixtime}, xx=True) 60 | await self._redis.delete(_lock_key(data)) 61 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/__init__.py: -------------------------------------------------------------------------------- 1 | from .steam_order_parser import SteamOrderParser 2 | from .steam_sell_history_parser import SteamSellHistoryParser 3 | 4 | __all__ = [ 5 | "SteamOrderParser", 6 | "SteamSellHistoryParser", 7 | ] 8 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/name_resolver/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_name_resolver import AbstractNameResolver, SkinNotFoundError 2 | from .memory_cached_name_resolver import MemoryCachedNameResolver 3 | from .name_resolver import NameResolver 4 | from .redis_cached_name_resolver import RedisCachedNameResolver 5 | 6 | __all__ = [ 7 | "AbstractNameResolver", 8 | "SkinNotFoundError", 9 | "MemoryCachedNameResolver", 10 | "NameResolver", 11 | "RedisCachedNameResolver", 12 | ] 13 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/name_resolver/abstract_name_resolver.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | from ....types import MarketName, ItemNameId 4 | 5 | 6 | class SkinNotFoundError(Exception): 7 | def __init__(self, market_name: MarketName): 8 | super().__init__() 9 | self._market_name = market_name 10 | 11 | def __str__(self): 12 | return self._market_name 13 | 14 | 15 | class AbstractNameResolver(ABC): 16 | @abstractmethod 17 | async def resolve_market_name(self, market_name: MarketName) -> ItemNameId: 18 | ... 19 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/name_resolver/memory_cached_name_resolver.py: -------------------------------------------------------------------------------- 1 | from .abstract_name_resolver import AbstractNameResolver, SkinNotFoundError 2 | from ....types import MarketName, ItemNameId 3 | 4 | 5 | class MemoryCachedNameResolver(AbstractNameResolver): 6 | def __init__(self, resolver: AbstractNameResolver): 7 | self._resolver = resolver 8 | self._cache: dict[str, ItemNameId] = {} 9 | 10 | async def resolve_market_name(self, market_name: MarketName) -> ItemNameId: 11 | if market_name not in self._cache: 12 | try: 13 | self._cache[market_name] = await self._resolver.resolve_market_name(market_name) 14 | except SkinNotFoundError: 15 | self._cache[market_name] = -1 16 | if self._cache[market_name] == -1: 17 | raise SkinNotFoundError(market_name) 18 | return self._cache[market_name] 19 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/name_resolver/name_resolver.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from proxy_http.async_proxies_concurrent_limiter import AsyncSessionConcurrentLimiter 4 | from .abstract_name_resolver import AbstractNameResolver, SkinNotFoundError 5 | from ....types import MarketName, ItemNameId 6 | 7 | _RESPONSE_TIMEOUT = 15 8 | _NAME_RESOLVE_POSTPONE_DURATION = 30 9 | 10 | 11 | def _generate_steam_market_url(market_name: MarketName) -> str: 12 | return f"https://steamcommunity.com/market/listings/730/{market_name}" 13 | 14 | 15 | class NameResolver(AbstractNameResolver): 16 | def __init__(self, limiter: AsyncSessionConcurrentLimiter): 17 | self._limiter = limiter 18 | 19 | async def resolve_market_name(self, market_name: MarketName) -> ItemNameId: 20 | session = await self._limiter.get_available(_NAME_RESOLVE_POSTPONE_DURATION) 21 | url = _generate_steam_market_url(market_name) 22 | cookies = {} # type: ignore 23 | async with session.get(url, cookies=cookies, timeout=_RESPONSE_TIMEOUT) as response: 24 | response.raise_for_status() 25 | text = await response.text() 26 | item_nameid = re.findall(r"Market_LoadOrderSpread\(\s*(\d*)\s*\);", text) 27 | if item_nameid: 28 | return int(item_nameid[0]) 29 | elif "var g_rgListingInfo = [];" in text: 30 | raise SkinNotFoundError(market_name) 31 | else: 32 | raise ValueError(text) 33 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/name_resolver/redis_cached_name_resolver.py: -------------------------------------------------------------------------------- 1 | from aioredis import Redis 2 | 3 | from .abstract_name_resolver import AbstractNameResolver, SkinNotFoundError 4 | from ....types import MarketName, ItemNameId 5 | 6 | _KEY = "steam_name_id_cache" 7 | 8 | 9 | class RedisCachedNameResolver(AbstractNameResolver): 10 | def __init__(self, resolver: AbstractNameResolver, redis: Redis): 11 | self._resolver = resolver 12 | self._redis = redis 13 | 14 | async def resolve_market_name(self, market_name: MarketName) -> ItemNameId: 15 | resp = await self._redis.hget(_KEY, market_name) 16 | if resp: 17 | name_id = int(resp.decode()) 18 | else: 19 | try: 20 | name_id = await self._resolver.resolve_market_name(market_name) 21 | except SkinNotFoundError: 22 | name_id = -1 23 | await self._redis.hset(_KEY, market_name, name_id) 24 | if name_id == -1: 25 | raise SkinNotFoundError(market_name) 26 | return name_id 27 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/parser/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_sell_history_parser import AbstractSellHistoryParser 2 | from .abstract_steam_orders_parser import AbstractSteamOrdersParser 3 | from .steam_orders_parser import SteamOrdersParser 4 | from .steam_sell_history_parser import SteamSellHistoryParser 5 | 6 | __all__ = [ 7 | "AbstractSellHistoryParser", 8 | "AbstractSteamOrdersParser", 9 | "SteamOrdersParser", 10 | "SteamSellHistoryParser", 11 | ] 12 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/parser/abstract_sell_history_parser.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from ....queues import AbstractSteamSellHistoryWriter 4 | from ....types import MarketName 5 | 6 | 7 | class AbstractSellHistoryParser(ABC): 8 | @abstractmethod 9 | async def fetch_history( 10 | self, market_name: MarketName, result_queue: AbstractSteamSellHistoryWriter 11 | ) -> bool: 12 | ... 13 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/parser/abstract_steam_orders_parser.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from ....queues import AbstractSteamOrderWriter 4 | from ....types import MarketName 5 | 6 | 7 | class AbstractSteamOrdersParser(ABC): 8 | @abstractmethod 9 | async def fetch_orders(self, market_name: MarketName, result_queue: AbstractSteamOrderWriter): 10 | ... 11 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/skin_scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_skin_scheduler import AbstractSkinScheduler 2 | from .redis_skin_scheduler import RedisSkinScheduler 3 | from .scheduler_filler import SchedulerFiller 4 | 5 | __all__ = [ 6 | "AbstractSkinScheduler", 7 | "RedisSkinScheduler", 8 | "SchedulerFiller", 9 | ] 10 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/skin_scheduler/abstract_skin_scheduler.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | from ....types import MarketName 4 | 5 | 6 | class AbstractSkinScheduler(ABC): 7 | @abstractmethod 8 | async def append_market_name(self, market_name: MarketName): 9 | ... 10 | 11 | @abstractmethod 12 | async def get_skin(self) -> MarketName | None: 13 | ... 14 | 15 | @abstractmethod 16 | async def release_skin(self, market_name: MarketName, is_success: bool) -> None: 17 | ... 18 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/skin_scheduler/redis_skin_scheduler.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from aioredis import Redis 4 | 5 | from .abstract_skin_scheduler import AbstractSkinScheduler 6 | from ....types import MarketName 7 | 8 | 9 | def _lock_key(market_name: MarketName) -> str: 10 | return f"steam_skin_lock:{market_name}" 11 | 12 | 13 | class RedisSkinScheduler(AbstractSkinScheduler): 14 | def __init__( 15 | self, 16 | redis: Redis, 17 | key: str, 18 | postpone: datetime.timedelta = datetime.timedelta(minutes=15), 19 | lock_duration: datetime.timedelta = datetime.timedelta(seconds=60), 20 | ): 21 | self._redis = redis 22 | self._key = key 23 | self._postpone = postpone 24 | self._lock_duration = lock_duration 25 | 26 | async def append_market_name(self, market_name: MarketName) -> None: 27 | unixtime = datetime.datetime.now().timestamp() 28 | await self._redis.zadd(name=self._key, mapping={market_name: unixtime}, nx=True) 29 | 30 | async def delete_skin(self, market_name: MarketName) -> None: 31 | await self._redis.zrem(self._key, market_name) 32 | 33 | async def get_skin(self) -> MarketName | None: 34 | unixtime = datetime.datetime.now().timestamp() 35 | market_names = await self._redis.zrangebyscore( 36 | name=self._key, min="-inf", max=unixtime, start=0, num=50 37 | ) # 50 is enough 38 | for market_name in market_names: 39 | market_name = market_name.decode() 40 | is_success = await self._redis.set( 41 | name=_lock_key(market_name), nx=True, ex=self._lock_duration, value=1 42 | ) 43 | if is_success: 44 | return market_name 45 | return None 46 | 47 | async def release_skin(self, market_name: MarketName, is_success: bool) -> None: 48 | if is_success: 49 | unixtime = (datetime.datetime.now() + self._postpone).timestamp() 50 | await self._redis.zadd(name=self._key, mapping={market_name: unixtime}, xx=True) 51 | await self._redis.delete(_lock_key(market_name)) 52 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/skin_scheduler/scheduler_filler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import Iterable 4 | 5 | from .abstract_skin_scheduler import AbstractSkinScheduler 6 | from ....decorators import async_infinite_loop 7 | from ....queues import AbstractMarketNameReader 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class SchedulerFiller: 13 | def __init__( 14 | self, 15 | market_name_queue: AbstractMarketNameReader, 16 | skin_schedulers: Iterable[AbstractSkinScheduler], 17 | ): 18 | self._market_name_queue = market_name_queue 19 | self._skin_schedulers = skin_schedulers 20 | 21 | async def run(self) -> None: 22 | await self._run_market_name_reader() 23 | 24 | @async_infinite_loop(logger) 25 | async def _run_market_name_reader(self) -> None: 26 | pack = await self._market_name_queue.get(timeout=10) 27 | if not pack: 28 | await asyncio.sleep(0.5) 29 | return 30 | 31 | await asyncio.gather( 32 | *[ 33 | scheduler.append_market_name(market_name) 34 | for market_name in pack.items 35 | for scheduler in self._skin_schedulers 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/steam_order_parser.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from .parser import AbstractSteamOrdersParser 5 | from .skin_scheduler import AbstractSkinScheduler 6 | from ..abstract_parser import AbstractParser 7 | from ...decorators import async_infinite_loop 8 | from ...queues import AbstractSteamOrderWriter 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class SteamOrderParser(AbstractParser): 14 | def __init__( 15 | self, 16 | parser: AbstractSteamOrdersParser, 17 | skin_scheduler: AbstractSkinScheduler, 18 | steam_result_queue: AbstractSteamOrderWriter, 19 | ): 20 | self._parser = parser 21 | self._skin_scheduler = skin_scheduler 22 | self._steam_result_queue = steam_result_queue 23 | 24 | async def run(self) -> None: 25 | await self._run_steam_parser() 26 | 27 | @async_infinite_loop(logger) 28 | async def _run_steam_parser(self) -> None: 29 | market_name = await self._skin_scheduler.get_skin() 30 | if not market_name: 31 | await asyncio.sleep(0.5) 32 | return 33 | 34 | is_success = False 35 | try: 36 | is_success = await self._parser.fetch_orders( 37 | market_name=market_name, result_queue=self._steam_result_queue 38 | ) 39 | except Exception as exc: 40 | logger.exception(exc) 41 | await self._skin_scheduler.release_skin(market_name, is_success) 42 | -------------------------------------------------------------------------------- /price_monitoring/parsers/steam/steam_sell_history_parser.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from .parser import AbstractSellHistoryParser 5 | from .skin_scheduler import AbstractSkinScheduler 6 | from ..abstract_parser import AbstractParser 7 | from ...decorators import async_infinite_loop 8 | from ...queues import AbstractSteamSellHistoryWriter 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class SteamSellHistoryParser(AbstractParser): 14 | def __init__( 15 | self, 16 | parser: AbstractSellHistoryParser, 17 | skin_scheduler: AbstractSkinScheduler, 18 | result_queue: AbstractSteamSellHistoryWriter, 19 | ): 20 | self._parser = parser 21 | self._skin_scheduler = skin_scheduler 22 | self._result_queue = result_queue 23 | 24 | async def run(self) -> None: 25 | await self._run_steam_parser() 26 | 27 | @async_infinite_loop(logger) 28 | async def _run_steam_parser(self) -> None: 29 | market_name = await self._skin_scheduler.get_skin() 30 | if not market_name: 31 | await asyncio.sleep(0.5) 32 | return 33 | 34 | is_success = False 35 | try: 36 | is_success = await self._parser.fetch_history( 37 | market_name=market_name, result_queue=self._result_queue 38 | ) 39 | except Exception as exc: 40 | logger.exception(exc) 41 | await self._skin_scheduler.release_skin(market_name, is_success) 42 | -------------------------------------------------------------------------------- /price_monitoring/queues/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_csmoney_result_queue import AbstractCsmoneyReader, AbstractCsmoneyWriter 2 | from .abstract_market_name_queue import ( 3 | AbstractMarketNameReader, 4 | AbstractMarketNameWriter, 5 | ) 6 | from .abstract_steam_order_queue import ( 7 | AbstractSteamOrderReader, 8 | AbstractSteamOrderWriter, 9 | ) 10 | from .abstract_steam_sell_history_queue import ( 11 | AbstractSteamSellHistoryReader, 12 | AbstractSteamSellHistoryWriter, 13 | ) 14 | 15 | __all__ = [ 16 | "AbstractSteamOrderReader", 17 | "AbstractSteamOrderWriter", 18 | "AbstractSteamSellHistoryWriter", 19 | "AbstractSteamSellHistoryReader", 20 | "AbstractCsmoneyReader", 21 | "AbstractCsmoneyWriter", 22 | "AbstractMarketNameReader", 23 | "AbstractMarketNameWriter", 24 | ] 25 | -------------------------------------------------------------------------------- /price_monitoring/queues/abstract_csmoney_result_queue.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | from ..models.csmoney import CsmoneyItemPack 4 | 5 | 6 | class AbstractCsmoneyReader(ABC): 7 | @abstractmethod 8 | async def get(self, timeout: int = 5) -> CsmoneyItemPack | None: 9 | ... 10 | 11 | 12 | class AbstractCsmoneyWriter(ABC): 13 | @abstractmethod 14 | async def put(self, item: CsmoneyItemPack) -> None: 15 | ... 16 | -------------------------------------------------------------------------------- /price_monitoring/queues/abstract_market_name_queue.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | from ..models.steam import MarketNamePack 4 | 5 | 6 | class AbstractMarketNameReader(ABC): 7 | @abstractmethod 8 | async def get(self, timeout: int = 5) -> MarketNamePack | None: 9 | ... 10 | 11 | 12 | class AbstractMarketNameWriter(ABC): 13 | @abstractmethod 14 | async def put(self, pack: MarketNamePack) -> None: 15 | ... 16 | -------------------------------------------------------------------------------- /price_monitoring/queues/abstract_steam_order_queue.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | from ..models.steam import SteamSkinHistogram 4 | 5 | 6 | class AbstractSteamOrderReader(ABC): 7 | @abstractmethod 8 | async def get(self, timeout: int = 5) -> SteamSkinHistogram | None: 9 | ... 10 | 11 | 12 | class AbstractSteamOrderWriter(ABC): 13 | @abstractmethod 14 | async def put(self, skin: SteamSkinHistogram) -> None: 15 | ... 16 | -------------------------------------------------------------------------------- /price_monitoring/queues/abstract_steam_sell_history_queue.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | from ..models.steam import SteamSellHistory 4 | 5 | 6 | class AbstractSteamSellHistoryReader(ABC): 7 | @abstractmethod 8 | async def get(self, timeout: int = 5) -> SteamSellHistory | None: 9 | ... 10 | 11 | 12 | class AbstractSteamSellHistoryWriter(ABC): 13 | @abstractmethod 14 | async def put(self, history: SteamSellHistory) -> None: 15 | ... 16 | -------------------------------------------------------------------------------- /price_monitoring/queues/rabbitmq/__init__.py: -------------------------------------------------------------------------------- 1 | from .csmoney_result_queue import CsmoneyReader, CsmoneyWriter 2 | from .market_name_queue import MarketNameReader, MarketNameWriter 3 | from .steam_result_queue import SteamOrderReader, SteamOrderWriter 4 | from .steam_sell_history_queue import SteamSellHistoryReader, SteamSellHistoryWriter 5 | 6 | __all__ = [ 7 | "CsmoneyReader", 8 | "CsmoneyWriter", 9 | "MarketNameReader", 10 | "MarketNameWriter", 11 | "SteamOrderReader", 12 | "SteamOrderWriter", 13 | "SteamSellHistoryReader", 14 | "SteamSellHistoryWriter", 15 | ] 16 | -------------------------------------------------------------------------------- /price_monitoring/queues/rabbitmq/csmoney_result_queue.py: -------------------------------------------------------------------------------- 1 | from common.rpc.queue_publisher import QueuePublisher 2 | from common.rpc.queue_reader import QueueReader 3 | from ..abstract_csmoney_result_queue import AbstractCsmoneyReader, AbstractCsmoneyWriter 4 | from ...models.csmoney import CsmoneyItemPack 5 | 6 | 7 | class CsmoneyReader(AbstractCsmoneyReader): 8 | def __init__(self, reader: QueueReader): 9 | self._reader = reader 10 | 11 | async def get(self, timeout: int = 5) -> CsmoneyItemPack | None: 12 | data = await self._reader.read(timeout=timeout) 13 | if data: 14 | return CsmoneyItemPack.load_bytes(data) 15 | return None 16 | 17 | 18 | class CsmoneyWriter(AbstractCsmoneyWriter): 19 | def __init__(self, publisher: QueuePublisher): 20 | self._publisher = publisher 21 | 22 | async def put(self, item: CsmoneyItemPack) -> None: 23 | data = item.dump_bytes() 24 | await self._publisher.publish(data) 25 | -------------------------------------------------------------------------------- /price_monitoring/queues/rabbitmq/market_name_queue.py: -------------------------------------------------------------------------------- 1 | from common.rpc.queue_publisher import QueuePublisher 2 | from common.rpc.queue_reader import QueueReader 3 | from ..abstract_market_name_queue import ( 4 | AbstractMarketNameReader, 5 | AbstractMarketNameWriter, 6 | ) 7 | from ...models.steam import MarketNamePack 8 | 9 | 10 | class MarketNameReader(AbstractMarketNameReader): 11 | def __init__(self, reader: QueueReader): 12 | self._reader = reader 13 | 14 | async def get(self, timeout: int = 5) -> MarketNamePack | None: 15 | data = await self._reader.read(timeout=timeout) 16 | if data: 17 | return MarketNamePack.load_bytes(data) 18 | return None 19 | 20 | 21 | class MarketNameWriter(AbstractMarketNameWriter): 22 | def __init__(self, publisher: QueuePublisher): 23 | self._publisher = publisher 24 | 25 | async def put(self, pack: MarketNamePack) -> None: 26 | data = pack.dump_bytes() 27 | await self._publisher.publish(data) 28 | -------------------------------------------------------------------------------- /price_monitoring/queues/rabbitmq/steam_result_queue.py: -------------------------------------------------------------------------------- 1 | from common.rpc.queue_publisher import QueuePublisher 2 | from common.rpc.queue_reader import QueueReader 3 | from ..abstract_steam_order_queue import ( 4 | AbstractSteamOrderReader, 5 | AbstractSteamOrderWriter, 6 | ) 7 | from ...models.steam import SteamSkinHistogram 8 | 9 | 10 | class SteamOrderReader(AbstractSteamOrderReader): 11 | def __init__(self, reader: QueueReader): 12 | self._reader = reader 13 | 14 | async def get(self, timeout: int = 5) -> SteamSkinHistogram | None: 15 | data = await self._reader.read(timeout=timeout) 16 | if data: 17 | return SteamSkinHistogram.load_bytes(data) 18 | return None 19 | 20 | 21 | class SteamOrderWriter(AbstractSteamOrderWriter): 22 | def __init__(self, publisher: QueuePublisher): 23 | self._publisher = publisher 24 | 25 | async def put(self, skin: SteamSkinHistogram) -> None: 26 | data = skin.dump_bytes() 27 | await self._publisher.publish(data) 28 | -------------------------------------------------------------------------------- /price_monitoring/queues/rabbitmq/steam_sell_history_queue.py: -------------------------------------------------------------------------------- 1 | from common.rpc.queue_publisher import QueuePublisher 2 | from common.rpc.queue_reader import QueueReader 3 | from ..abstract_steam_sell_history_queue import ( 4 | AbstractSteamSellHistoryReader, 5 | AbstractSteamSellHistoryWriter, 6 | ) 7 | from ...models.steam import SteamSellHistory 8 | 9 | 10 | class SteamSellHistoryReader(AbstractSteamSellHistoryReader): 11 | def __init__(self, reader: QueueReader): 12 | self._reader = reader 13 | 14 | async def get(self, timeout: int = 5) -> SteamSellHistory | None: 15 | data = await self._reader.read(timeout=timeout) 16 | if data: 17 | return SteamSellHistory.load_bytes(data) 18 | return None 19 | 20 | 21 | class SteamSellHistoryWriter(AbstractSteamSellHistoryWriter): 22 | def __init__(self, publisher: QueuePublisher): 23 | self._publisher = publisher 24 | 25 | async def put(self, history: SteamSellHistory) -> None: 26 | data = history.dump_bytes() 27 | await self._publisher.publish(data) 28 | -------------------------------------------------------------------------------- /price_monitoring/storage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/price_monitoring/storage/__init__.py -------------------------------------------------------------------------------- /price_monitoring/storage/csmoney/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_csmoney_item_storage import AbstractCsmoneyItemStorage 2 | from .redis_csmoney_item_storage import RedisCsmoneyItemStorage 3 | 4 | __all__ = [ 5 | "AbstractCsmoneyItemStorage", 6 | "RedisCsmoneyItemStorage", 7 | ] 8 | -------------------------------------------------------------------------------- /price_monitoring/storage/csmoney/abstract_csmoney_item_storage.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from ...types import MarketName 4 | 5 | 6 | class AbstractCsmoneyItemStorage(ABC): 7 | @abstractmethod 8 | async def update_item(self, market_name: MarketName, item_price: float) -> None: 9 | ... 10 | 11 | @abstractmethod 12 | async def get_all(self) -> dict[MarketName, float]: 13 | ... 14 | 15 | @property 16 | @abstractmethod 17 | def is_trade_ban(self) -> bool: 18 | ... 19 | -------------------------------------------------------------------------------- /price_monitoring/storage/csmoney/redis_csmoney_item_storage.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from aioredis import Redis 4 | 5 | from common.tracer import trace, annotate 6 | from .abstract_csmoney_item_storage import AbstractCsmoneyItemStorage 7 | from ...types import MarketName 8 | 9 | _ITEM_TTL = timedelta(minutes=60) 10 | 11 | 12 | def _pattern(prefix: str, market_name: MarketName) -> str: 13 | return f"{prefix}{market_name}:*" 14 | 15 | 16 | def _key(prefix: str, market_name: MarketName, price: float) -> str: 17 | return f"{prefix}{market_name}:{price}" 18 | 19 | 20 | def _extract_market_name(key: str, full_key: str) -> MarketName: 21 | suffix = full_key.removeprefix(key) 22 | market_name = suffix.rsplit(":", maxsplit=1)[0] 23 | return market_name 24 | 25 | 26 | def _extract_price(prefix: str, full_key: str) -> float: 27 | suffix = full_key.removeprefix(prefix) 28 | price = suffix.rsplit(":", maxsplit=1)[-1] 29 | return float(price) 30 | 31 | 32 | class RedisCsmoneyItemStorage(AbstractCsmoneyItemStorage): 33 | def __init__(self, redis: Redis, prefix: str, trade_ban: bool): 34 | self._redis = redis 35 | self._prefix = prefix 36 | self._trade_ban = trade_ban 37 | 38 | async def update_item(self, market_name: MarketName, item_price: float) -> None: 39 | key = _key(self._prefix, market_name, item_price) 40 | await self._redis.set(key, 1, ex=_ITEM_TTL) 41 | 42 | @trace 43 | async def get_all(self) -> dict[MarketName, float]: 44 | pattern = _pattern(self._prefix, "*") 45 | keys = [x.decode() for x in await self._redis.keys(pattern)] 46 | annotate(f"Loaded {len(keys)} from redis") 47 | result = {} 48 | for key in keys: 49 | market_name = _extract_market_name(self._prefix, key) 50 | price = _extract_price(self._prefix, key) 51 | if market_name not in result: 52 | result[market_name] = price 53 | elif result[market_name] > price: 54 | result[market_name] = price 55 | return result 56 | 57 | @property 58 | def is_trade_ban(self) -> bool: 59 | return self._trade_ban 60 | -------------------------------------------------------------------------------- /price_monitoring/storage/proxy/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_proxy_storage import AbstractProxyStorage 2 | from .redis_proxy_storage import RedisProxyStorage 3 | 4 | __all__ = [ 5 | "AbstractProxyStorage", 6 | "RedisProxyStorage", 7 | ] 8 | -------------------------------------------------------------------------------- /price_monitoring/storage/proxy/abstract_proxy_storage.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from proxy_http.proxy import Proxy 4 | 5 | 6 | class AbstractProxyStorage(ABC): 7 | @abstractmethod 8 | async def add(self, proxy: Proxy) -> None: 9 | ... 10 | 11 | @abstractmethod 12 | async def get_all(self) -> list[Proxy]: 13 | ... 14 | 15 | @abstractmethod 16 | async def remove(self, proxy: Proxy) -> None: 17 | ... 18 | -------------------------------------------------------------------------------- /price_monitoring/storage/proxy/redis_proxy_storage.py: -------------------------------------------------------------------------------- 1 | from aioredis import Redis 2 | 3 | from proxy_http.proxy import Proxy 4 | from .abstract_proxy_storage import AbstractProxyStorage 5 | 6 | 7 | class RedisProxyStorage(AbstractProxyStorage): 8 | def __init__(self, redis: Redis, key: str): 9 | self._redis = redis 10 | self._key = key 11 | 12 | async def add(self, proxy: Proxy) -> None: 13 | await self._redis.sadd(self._key, proxy.dumps()) 14 | 15 | async def get_all(self) -> list[Proxy]: 16 | return [Proxy.load_bytes(x) for x in await self._redis.smembers(self._key)] 17 | 18 | async def remove(self, proxy: Proxy) -> None: 19 | await self._redis.srem(self._key, proxy.dumps()) 20 | -------------------------------------------------------------------------------- /price_monitoring/storage/steam/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_steam_orders_storage import AbstractSteamOrdersStorage 2 | from .abstract_steam_sell_history_storage import AbstractSteamSellHistoryStorage 3 | from .redis_steam_orders_storage import RedisSteamOrdersStorage 4 | from .redis_steam_sell_history_storage import RedisSteamSellHistoryStorage 5 | 6 | __all__ = [ 7 | "AbstractSteamOrdersStorage", 8 | "AbstractSteamSellHistoryStorage", 9 | "RedisSteamOrdersStorage", 10 | "RedisSteamSellHistoryStorage", 11 | ] 12 | -------------------------------------------------------------------------------- /price_monitoring/storage/steam/abstract_steam_orders_storage.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | from ...types import MarketName, BuySellOrders 4 | 5 | 6 | class AbstractSteamOrdersStorage(ABC): 7 | @abstractmethod 8 | async def get_all(self) -> dict[MarketName, BuySellOrders]: 9 | ... 10 | 11 | @abstractmethod 12 | async def update_skin_order( 13 | self, market_name: MarketName, buy_order: float | None, sell_order: float | None 14 | ) -> None: 15 | ... 16 | -------------------------------------------------------------------------------- /price_monitoring/storage/steam/abstract_steam_sell_history_storage.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | from ...models.steam import SkinSellHistory 4 | from ...types import MarketName 5 | 6 | 7 | class AbstractSteamSellHistoryStorage(ABC): 8 | @abstractmethod 9 | async def get_all(self) -> dict[MarketName, SkinSellHistory]: 10 | ... 11 | 12 | @abstractmethod 13 | async def update_skin(self, history: SkinSellHistory) -> None: 14 | ... 15 | -------------------------------------------------------------------------------- /price_monitoring/storage/steam/redis_steam_orders_storage.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from aioredis import Redis 4 | 5 | from common.tracer import trace, annotate 6 | from .abstract_steam_orders_storage import AbstractSteamOrdersStorage 7 | from ...types import MarketName, BuySellOrders, OrderPrice 8 | 9 | _ITEM_TTL = timedelta(hours=4) 10 | 11 | 12 | def _pattern(market_name: MarketName) -> str: 13 | return f"prices:steam:{market_name}" 14 | 15 | 16 | def _key(market_name: MarketName) -> str: 17 | return f"prices:steam:{market_name}" 18 | 19 | 20 | def _value(buy_order: OrderPrice, sell_order: OrderPrice) -> str: 21 | return f"{buy_order}:{sell_order}" 22 | 23 | 24 | def _extract_market_name(key: str) -> MarketName: 25 | parts = key.split(":", 2) 26 | market_name = parts[-1] 27 | return market_name 28 | 29 | 30 | def _extract_orders(key: str) -> BuySellOrders: 31 | parts = key.split(":") 32 | buy = parts[-2] 33 | sell = parts[-1] 34 | buy = None if buy == "None" else float(buy) 35 | sell = None if sell == "None" else float(sell) 36 | return buy, sell 37 | 38 | 39 | class RedisSteamOrdersStorage(AbstractSteamOrdersStorage): 40 | def __init__(self, redis: Redis): 41 | self._redis = redis 42 | 43 | @trace 44 | async def get_all(self) -> dict[MarketName, BuySellOrders]: 45 | pattern = _pattern("*") 46 | keys = [x.decode() for x in await self._redis.keys(pattern)] 47 | annotate(f"Loaded {len(keys)} keys") 48 | values = [x.decode() if x else None for x in await self._redis.mget(keys)] 49 | annotate(f"Loaded {len(values)} values") 50 | return { 51 | _extract_market_name(key): _extract_orders(value) 52 | for key, value in zip(keys, values) 53 | if value 54 | } 55 | 56 | async def update_skin_order( 57 | self, market_name: MarketName, buy_order: OrderPrice, sell_order: OrderPrice 58 | ) -> None: 59 | key = _key(market_name) 60 | value = _value(buy_order, sell_order) 61 | await self._redis.set(key, value, ex=_ITEM_TTL) 62 | -------------------------------------------------------------------------------- /price_monitoring/storage/steam/redis_steam_sell_history_storage.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from aioredis import Redis 4 | 5 | from common.tracer import trace, annotate 6 | from .abstract_steam_sell_history_storage import AbstractSteamSellHistoryStorage 7 | from ...models.steam import SkinSellHistory 8 | from ...types import MarketName 9 | 10 | _ITEM_TTL = timedelta(hours=18) 11 | 12 | 13 | def _pattern(market_name: MarketName) -> str: 14 | return f"sell_history:steam:{market_name}" 15 | 16 | 17 | def _key(market_name: MarketName) -> str: 18 | return f"sell_history:steam:{market_name}" 19 | 20 | 21 | def _extract_market_name(key: str) -> MarketName: 22 | parts = key.split(":", 2) 23 | market_name = parts[-1] 24 | return market_name 25 | 26 | 27 | class RedisSteamSellHistoryStorage(AbstractSteamSellHistoryStorage): 28 | def __init__(self, redis: Redis): 29 | self._redis = redis 30 | 31 | @trace 32 | async def get_all(self) -> dict[MarketName, SkinSellHistory]: 33 | pattern = _pattern("*") 34 | keys = [x.decode() for x in await self._redis.keys(pattern)] 35 | annotate(f"Loaded {len(keys)} keys") 36 | values = await self._redis.mget(keys) 37 | annotate(f"Loaded {len(values)} keys") 38 | return { 39 | _extract_market_name(key): SkinSellHistory.load_bytes(value) 40 | for key, value in zip(keys, values) 41 | if value 42 | } 43 | 44 | async def update_skin(self, history: SkinSellHistory) -> None: 45 | key = _key(history.market_name) 46 | value = history.dump_bytes() 47 | await self._redis.set(key, value, ex=_ITEM_TTL) 48 | -------------------------------------------------------------------------------- /price_monitoring/telegram/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/price_monitoring/telegram/__init__.py -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_bot import AbstractBot 2 | from .abstract_settings import AbstractSettings 3 | from .abstract_whitelist import AbstractWhitelist 4 | from .aiogram_bot import AiogramBot 5 | from .notification_formatter import to_markdown, several_to_markdown 6 | from .redis_settings import RedisSettings 7 | from .redis_whitelist import RedisWhitelist 8 | 9 | __all__ = [ 10 | "AbstractBot", 11 | "AbstractSettings", 12 | "AbstractWhitelist", 13 | "AiogramBot", 14 | "to_markdown", 15 | "several_to_markdown", 16 | "RedisSettings", 17 | "RedisWhitelist", 18 | ] 19 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/abstract_bot.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from ..models import ItemOfferNotification 4 | 5 | 6 | class AbstractBot(ABC): 7 | @abstractmethod 8 | async def notify(self, notification: ItemOfferNotification) -> None: 9 | ... 10 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/abstract_command.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Iterable 3 | 4 | from aiogram import Dispatcher, types 5 | 6 | 7 | class AbstractCommand(ABC): 8 | def __init__(self, name: str): 9 | self.name = name 10 | 11 | def register_command(self, dispatcher: Dispatcher, members: Iterable[int]): 12 | dispatcher.message_handler(commands=[self.name], user_id=members)(self.handler) 13 | 14 | @abstractmethod 15 | async def handler(self, message: types.Message) -> None: 16 | ... 17 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/abstract_settings.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from ..models import NotificationSettings 4 | 5 | 6 | class AbstractSettings(ABC): 7 | @abstractmethod 8 | async def get(self) -> NotificationSettings | None: 9 | ... 10 | 11 | @abstractmethod 12 | async def set(self, settings: NotificationSettings) -> None: 13 | ... 14 | 15 | @abstractmethod 16 | async def set_default(self) -> None: 17 | ... 18 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/abstract_whitelist.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class AbstractWhitelist(ABC): 5 | @abstractmethod 6 | async def add_member(self, member: int) -> None: 7 | ... 8 | 9 | @abstractmethod 10 | async def remove_member(self, member: int) -> None: 11 | ... 12 | 13 | @abstractmethod 14 | async def get_members(self) -> list[int]: 15 | ... 16 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/aiogram_bot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Iterable 3 | 4 | import aiogram 5 | from aiogram.types import ParseMode 6 | 7 | from .abstract_bot import AbstractBot 8 | from .abstract_command import AbstractCommand 9 | from .abstract_whitelist import AbstractWhitelist 10 | from .notification_formatter import to_markdown 11 | from ..models import ItemOfferNotification 12 | 13 | 14 | class AiogramBot(AbstractBot): 15 | def __init__( 16 | self, 17 | token: str, 18 | whitelist: AbstractWhitelist, 19 | commands: Iterable[AbstractCommand], 20 | ): 21 | self._whitelist = whitelist 22 | self.commands = commands 23 | self._bot = aiogram.Bot(token=token) 24 | self._dispatcher = aiogram.Dispatcher(self._bot) 25 | self._polling_task = None 26 | 27 | async def start(self): 28 | members = await self._whitelist.get_members() 29 | for command in self.commands: 30 | command.register_command(self._dispatcher, members) 31 | self._polling_task = asyncio.create_task(self._dispatcher.start_polling()) 32 | 33 | async def notify(self, notification: ItemOfferNotification): 34 | await asyncio.gather( 35 | *[ 36 | asyncio.create_task( 37 | self._bot.send_message( 38 | chat_id=member, 39 | text=to_markdown(notification), 40 | parse_mode=ParseMode.MARKDOWN_V2, 41 | disable_web_page_preview=True, 42 | ) 43 | ) 44 | for member in await self._whitelist.get_members() 45 | ] 46 | ) 47 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .offers import Offers 2 | from .set_limit import SetLimit 3 | from .set_min_price import SetMinPrice 4 | from .settings import Settings 5 | 6 | __all__ = [ 7 | "Offers", 8 | "SetLimit", 9 | "SetMinPrice", 10 | "Settings", 11 | ] 12 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/commands/offers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import aiogram.utils.parts 4 | from aiogram import types 5 | from aiogram.types import ParseMode 6 | 7 | from common.tracer import trace, annotate 8 | from ..abstract_command import AbstractCommand 9 | from ..notification_formatter import several_to_markdown 10 | from ...offer_provider import AbstractOfferProvider 11 | from ...offers import BaseItemOffer 12 | 13 | _COMMAND = "offers" 14 | 15 | 16 | def _key(offer: BaseItemOffer): 17 | return offer.compute_percentage() 18 | 19 | 20 | class Offers(AbstractCommand): 21 | def __init__(self, offer_provider: AbstractOfferProvider): 22 | super().__init__(_COMMAND) 23 | self.offer_provider = offer_provider 24 | 25 | @trace 26 | async def handler(self, message: types.Message) -> None: 27 | try: 28 | offers = await self.offer_provider.get_items() 29 | annotate(f"Loaded {len(offers)} offers") 30 | 31 | sorted_offers = sorted(offers, key=_key, reverse=True) 32 | 33 | result = "Current offers:\n" 34 | result += several_to_markdown(offer.create_notification() for offer in sorted_offers) 35 | parts = aiogram.utils.parts.safe_split_text(result, split_separator="\n\n") 36 | 37 | # Strictly follow original ordering, because offers sorted by profitability 38 | for part in parts: 39 | await message.reply( 40 | part, 41 | parse_mode=ParseMode.MARKDOWN_V2, 42 | disable_web_page_preview=True, 43 | ) 44 | except Exception as exc: 45 | logging.error('There was an error while handling "/offers" command.', exc_info=exc) 46 | await message.reply(str(exc)) 47 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/commands/set_limit.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | 3 | from ..abstract_command import AbstractCommand 4 | from ..abstract_settings import AbstractSettings 5 | 6 | _COMMAND = "set_limit" 7 | 8 | 9 | class SetLimit(AbstractCommand): 10 | def __init__(self, settings_provider: AbstractSettings): 11 | super().__init__(_COMMAND) 12 | self.settings_provider = settings_provider 13 | 14 | async def handler(self, message: types.Message) -> None: 15 | try: 16 | args = message.get_args() 17 | try: 18 | percentage = float(args.split()[0]) 19 | except Exception as exc: 20 | raise ValueError(f"could not convert string to float: '{args}'") from exc 21 | 22 | settings = await self.settings_provider.get() 23 | if not settings: 24 | raise ValueError("Failed to load settings!") 25 | settings.max_threshold = percentage 26 | await self.settings_provider.set(settings) 27 | 28 | result = f"Limit for {percentage}% successfully set!" 29 | 30 | await message.reply(result) 31 | except Exception as exc: 32 | await message.reply(str(exc)) 33 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/commands/set_min_price.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | 3 | from ..abstract_command import AbstractCommand 4 | from ..abstract_settings import AbstractSettings 5 | 6 | _COMMAND = "set_min_price" 7 | 8 | 9 | class SetMinPrice(AbstractCommand): 10 | def __init__(self, settings_provider: AbstractSettings): 11 | super().__init__(_COMMAND) 12 | self.settings_provider = settings_provider 13 | 14 | async def handler(self, message: types.Message) -> None: 15 | try: 16 | args = message.get_args() 17 | try: 18 | min_price = float(args.split()[0]) 19 | except Exception as exc: 20 | raise ValueError(f"could not convert string to float: '{args}'") from exc 21 | 22 | if min_price < 0: 23 | raise ValueError("Negative values are not allowed!") 24 | 25 | settings = await self.settings_provider.get() 26 | if not settings: 27 | raise ValueError("Failed to load settings!") 28 | settings.min_price = min_price 29 | await self.settings_provider.set(settings) 30 | 31 | result = f"Minimal price ${min_price} successfully set!" 32 | 33 | await message.reply(result) 34 | except Exception as exc: 35 | await message.reply(str(exc)) 36 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/commands/settings.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | 3 | from ..abstract_command import AbstractCommand 4 | from ..abstract_settings import AbstractSettings 5 | 6 | _COMMAND = "settings" 7 | 8 | 9 | class Settings(AbstractCommand): 10 | def __init__(self, settings_provider: AbstractSettings): 11 | super().__init__(_COMMAND) 12 | self.settings_provider = settings_provider 13 | 14 | async def handler(self, message: types.Message) -> None: 15 | try: 16 | settings = await self.settings_provider.get() 17 | 18 | result = f"Current settings: {str(settings)}" 19 | 20 | await message.reply(result) 21 | except Exception as exc: 22 | await message.reply(str(exc)) 23 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/notification_formatter.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from aiogram.utils import markdown 4 | 5 | from ..models import ItemOfferNotification 6 | from ..steam_fee import SteamFee 7 | 8 | 9 | def to_markdown(notification: ItemOfferNotification) -> str: 10 | price_with_fee = SteamFee.add_fee(notification.sell_price) 11 | # 1.0% $1.14 -> $0.98 ($0.98) AUTOBUY 12 | # pylint: disable=consider-using-f-string 13 | block = "{} {} \\-\\> {} {} {}".format( 14 | markdown.bold(f"{notification.compute_percentage_diff()}%"), 15 | markdown.escape_md(f"${notification.orig_price}"), 16 | markdown.escape_md(f"${notification.sell_price}"), 17 | markdown.escape_md(f"(${price_with_fee})"), 18 | markdown.italic(notification.short_title), 19 | ) 20 | 21 | full_name = markdown.code(notification.market_name) 22 | 23 | link = markdown.link( 24 | notification.market_name, 25 | markdown.escape_md( 26 | "https://steamcommunity.com/market/listings/730/" + notification.market_name 27 | ), 28 | ) 29 | 30 | return "\n".join([block, full_name, link]) 31 | 32 | 33 | def several_to_markdown(notifications: typing.Iterable[ItemOfferNotification]) -> str: 34 | return "\n\n".join(to_markdown(notification) for notification in notifications) 35 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/redis_settings.py: -------------------------------------------------------------------------------- 1 | from aioredis import Redis 2 | 3 | from .abstract_settings import AbstractSettings 4 | from ..models import NotificationSettings 5 | 6 | 7 | class RedisSettings(AbstractSettings): 8 | def __init__(self, redis: Redis, key: str): 9 | self._redis = redis 10 | self._key = key 11 | 12 | async def get(self) -> NotificationSettings | None: 13 | value = await self._redis.get(self._key) 14 | if value: 15 | return NotificationSettings.load_bytes(value) 16 | return None 17 | 18 | async def set(self, settings: NotificationSettings) -> None: 19 | await self._redis.set(self._key, settings.dumps()) 20 | 21 | async def set_default(self) -> None: 22 | await self._redis.setnx(self._key, NotificationSettings().dumps()) 23 | -------------------------------------------------------------------------------- /price_monitoring/telegram/bot/redis_whitelist.py: -------------------------------------------------------------------------------- 1 | from aioredis import Redis 2 | 3 | from .abstract_whitelist import AbstractWhitelist 4 | 5 | 6 | class RedisWhitelist(AbstractWhitelist): 7 | def __init__(self, redis: Redis, key: str): 8 | self._redis = redis 9 | self._key = key 10 | 11 | async def add_member(self, member: int) -> None: 12 | await self._redis.sadd(self._key, member) 13 | 14 | async def remove_member(self, member: int) -> None: 15 | await self._redis.srem(self._key, member) 16 | 17 | async def get_members(self) -> list[int]: 18 | members = await self._redis.smembers(self._key) 19 | return [int(x) for x in members] 20 | -------------------------------------------------------------------------------- /price_monitoring/telegram/fresh_filter/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_filter import AbstractFilter 2 | from .redis_filter import RedisFilter 3 | 4 | __all__ = [ 5 | "AbstractFilter", 6 | "RedisFilter", 7 | ] 8 | -------------------------------------------------------------------------------- /price_monitoring/telegram/fresh_filter/abstract_filter.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Sequence 3 | 4 | from ..offers import BaseItemOffer 5 | 6 | 7 | class AbstractFilter(ABC): 8 | @abstractmethod 9 | async def filter_new_offers(self, offers: Sequence[BaseItemOffer]) -> Sequence[BaseItemOffer]: 10 | ... 11 | 12 | @abstractmethod 13 | async def append_offers(self, offers: Sequence[BaseItemOffer]) -> None: 14 | ... 15 | -------------------------------------------------------------------------------- /price_monitoring/telegram/fresh_filter/redis_filter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import timedelta 3 | from typing import Sequence 4 | 5 | from aioredis import Redis 6 | 7 | from .abstract_filter import AbstractFilter 8 | from ..offers import BaseItemOffer 9 | 10 | _ENTRY_TTL = timedelta(minutes=30) 11 | 12 | 13 | def _key(market_name: str, percent_diff: float) -> str: 14 | return f"cache:withdraw:{market_name}:{percent_diff}" 15 | 16 | 17 | class RedisFilter(AbstractFilter): 18 | def __init__(self, redis: Redis): 19 | self.redis = redis 20 | 21 | async def filter_new_offers(self, offers: Sequence[BaseItemOffer]) -> Sequence[BaseItemOffer]: 22 | keys = [_key(offer.market_name, offer.compute_percentage()) for offer in offers] 23 | values = await self.redis.mget(keys) 24 | result = [] 25 | for offer, value in zip(offers, values): 26 | if not value: 27 | result.append(offer) 28 | return result 29 | 30 | async def append_offers(self, offers: Sequence[BaseItemOffer]) -> None: 31 | tasks = [] # type: ignore 32 | for offer in offers: 33 | key = _key(offer.market_name, offer.compute_percentage()) 34 | tasks.append(self.redis.set(key, 1, ex=_ENTRY_TTL)) 35 | await asyncio.gather(*tasks) 36 | -------------------------------------------------------------------------------- /price_monitoring/telegram/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from marshmallow_dataclass import add_schema 4 | 5 | from common.core.dataclass_json import JsonMixin 6 | from ..types import MarketName 7 | 8 | 9 | @add_schema 10 | @dataclass 11 | class NotificationSettings(JsonMixin): 12 | max_threshold: float = 0 13 | min_price: float = 10 14 | 15 | 16 | @add_schema 17 | @dataclass 18 | class ItemOfferNotification(JsonMixin): 19 | market_name: MarketName 20 | orig_price: float 21 | sell_price: float 22 | short_title: str 23 | 24 | def compute_percentage_diff(self) -> float: 25 | return round((self.sell_price - self.orig_price) / self.orig_price * 100, 2) 26 | -------------------------------------------------------------------------------- /price_monitoring/telegram/offer_provider/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_offer_provider import AbstractOfferProvider 2 | from .chain_provider import ChainProvider 3 | from .redis_provider import RedisOfferProvider 4 | from .redis_sell_history_provider import RedisSellHistoryProvider 5 | from .settings_based_provider import SettingsBasedProvider 6 | 7 | __all__ = [ 8 | "AbstractOfferProvider", 9 | "ChainProvider", 10 | "RedisOfferProvider", 11 | "RedisSellHistoryProvider", 12 | "SettingsBasedProvider", 13 | ] 14 | -------------------------------------------------------------------------------- /price_monitoring/telegram/offer_provider/abstract_offer_provider.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Sequence 3 | 4 | from ..offers import BaseItemOffer 5 | 6 | 7 | class AbstractOfferProvider(ABC): 8 | @abstractmethod 9 | async def get_items( 10 | self, percentage_limit: float = None, min_price: float = None 11 | ) -> Sequence[BaseItemOffer]: 12 | ... 13 | -------------------------------------------------------------------------------- /price_monitoring/telegram/offer_provider/chain_provider.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import typing 3 | 4 | from common.tracer import trace 5 | from .abstract_offer_provider import AbstractOfferProvider 6 | from ..offers import BaseItemOffer 7 | 8 | 9 | class ChainProvider(AbstractOfferProvider): 10 | def __init__(self, offer_providers: typing.Iterable[AbstractOfferProvider]): 11 | self.offer_providers = offer_providers 12 | 13 | @trace 14 | async def get_items( 15 | self, percentage_limit: float = None, min_price: float = None 16 | ) -> list[BaseItemOffer]: 17 | result = [] 18 | for array in await asyncio.gather( 19 | *[ 20 | provider.get_items(percentage_limit=percentage_limit, min_price=min_price) 21 | for provider in self.offer_providers 22 | ] 23 | ): 24 | result.extend(array) 25 | return result 26 | -------------------------------------------------------------------------------- /price_monitoring/telegram/offer_provider/redis_provider.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | 3 | from common.tracer import trace, annotate 4 | from .abstract_offer_provider import AbstractOfferProvider 5 | from ..offers import BaseItemOffer, SteamOrdersOffer 6 | from ..steam_fee import SteamFee 7 | from ...storage.csmoney import AbstractCsmoneyItemStorage 8 | from ...storage.steam import AbstractSteamOrdersStorage 9 | 10 | 11 | class RedisOfferProvider(AbstractOfferProvider): 12 | def __init__(self, steam: AbstractSteamOrdersStorage, csmoney: AbstractCsmoneyItemStorage): 13 | self.steam = steam 14 | self.csmoney = csmoney 15 | 16 | @trace 17 | async def get_items( 18 | self, percentage_limit: float = None, min_price: float = None 19 | ) -> Sequence[BaseItemOffer]: 20 | csmoney_items = await self.csmoney.get_all() 21 | annotate(f"Loaded {len(csmoney_items)} items from cs.money") 22 | steam_items = await self.steam.get_all() 23 | annotate(f"Loaded {len(steam_items)} items from steam") 24 | 25 | items = [] 26 | for market_name, (buy_order, _) in steam_items.items(): 27 | if not buy_order: 28 | continue 29 | if market_name not in csmoney_items: 30 | continue 31 | csmoney_price = csmoney_items[market_name] 32 | if min_price and csmoney_price < min_price: 33 | continue 34 | offer = SteamOrdersOffer( 35 | market_name=market_name, 36 | orig_price=csmoney_price, 37 | buy_order=SteamFee.subtract_fee(buy_order), 38 | ) 39 | if percentage_limit is not None and offer.compute_percentage() < percentage_limit: 40 | continue 41 | items.append(offer) 42 | 43 | return items 44 | -------------------------------------------------------------------------------- /price_monitoring/telegram/offer_provider/settings_based_provider.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | 3 | from common.tracer import trace 4 | from .abstract_offer_provider import AbstractOfferProvider 5 | from ..bot import AbstractSettings 6 | from ..offers import BaseItemOffer 7 | 8 | 9 | class SettingsBasedProvider(AbstractOfferProvider): 10 | def __init__(self, settings_provider: AbstractSettings, offer_provider: AbstractOfferProvider): 11 | self.settings_provider = settings_provider 12 | self.offer_provider = offer_provider 13 | 14 | @trace 15 | async def get_items( 16 | self, percentage_limit: float = None, min_price: float = None 17 | ) -> Sequence[BaseItemOffer]: 18 | settings = await self.settings_provider.get() 19 | if not settings: 20 | raise ValueError("Failed to load settings!") 21 | return await self.offer_provider.get_items( 22 | percentage_limit=percentage_limit or settings.max_threshold, 23 | min_price=min_price or settings.min_price, 24 | ) 25 | -------------------------------------------------------------------------------- /price_monitoring/telegram/offers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base_item_offer import BaseItemOffer 2 | from .steam_orders_offer import SteamOrdersOffer 3 | from .steam_sell_history_offer import SteamSellHistoryOffer 4 | 5 | __all__ = [ 6 | "BaseItemOffer", 7 | "SteamOrdersOffer", 8 | "SteamSellHistoryOffer", 9 | ] 10 | -------------------------------------------------------------------------------- /price_monitoring/telegram/offers/base_item_offer.py: -------------------------------------------------------------------------------- 1 | from ..models import ItemOfferNotification 2 | from ...types import MarketName 3 | 4 | 5 | class BaseItemOffer: 6 | def __init__(self, market_name: MarketName, orig_price: float, sell_price: float): 7 | self.market_name = market_name 8 | self.orig_price = round(orig_price, 2) 9 | self.sell_price = round(sell_price, 2) 10 | 11 | def create_notification(self) -> ItemOfferNotification: 12 | return ItemOfferNotification( 13 | market_name=self.market_name, 14 | orig_price=self.orig_price, 15 | sell_price=self.sell_price, 16 | short_title="UNKNOWN", 17 | ) 18 | 19 | def compute_difference(self) -> float: 20 | return round(self.sell_price - self.orig_price, 2) 21 | 22 | def compute_percentage(self) -> float: 23 | return round(self.compute_difference() / self.orig_price * 100, 2) 24 | -------------------------------------------------------------------------------- /price_monitoring/telegram/offers/steam_orders_offer.py: -------------------------------------------------------------------------------- 1 | from .base_item_offer import BaseItemOffer 2 | from ..models import ItemOfferNotification 3 | from ...types import MarketName 4 | 5 | AUTOBUY = "AUTOBUY" 6 | 7 | 8 | class SteamOrdersOffer(BaseItemOffer): 9 | def __init__(self, market_name: MarketName, orig_price: float, buy_order: float): 10 | # pylint: disable=useless-super-delegation 11 | super().__init__(market_name, orig_price, buy_order) 12 | 13 | def create_notification(self) -> ItemOfferNotification: 14 | obj = super().create_notification() 15 | obj.short_title = AUTOBUY 16 | return obj 17 | -------------------------------------------------------------------------------- /price_monitoring/telegram/offers/steam_sell_history_offer.py: -------------------------------------------------------------------------------- 1 | from .base_item_offer import BaseItemOffer 2 | from ..models import ItemOfferNotification 3 | from ...types import MarketName 4 | 5 | 6 | class SteamSellHistoryOffer(BaseItemOffer): 7 | def __init__( 8 | self, 9 | market_name: MarketName, 10 | orig_price: float, 11 | suggested_price: float, 12 | mean_price: float, 13 | sold_per_week: int, 14 | lock_status: str | None = None, 15 | ): 16 | super().__init__(market_name, orig_price, suggested_price) 17 | self.mean_price = mean_price 18 | self.sold_per_week = sold_per_week 19 | self.lock_status = lock_status 20 | 21 | def create_notification(self) -> ItemOfferNotification: 22 | obj = super().create_notification() 23 | obj.short_title = f"AVG ${self.mean_price} | {self.sold_per_week} SOLD IN WEEK" 24 | if self.lock_status: 25 | obj.short_title += f" | {self.lock_status}" 26 | return obj 27 | -------------------------------------------------------------------------------- /price_monitoring/telegram/runner/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_runner import AbstractRunner 2 | from .runner_impl import Runner 3 | 4 | __all__ = [ 5 | "AbstractRunner", 6 | "Runner", 7 | ] 8 | -------------------------------------------------------------------------------- /price_monitoring/telegram/runner/abstract_runner.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class AbstractRunner(ABC): 5 | @abstractmethod 6 | async def run(self) -> None: 7 | ... 8 | -------------------------------------------------------------------------------- /price_monitoring/telegram/runner/runner_impl.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from common.tracer import trace, annotate 5 | from .abstract_runner import AbstractRunner 6 | from ..bot import AbstractBot 7 | from ..fresh_filter import AbstractFilter 8 | from ..offer_provider import AbstractOfferProvider 9 | from ...decorators import async_infinite_loop 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class Runner(AbstractRunner): 15 | def __init__( 16 | self, 17 | bot: AbstractBot, 18 | price_provider: AbstractOfferProvider, 19 | filter_: AbstractFilter, 20 | ): 21 | self.bot = bot 22 | self.price_provider = price_provider 23 | self.filter_ = filter_ 24 | 25 | @async_infinite_loop(logger) 26 | @trace 27 | async def run(self) -> None: 28 | offers = await self.price_provider.get_items() 29 | annotate(f"Got {len(offers)} offers from {len(offers)}") 30 | new_offers = await self.filter_.filter_new_offers(offers) 31 | annotate(f"Filtered out {len(offers) - len(new_offers)}") 32 | logger.info(f"Got {len(new_offers)} new offers from {len(offers)}") 33 | annotate(f"Got {len(new_offers)} new offers from {len(offers)}") 34 | await self.filter_.append_offers(offers) 35 | 36 | for offer in new_offers: 37 | notification = offer.create_notification() 38 | await self.bot.notify(notification) 39 | await asyncio.sleep(1/15) 40 | 41 | await asyncio.sleep(3) 42 | -------------------------------------------------------------------------------- /price_monitoring/telegram/steam_fee.py: -------------------------------------------------------------------------------- 1 | import math 2 | from functools import lru_cache 3 | 4 | 5 | class SteamFee: 6 | @staticmethod 7 | @lru_cache(maxsize=10**5) 8 | def subtract_fee(price: float) -> float: 9 | if price <= 0.02: 10 | return 0 11 | est_poor = round(price * 0.8, 2) 12 | 13 | while True: 14 | with_fee = SteamFee.add_fee(est_poor) 15 | if with_fee > price: 16 | break 17 | step = math.floor((price - with_fee) * 70) / 100 18 | est_poor += max(step, 0.01) 19 | est_poor = round(est_poor, 2) 20 | 21 | return round(est_poor - 0.01, 2) 22 | 23 | @staticmethod 24 | @lru_cache(maxsize=10**5) 25 | def add_fee(price: float) -> float: 26 | def _compute_fee(price_, perc_amount): 27 | fee = math.floor(price_ * perc_amount) / 100 28 | return max(round(fee, 2), 0.01) 29 | 30 | game = _compute_fee(price, 10) 31 | steam = _compute_fee(price, 5) 32 | return round(price + game + steam, 2) 33 | -------------------------------------------------------------------------------- /price_monitoring/types.py: -------------------------------------------------------------------------------- 1 | from typing import TypeAlias 2 | 3 | MarketName: TypeAlias = str 4 | NameId: TypeAlias = int # used on cs.money 5 | ItemNameId: TypeAlias = int # used on steam market 6 | OrderPrice: TypeAlias = float | None 7 | BuySellOrders: TypeAlias = tuple[OrderPrice, OrderPrice] 8 | -------------------------------------------------------------------------------- /price_monitoring/worker/__init__.py: -------------------------------------------------------------------------------- 1 | from .worker import Worker, WorkerThread 2 | 3 | __all__ = [ 4 | "Worker", 5 | "WorkerThread", 6 | ] 7 | -------------------------------------------------------------------------------- /price_monitoring/worker/processing/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract_csmoney_item_processor import AbstractCsmoneyItemProcessor 2 | from .abstract_steam_processor import AbstractSteamSkinProcessor 3 | from .abstract_steam_sell_history_processor import AbstractSteamSellHistoryProcessor 4 | from .csmoney_item_processor import CsmoneyItemProcessor 5 | from .market_name_extractor import MarketNameExtractor 6 | from .steam_sell_history_processor import SteamSellHistoryProcessor 7 | from .steam_skin_processor import SteamSkinProcessor 8 | 9 | __all__ = [ 10 | "AbstractCsmoneyItemProcessor", 11 | "AbstractSteamSkinProcessor", 12 | "AbstractSteamSellHistoryProcessor", 13 | "CsmoneyItemProcessor", 14 | "MarketNameExtractor", 15 | "SteamSellHistoryProcessor", 16 | "SteamSkinProcessor", 17 | ] 18 | -------------------------------------------------------------------------------- /price_monitoring/worker/processing/abstract_csmoney_item_processor.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from ...models.csmoney import CsmoneyItemPack 4 | 5 | 6 | class AbstractCsmoneyItemProcessor(ABC): 7 | @abstractmethod 8 | async def process(self, pack: CsmoneyItemPack) -> None: 9 | ... 10 | -------------------------------------------------------------------------------- /price_monitoring/worker/processing/abstract_steam_processor.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | from ...models.steam import SteamSkinHistogram 4 | 5 | 6 | class AbstractSteamSkinProcessor(ABC): 7 | @abstractmethod 8 | async def process(self, skin: SteamSkinHistogram) -> None: 9 | ... 10 | -------------------------------------------------------------------------------- /price_monitoring/worker/processing/abstract_steam_sell_history_processor.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | from ...models.steam import SteamSellHistory 4 | 5 | 6 | class AbstractSteamSellHistoryProcessor(ABC): 7 | @abstractmethod 8 | async def process(self, history: SteamSellHistory) -> None: 9 | ... 10 | -------------------------------------------------------------------------------- /price_monitoring/worker/processing/csmoney_item_processor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from .abstract_csmoney_item_processor import AbstractCsmoneyItemProcessor 5 | from ...models.csmoney import CsmoneyItemPack 6 | from ...storage.csmoney import AbstractCsmoneyItemStorage 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class CsmoneyItemProcessor(AbstractCsmoneyItemProcessor): 12 | def __init__( 13 | self, 14 | unlocked_storage: AbstractCsmoneyItemStorage, 15 | locked_storage: AbstractCsmoneyItemStorage, 16 | ): 17 | self._unlocked_storage = unlocked_storage 18 | self._locked_storage = locked_storage 19 | 20 | async def process(self, pack: CsmoneyItemPack) -> None: 21 | await asyncio.gather( 22 | *[ 23 | self._locked_storage.update_item(item.name, item.price) 24 | if item.unlock_timestamp 25 | else self._unlocked_storage.update_item(item.name, item.price) 26 | for item in pack.items 27 | ] 28 | ) 29 | logger.info(f"Updated {len(pack.items)} cs.money items") 30 | -------------------------------------------------------------------------------- /price_monitoring/worker/processing/market_name_extractor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .abstract_csmoney_item_processor import AbstractCsmoneyItemProcessor 4 | from ...models.csmoney import CsmoneyItemPack 5 | from ...models.steam import MarketNamePack 6 | from ...queues import AbstractMarketNameWriter 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class MarketNameExtractor(AbstractCsmoneyItemProcessor): 12 | def __init__(self, market_name_queue: AbstractMarketNameWriter): 13 | self._market_name_queue = market_name_queue 14 | 15 | async def process(self, pack: CsmoneyItemPack) -> None: 16 | market_names = {item.name for item in pack.items} 17 | market_name_pack = MarketNamePack(items=list(market_names)) 18 | await self._market_name_queue.put(market_name_pack) 19 | logger.info(f"Updated market names for items {market_names}") 20 | -------------------------------------------------------------------------------- /price_monitoring/worker/processing/sell_history/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/price_monitoring/worker/processing/sell_history/__init__.py -------------------------------------------------------------------------------- /price_monitoring/worker/processing/steam_sell_history_processor.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | from .abstract_steam_sell_history_processor import AbstractSteamSellHistoryProcessor 5 | from .sell_history.analyzer import SellHistoryAnalyzer 6 | from ...models.steam import SteamSellHistory, SkinSellHistory 7 | from ...storage.steam import AbstractSteamSellHistoryStorage 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class SteamSellHistoryProcessor(AbstractSteamSellHistoryProcessor): 13 | def __init__(self, steam_storage: AbstractSteamSellHistoryStorage): 14 | self._steam_storage = steam_storage 15 | 16 | async def process(self, history: SteamSellHistory) -> None: 17 | market_name = history.market_name 18 | 19 | analyzer = SellHistoryAnalyzer(history.encoded_data) 20 | current_dt = datetime.datetime.utcnow() 21 | is_stable = analyzer.is_stable(current_dt) 22 | sold_per_week = analyzer.get_sold_amount_for_week(current_dt) 23 | summary = analyzer.analyze_history(current_dt) 24 | 25 | logger.info(f"Sell history for {market_name}: {is_stable=} | {sold_per_week=} | {summary=}") 26 | 27 | await self._steam_storage.update_skin( 28 | SkinSellHistory( 29 | market_name=history.market_name, 30 | is_stable=is_stable, 31 | sold_per_week=sold_per_week, 32 | summary=summary, 33 | ) 34 | ) 35 | -------------------------------------------------------------------------------- /price_monitoring/worker/processing/steam_skin_processor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .abstract_steam_processor import AbstractSteamSkinProcessor 4 | from ...models.steam import SteamSkinHistogram 5 | from ...storage.steam import AbstractSteamOrdersStorage 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def _extract_buy_order(data: dict) -> float | None: 11 | if "highest_buy_order" in data: 12 | value = data["highest_buy_order"] 13 | if value: 14 | return int(value) / 100 15 | return None 16 | 17 | 18 | def _extract_sell_order(data: dict) -> float | None: 19 | if "lowest_sell_order" in data: 20 | value = data["lowest_sell_order"] 21 | if value: 22 | return int(value) / 100 23 | return None 24 | 25 | 26 | class SteamSkinProcessor(AbstractSteamSkinProcessor): 27 | def __init__(self, steam_order_storage: AbstractSteamOrdersStorage): 28 | self._steam_order_storage = steam_order_storage 29 | 30 | async def process(self, skin: SteamSkinHistogram) -> None: 31 | market_name = skin.market_name 32 | buy_order = _extract_buy_order(skin.response) 33 | sell_order = _extract_sell_order(skin.response) 34 | 35 | await self._steam_order_storage.update_skin_order( 36 | market_name=market_name, buy_order=buy_order, sell_order=sell_order 37 | ) 38 | logger.info(f"Updated orders for {market_name}: {buy_order} and {sell_order}") 39 | -------------------------------------------------------------------------------- /price_monitoring/worker/worker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import typing 4 | from dataclasses import dataclass 5 | 6 | from ..decorators import async_infinite_loop 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @dataclass 12 | class WorkerThread: 13 | reader: typing.Any 14 | delay_duration: float 15 | processors: typing.Iterable[typing.Any] 16 | 17 | 18 | @async_infinite_loop(logger) 19 | async def _run_processor(thread: WorkerThread) -> None: 20 | item = await thread.reader.get() 21 | if not item: 22 | await asyncio.sleep(thread.delay_duration) 23 | return 24 | await asyncio.gather(*[processor.process(item) for processor in thread.processors]) 25 | 26 | 27 | class Worker: 28 | def __init__(self, threads: typing.Iterable[WorkerThread]): 29 | self._threads = threads 30 | 31 | async def run(self) -> None: 32 | await asyncio.gather( 33 | *[asyncio.create_task(_run_processor(thread)) for thread in self._threads] 34 | ) 35 | -------------------------------------------------------------------------------- /prod.env: -------------------------------------------------------------------------------- 1 | RABBITMQ_ERLANG_COOKIE=40d5456f34f6cefb88b3 2 | RABBITMQ_DEFAULT_USER=b89064ff8c4016862f05 3 | RABBITMQ_DEFAULT_PASS=1933952ebd3fca7d973e 4 | RABBITMQ_AMQP_PORT=5672 5 | RABBITMQ_WEBUI_PORT=15672 6 | REDIS_PORT=6379 7 | REDIS_PASSWORD=25a7edbfdd5bc5d7f8ce -------------------------------------------------------------------------------- /proxy_http/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/proxy_http/__init__.py -------------------------------------------------------------------------------- /proxy_http/aiohttp_addons/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/proxy_http/aiohttp_addons/__init__.py -------------------------------------------------------------------------------- /proxy_http/aiohttp_addons/aihttp_socks_connector.py: -------------------------------------------------------------------------------- 1 | from aiohttp import TCPConnector 2 | 3 | from python_socks import ProxyType, parse_proxy_url 4 | from python_socks.async_.asyncio.v2 import Proxy 5 | 6 | 7 | class ProxyConnector(TCPConnector): 8 | def __init__( 9 | self, 10 | proxy_type=ProxyType.SOCKS5, 11 | host=None, 12 | port=None, 13 | username=None, 14 | password=None, 15 | rdns=None, 16 | **kwargs, 17 | ): 18 | super().__init__(**kwargs) 19 | 20 | self._proxy_type = proxy_type 21 | self._proxy_host = host 22 | self._proxy_port = port 23 | self._proxy_username = username 24 | self._proxy_password = password 25 | self._rdns = rdns 26 | 27 | # noinspection PyMethodOverriding 28 | async def _wrap_create_connection(self, protocol_factory, host, port, *, ssl, **kwargs): 29 | proxy = Proxy.create( 30 | proxy_type=self._proxy_type, 31 | host=self._proxy_host, 32 | port=self._proxy_port, 33 | username=self._proxy_username, 34 | password=self._proxy_password, 35 | rdns=self._rdns, 36 | loop=self._loop, 37 | ) 38 | 39 | connect_timeout = None 40 | 41 | timeout = kwargs.get("timeout") 42 | if timeout is not None: 43 | connect_timeout = getattr(timeout, "sock_connect", None) 44 | 45 | stream = await proxy.connect( 46 | dest_host=host, dest_port=port, dest_ssl=ssl, timeout=connect_timeout 47 | ) 48 | 49 | transport = stream.writer.transport 50 | protocol = protocol_factory() 51 | 52 | transport.set_protocol(protocol) 53 | protocol.transport = transport 54 | 55 | return transport, protocol 56 | 57 | @classmethod 58 | def from_url(cls, url, **kwargs): 59 | proxy_type, host, port, username, password = parse_proxy_url(url) 60 | return cls( 61 | proxy_type=proxy_type, 62 | host=host, 63 | port=port, 64 | username=username, 65 | password=password, 66 | **kwargs, 67 | ) 68 | -------------------------------------------------------------------------------- /proxy_http/aiohttp_session_factory.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Mapping 2 | 3 | from aiohttp import ClientSession 4 | 5 | from proxy_http.aiohttp_addons.aihttp_socks_connector import ProxyConnector 6 | from proxy_http.proxy import Proxy 7 | 8 | 9 | class AiohttpSessionFactory: 10 | @staticmethod 11 | def create_session() -> ClientSession: 12 | return ClientSession() 13 | 14 | @staticmethod 15 | def create_session_with_proxy( 16 | proxy: Proxy, headers: Optional[Mapping[str, str]] = None 17 | ) -> ClientSession: 18 | connector = ProxyConnector.from_url(proxy.serialize(), ssl=False) 19 | return ClientSession(connector=connector, headers=headers) 20 | -------------------------------------------------------------------------------- /proxy_http/async_proxies_concurrent_limiter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from asyncio import Lock 3 | from time import time 4 | from typing import List 5 | 6 | from aiohttp import ClientSession 7 | 8 | 9 | class NoAvailableSessionError(Exception): 10 | pass 11 | 12 | 13 | class AsyncSessionConcurrentLimiter: 14 | def __init__(self, sessions: List[ClientSession], timestamp: float): 15 | self._sessions = {session: timestamp for session in sessions} 16 | self._lock = Lock() 17 | 18 | async def get_available(self, postpone_duration: float) -> ClientSession: 19 | while True: 20 | try: 21 | async with self._lock: 22 | return self._get_available_no_wait(time(), postpone_duration) 23 | except NoAvailableSessionError: 24 | await asyncio.sleep(0.1) 25 | 26 | def _get_available_no_wait(self, timestamp: float, postpone_duration: float) -> ClientSession: 27 | for session in self._sessions: 28 | if timestamp > self._sessions[session]: 29 | self._postpone(session, timestamp + postpone_duration) 30 | return session 31 | raise NoAvailableSessionError 32 | 33 | def _postpone(self, session: ClientSession, timestamp: float): 34 | try: 35 | self._sessions[session] = timestamp 36 | except KeyError: 37 | raise NoAvailableSessionError 38 | -------------------------------------------------------------------------------- /proxy_http/decorators.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from functools import wraps 3 | 4 | import aiohttp 5 | 6 | 7 | def catch_aiohttp(logger): 8 | def decorator(func): 9 | @wraps(func) 10 | async def wrapper(*args, **kwargs): 11 | try: 12 | return await func(*args, **kwargs) 13 | except (aiohttp.ClientHttpProxyError, aiohttp.ClientProxyConnectionError) as exc: 14 | logger.exception("Failed to connect to proxy", exc_info=exc) 15 | except ( 16 | asyncio.exceptions.TimeoutError, 17 | aiohttp.ClientConnectionError, 18 | aiohttp.ClientPayloadError, 19 | aiohttp.ClientResponseError, 20 | ConnectionResetError, 21 | ) as exc: 22 | logger.exception("Failed to connect to proxy", exc_info=exc) 23 | 24 | return wrapper 25 | 26 | return decorator 27 | -------------------------------------------------------------------------------- /steam_parser.dev.env: -------------------------------------------------------------------------------- 1 | REDIS_HOST=localhost 2 | REDIS_PORT=6379 3 | REDIS_DB=1 4 | REDIS_PASSWORD=25a7edbfdd5bc5d7f8ce 5 | RABBITMQ_HOST=localhost 6 | RABBITMQ_PORT=5672 7 | RABBITMQ_LOGIN=b89064ff8c4016862f05 8 | RABBITMQ_PASSWORD=1933952ebd3fca7d973e 9 | ORDER_WORKERS_COUNT=10 10 | SELL_HISTORY_WORKERS_COUNT=5 -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from unittest import mock 3 | 4 | import fakeredis.aioredis 5 | import pytest 6 | 7 | from price_monitoring import decorators 8 | 9 | 10 | @pytest.fixture() 11 | def fake_redis(): 12 | redis = fakeredis.aioredis.FakeRedis() 13 | yield redis 14 | redis.close() 15 | 16 | 17 | @pytest.fixture() 18 | def disable_asyncio_sleep(): 19 | orig = asyncio.sleep 20 | 21 | # noinspection PyUnusedLocal 22 | async def _mock(*args, **kwargs): 23 | await orig(0) 24 | 25 | with mock.patch("asyncio.sleep", new=_mock): 26 | yield 27 | 28 | 29 | @pytest.fixture(scope="session", autouse=True) 30 | def disable_infinite_loop(): 31 | decorators._INFINITE_RUN = False 32 | yield 33 | decorators._INFINITE_RUN = True 34 | -------------------------------------------------------------------------------- /tests/features/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/features/__init__.py -------------------------------------------------------------------------------- /tests/features/overpay/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/features/overpay/__init__.py -------------------------------------------------------------------------------- /tests/features/overpay/csmoney/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/features/overpay/csmoney/__init__.py -------------------------------------------------------------------------------- /tests/features/overpay/csmoney/test_overpay_calculator.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.features.overpay.csmoney.overpay_calculator import compute_accept_price 4 | 5 | TEST_DATA = [ 6 | (0.23, 0.36, 0.54), 7 | (3.08, 1.24, 4.01), 8 | (12.67, 8.74, 19.90), 9 | ] 10 | 11 | # 5% fee instead of 7% 12 | TEST_DATA_WITH_5_FEE = [ 13 | (9.56, 3.31, 12.22), 14 | (6.66, 2.34, 8.54), 15 | (5.75, 2.87, 8.18), 16 | ] 17 | 18 | 19 | @pytest.mark.parametrize("pair", TEST_DATA) 20 | def test_compute_accept_price(pair): 21 | base_price = pair[0] 22 | overpay = pair[1] 23 | expected = pair[2] 24 | assert compute_accept_price(base_price, overpay) == expected 25 | 26 | 27 | @pytest.mark.parametrize("pair", TEST_DATA_WITH_5_FEE) 28 | def test_compute_accept_price_with_5_fee(pair): 29 | base_price = pair[0] 30 | overpay = pair[1] 31 | expected = pair[2] 32 | assert compute_accept_price(base_price, overpay, commission=0.05) == expected 33 | -------------------------------------------------------------------------------- /tests/features/overpay/storage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/features/overpay/storage/__init__.py -------------------------------------------------------------------------------- /tests/features/overpay/storage/test_redis_base_price.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.features.overpay.storage import RedisBasePriceStorage 4 | from price_monitoring.features.overpay.storage.redis_base_price import _key 5 | 6 | 7 | @pytest.fixture() 8 | def storage(fake_redis): 9 | return RedisBasePriceStorage(fake_redis) 10 | 11 | 12 | async def test_empty(storage): 13 | assert await storage.get_all() == {} 14 | 15 | 16 | async def test_update(storage): 17 | await storage.update_item("AK", 0.6) 18 | 19 | assert await storage.get_all() == {"AK": 0.6} 20 | 21 | 22 | async def test_several_update(storage): 23 | await storage.update_item("AK", 0.6) 24 | await storage.update_item("AK", 0.7) 25 | 26 | assert await storage.get_all() == {"AK": 0.7} 27 | 28 | 29 | async def test_several_update_2(storage): 30 | await storage.update_item("AK", 0.5) 31 | await storage.update_item("AK", 0.7) 32 | await storage.update_item("AK", 0.6) 33 | 34 | assert await storage.get_all() == {"AK": 0.6} 35 | 36 | 37 | async def test_ttl(fake_redis, storage): 38 | await storage.update_item("AK", 1) 39 | 40 | assert await fake_redis.ttl(_key("AK")) > 0 41 | 42 | 43 | if __name__ == "__main__": 44 | pytest.main() 45 | -------------------------------------------------------------------------------- /tests/features/overpay/storage/test_redis_overpay.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.features.overpay.storage.redis_overpay import _key, RedisOverpayStorage 4 | from price_monitoring.models.csmoney import CsmoneyItemOverpay 5 | 6 | overpay = CsmoneyItemOverpay(market_name="AK", float_="0.001", name_id=1, overpay=0.51) 7 | 8 | 9 | @pytest.fixture() 10 | def storage(fake_redis): 11 | return RedisOverpayStorage(fake_redis) 12 | 13 | 14 | async def test_empty(storage): 15 | assert await storage.get_all() == [] 16 | 17 | 18 | async def test_update(storage): 19 | await storage.add_overpay(overpay) 20 | 21 | assert await storage.get_all() == [overpay] 22 | 23 | 24 | async def test_several_update(storage): 25 | await storage.add_overpay(overpay) 26 | overpay2 = CsmoneyItemOverpay(market_name="AK", float_="0.001", name_id=1, overpay=0.49) 27 | await storage.add_overpay(overpay2) 28 | 29 | assert await storage.get_all() == [overpay2] 30 | 31 | 32 | async def test_several_update_2(storage): 33 | overpay2 = CsmoneyItemOverpay(market_name="AK", float_="0.0012", name_id=1, overpay=0.51) 34 | await storage.add_overpay(overpay) 35 | await storage.add_overpay(overpay2) 36 | 37 | assert await storage.get_all() == [overpay, overpay2] 38 | 39 | 40 | async def test_ttl(fake_redis, storage): 41 | await storage.add_overpay(overpay) 42 | 43 | assert await fake_redis.ttl(_key("AK", "0.001")) > 0 44 | 45 | 46 | if __name__ == "__main__": 47 | pytest.main() 48 | -------------------------------------------------------------------------------- /tests/features/overpay/test_generate_list.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.features.overpay.generate_list import adjust_float, generate_list 4 | from price_monitoring.features.overpay.overpay_reference import OverpayReference 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "float_, expected", 9 | [ 10 | ("0.01", "0.0101"), 11 | ("0.007", "0.00707"), 12 | ("0.004772999789565", "0.00482"), 13 | ("0.07697753608226701", "0.07774"), 14 | ("0.07697053608336746", "0.07774"), 15 | ], 16 | ) 17 | def test_adjust_float(float_: str, expected): 18 | assert adjust_float(float_) == expected 19 | 20 | 21 | def test_generate_list(): 22 | overpay = OverpayReference( 23 | market_name="M4A1-S | Nightmare (Field-Tested)", 24 | float_="0.004772999789565", 25 | overpay=0.5, 26 | base_price=5, 27 | sell_price=4.12, 28 | ) 29 | assert generate_list([overpay]) == [ 30 | "https://steamcommunity.com/market/listings/730/" 31 | "M4A1-S%20%7C%20Nightmare%20%28Field-Tested%29 0.00482 $4.12" 32 | ] 33 | 34 | 35 | def test_generate_list_skip_high_floats(): 36 | overpay = OverpayReference( 37 | market_name="M4A1-S | Nightmare (Field-Tested)", 38 | float_="0.324772999789565", 39 | overpay=0.5, 40 | base_price=5, 41 | sell_price=4.12, 42 | ) 43 | assert generate_list([overpay]) == [] 44 | -------------------------------------------------------------------------------- /tests/features/overpay/test_overpay_reference.py: -------------------------------------------------------------------------------- 1 | from price_monitoring.features.overpay.csmoney.overpay_calculator import compute_accept_price 2 | from price_monitoring.features.overpay.overpay_reference import OverpayReference 3 | 4 | overpay_reference = OverpayReference( 5 | market_name="AK", float_="0.00005706", overpay=0.5, base_price=1, sell_price=1.2 6 | ) 7 | 8 | accept_price = compute_accept_price(base_price=1, overpay=0.5) 9 | 10 | 11 | def test_compute_accept_price(): 12 | assert overpay_reference.compute_accept_price() == accept_price 13 | 14 | 15 | def test_compute_profit(): 16 | assert overpay_reference.compute_profit() == accept_price - overpay_reference.sell_price 17 | 18 | 19 | def test_compute_perc_profit(): 20 | assert overpay_reference.compute_perc_profit() == 15.83 # 15.83% 21 | 22 | 23 | def test_str(): 24 | assert ( 25 | str(overpay_reference) == "AK" 26 | " " 27 | " " 28 | "0.00005706 1.39 1.20 15.83%" 29 | ) 30 | -------------------------------------------------------------------------------- /tests/features/overpay/test_overpay_sort.py: -------------------------------------------------------------------------------- 1 | from price_monitoring.features.overpay.overpay_reference import OverpayReference 2 | from price_monitoring.features.overpay.overpay_sort import ( 3 | sort_each_name_by_profit, 4 | sort_name_by_lowest_profit, 5 | ) 6 | 7 | SOURCE = { 8 | "AK": [ 9 | OverpayReference(market_name="AK", float_="0.002", overpay=0.4, base_price=2, sell_price=1), 10 | OverpayReference(market_name="AK", float_="0.001", overpay=0.5, base_price=2, sell_price=1), 11 | ], 12 | "M4A1": [ 13 | OverpayReference( 14 | market_name="M4A1", 15 | float_="0.0023", 16 | overpay=0.4, 17 | base_price=2, 18 | sell_price=0.5, 19 | ), 20 | OverpayReference( 21 | market_name="M4A1", 22 | float_="0.0012", 23 | overpay=0.5, 24 | base_price=2, 25 | sell_price=0.5, 26 | ), 27 | ], 28 | } 29 | 30 | EXPECTED = { 31 | "M4A1": [ 32 | OverpayReference( 33 | market_name="M4A1", 34 | float_="0.0012", 35 | overpay=0.5, 36 | base_price=2, 37 | sell_price=0.5, 38 | ), 39 | OverpayReference( 40 | market_name="M4A1", 41 | float_="0.0023", 42 | overpay=0.4, 43 | base_price=2, 44 | sell_price=0.5, 45 | ), 46 | ], 47 | "AK": [ 48 | OverpayReference(market_name="AK", float_="0.001", overpay=0.5, base_price=2, sell_price=1), 49 | OverpayReference(market_name="AK", float_="0.002", overpay=0.4, base_price=2, sell_price=1), 50 | ], 51 | } 52 | 53 | EXPECTED_LOWEST = { 54 | "M4A1": OverpayReference( 55 | market_name="M4A1", float_="0.0023", overpay=0.4, base_price=2, sell_price=0.5 56 | ), 57 | "AK": OverpayReference( 58 | market_name="AK", float_="0.002", overpay=0.4, base_price=2, sell_price=1 59 | ), 60 | } 61 | 62 | 63 | def test_sort(): 64 | assert sort_each_name_by_profit(SOURCE) == EXPECTED 65 | 66 | 67 | def test_sort_lowest(): 68 | assert sort_name_by_lowest_profit(SOURCE) == EXPECTED_LOWEST 69 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/models/__init__.py -------------------------------------------------------------------------------- /tests/models/test_steam.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.models.steam import SkinSellHistory 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "max_level, expected", 8 | [ 9 | (50, 1.06), 10 | (51, 1.05), 11 | (49, 1.06), 12 | (40, 1.07), 13 | (10, 1.07), 14 | (61, None), 15 | ], 16 | ) 17 | def test_get(max_level, expected): 18 | summary = SkinSellHistory( 19 | market_name="AK", is_stable=True, sold_per_week=150, summary={1.05: 60, 1.06: 50, 1.07: 40} 20 | ) 21 | 22 | assert summary.get(max_level) == expected 23 | -------------------------------------------------------------------------------- /tests/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/parsers/__init__.py -------------------------------------------------------------------------------- /tests/parsers/csmoney/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/parsers/csmoney/__init__.py -------------------------------------------------------------------------------- /tests/parsers/csmoney/parser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/parsers/csmoney/parser/__init__.py -------------------------------------------------------------------------------- /tests/parsers/csmoney/parser/data/item_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": 730, 3 | "assetId": 24898849555, 4 | "float": "0.008115612901747", 5 | "hasHighDemand": false, 6 | "id": 24898849555, 7 | "img": "https://s1.cs.money/nYb8ubG_icon.png", 8 | "nameId": 3985, 9 | "overprice": 0.57, 10 | "price": 24768.93, 11 | "quality": "fn", 12 | "rarity": "Covert", 13 | "steamId": "76561198310252968", 14 | "steamImg": "https://steamcommunity-a.akamaihd.net/economy/image/-9a81dlWLwJ2UUGcVs_nsVtzdOEdtWwKGZZLQHTxDZ7I56KU0Zwwo4NUX4oFJZEHLbXH5ApeO4YmlhxYQknCRvCo04DEVlxkKgpovbSsLQJf0ebcZThQ6tCvq4GGqOT1I6vZn3lU18hwmOvN8IXvjVCLqSwwOj6rYJiRdg42NAuE-lW5kri5hpbuvM7AzHtmsnMh4imPzUa3gB4aaOw9hfCeVxzAUJ5TOTzr", 15 | "tradeLock": 1645430400000, 16 | "type": 2, 17 | "3d": "https://3d.cs.money/item/nYb8ubG", 18 | "hasScreenshot": true, 19 | "preview": "https://s1.cs.money/nYb8ubG_large_preview.png", 20 | "screenshot": "https://s1.cs.money/nYb8ubG_image.jpg", 21 | "userId": null, 22 | "pattern": 615, 23 | "rank": null, 24 | "collection": null, 25 | "overpay": { 26 | "float": 140.69 27 | }, 28 | "stickers": null, 29 | "inspect": "38538278967994973", 30 | "fullName": "★ Butterfly Knife | Doppler Sapphire (Factory New)" 31 | } 32 | -------------------------------------------------------------------------------- /tests/parsers/csmoney/parser/data/item_with_stack_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": 730, 3 | "assetId": 24491496626, 4 | "float": "0.065496817231178", 5 | "hasHighDemand": false, 6 | "id": 24491496626, 7 | "img": "https://s1.cs.money/JcECfYG_icon.png", 8 | "nameId": 29570, 9 | "price": 35718.7, 10 | "quality": "fn", 11 | "rarity": "Extraordinary", 12 | "stackId": "29570_35718.7", 13 | "stackSize": 2, 14 | "steamId": "76561198797034131", 15 | "steamImg": "https://steamcommunity-a.akamaihd.net/economy/image/-9a81dlWLwJ2UUGcVs_nsVtzdOEdtWwKGZZLQHTxDZ7I56KU0Zwwo4NUX4oFJZEHLbXH5ApeO4YmlhxYQknCRvCo04DAQ1JmMR1osbaqPQJz7ODYfi9W9eO0mJWOqOf9PbDummJW4NE_2u3Aooj2i1KwrkNoYW_7dYKXeg9vNVyC_AK-wb_thse9vpmYz3Bn7z5iuyh6Nk1_Sw", 16 | "type": 13, 17 | "3d": "https://3d.cs.money/item/JcECfYG", 18 | "hasScreenshot": true, 19 | "preview": "https://s1.cs.money/JcECfYG_large_preview.png", 20 | "screenshot": "https://s1.cs.money/JcECfYG_image.jpg", 21 | "userId": null, 22 | "pattern": 667, 23 | "rank": null, 24 | "collection": null, 25 | "overpay": null, 26 | "stickers": null, 27 | "inspect": "10153266635756817242", 28 | "fullName": "★ Sport Gloves | Vice (Factory New)", 29 | "stackItems": [ 30 | { 31 | "float": "0.067453943192958", 32 | "id": 24571330159, 33 | "img": "https://s1.cs.money/lFxuoKe_icon.png", 34 | "pattern": 130, 35 | "stackId": "29570_35718.7", 36 | "steamId": "76561198795894040", 37 | "tradeLock": null, 38 | "3d": "https://3d.cs.money/item/lFxuoKe", 39 | "hasScreenshot": true, 40 | "preview": "https://s1.cs.money/lFxuoKe_large_preview.png", 41 | "screenshot": "https://s1.cs.money/lFxuoKe_image.jpg" 42 | } 43 | ] 44 | } -------------------------------------------------------------------------------- /tests/parsers/csmoney/parser/data/item_with_stack_2.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": 730, 3 | "assetId": 24899230485, 4 | "float": "0.056123819202184", 5 | "hasHighDemand": false, 6 | "id": 24899230485, 7 | "img": "https://s1.cs.money/x93C6B5_icon.png", 8 | "nameId": 15840, 9 | "price": 11592.8, 10 | "quality": "fn", 11 | "rarity": "Covert", 12 | "stackId": "15840_11592.81645430400000", 13 | "stackSize": 2, 14 | "steamId": "76561198808714565", 15 | "steamImg": "https://steamcommunity-a.akamaihd.net/economy/image/-9a81dlWLwJ2UUGcVs_nsVtzdOEdtWwKGZZLQHTxDZ7I56KU0Zwwo4NUX4oFJZEHLbXH5ApeO4YmlhxYQknCRvCo04DEVlxkKgpovbSsLQJf3qr3czxb49KzgL-KmsjmJrnIqWZQ-sd9j-Db8IjKhF2zowdyYzjyLIGSIAA8YguCqVK9lOa-1JW5vprBz3EyviB07SveyhfkhklNP_sv26JLM0iiyQ", 16 | "tradeLock": 1645430400000, 17 | "type": 2, 18 | "3d": "https://3d.cs.money/item/x93C6B5", 19 | "hasScreenshot": true, 20 | "preview": "https://s1.cs.money/x93C6B5_large_preview.png", 21 | "screenshot": "https://s1.cs.money/x93C6B5_image.jpg", 22 | "userId": null, 23 | "pattern": 329, 24 | "rank": null, 25 | "collection": null, 26 | "overpay": null, 27 | "stickers": null, 28 | "inspect": "11997660769701535488", 29 | "fullName": "★ M9 Bayonet | Doppler Ruby (Factory New)", 30 | "stackItems": [ 31 | { 32 | "float": "0.06806051731109601", 33 | "id": 24902572721, 34 | "img": "https://s1.cs.money/4feJzEC_icon.png", 35 | "pattern": 422, 36 | "stackId": "15840_11592.81645430400000", 37 | "steamId": "76561198809211591", 38 | "tradeLock": 1645430400000, 39 | "3d": "https://3d.cs.money/item/4feJzEC", 40 | "hasScreenshot": true, 41 | "preview": "https://s1.cs.money/4feJzEC_large_preview.png", 42 | "screenshot": "https://s1.cs.money/4feJzEC_image.jpg" 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /tests/parsers/csmoney/parser/data/item_without_full_name_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": 730, 3 | "assetId": 24898849555, 4 | "float": "0.008115612901747", 5 | "hasHighDemand": false, 6 | "id": 24898849555, 7 | "img": "https://s1.cs.money/nYb8ubG_icon.png", 8 | "nameId": 3985, 9 | "overprice": 0.57, 10 | "price": 24768.93, 11 | "quality": "fn", 12 | "rarity": "Covert", 13 | "steamId": "76561198310252968", 14 | "steamImg": "https://steamcommunity-a.akamaihd.net/economy/image/-9a81dlWLwJ2UUGcVs_nsVtzdOEdtWwKGZZLQHTxDZ7I56KU0Zwwo4NUX4oFJZEHLbXH5ApeO4YmlhxYQknCRvCo04DEVlxkKgpovbSsLQJf0ebcZThQ6tCvq4GGqOT1I6vZn3lU18hwmOvN8IXvjVCLqSwwOj6rYJiRdg42NAuE-lW5kri5hpbuvM7AzHtmsnMh4imPzUa3gB4aaOw9hfCeVxzAUJ5TOTzr", 15 | "tradeLock": 1645430400000, 16 | "type": 2, 17 | "3d": "https://3d.cs.money/item/nYb8ubG", 18 | "hasScreenshot": true, 19 | "preview": "https://s1.cs.money/nYb8ubG_large_preview.png", 20 | "screenshot": "https://s1.cs.money/nYb8ubG_image.jpg", 21 | "userId": null, 22 | "pattern": 615, 23 | "rank": null, 24 | "collection": null, 25 | "overpay": { 26 | "float": 140.69 27 | }, 28 | "stickers": null, 29 | "inspect": "38538278967994973" 30 | } 31 | -------------------------------------------------------------------------------- /tests/parsers/csmoney/parser/test_name_patcher.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.parsers.csmoney.parser._name_patcher import patch_market_name 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "src,expected", 8 | [ 9 | ("★ Sport Gloves | Vice (Factory New)", "★ Sport Gloves | Vice (Factory New)"), 10 | ( 11 | "Souvenir Glock-18 | Nuclear Garden (Factory New)", 12 | "Souvenir Glock-18 | Nuclear Garden (Factory New)", 13 | ), 14 | ( 15 | "★ Butterfly Knife | Doppler Sapphire (Factory New)", 16 | "★ Butterfly Knife | Doppler (Factory New)", 17 | ), 18 | ( 19 | "★ M9 Bayonet | Gamma Doppler Emerald (Minimal Wear)", 20 | "★ M9 Bayonet | Gamma Doppler (Minimal Wear)", 21 | ), 22 | ( 23 | "★ Butterfly Knife | Doppler Ruby (Factory New)", 24 | "★ Butterfly Knife | Doppler (Factory New)", 25 | ), 26 | ( 27 | "★ Talon Knife | Doppler Black Pearl (Minimal Wear)", 28 | "★ Talon Knife | Doppler (Minimal Wear)", 29 | ), 30 | ( 31 | "★ Butterfly Knife | Gamma Doppler Phase 1 (Minimal Wear)", 32 | "★ Butterfly Knife | Gamma Doppler (Minimal Wear)", 33 | ), 34 | ( 35 | "★ Butterfly Knife | Gamma Doppler Phase 2 (Minimal Wear)", 36 | "★ Butterfly Knife | Gamma Doppler (Minimal Wear)", 37 | ), 38 | ( 39 | "★ Butterfly Knife | Gamma Doppler Phase 3 (Minimal Wear)", 40 | "★ Butterfly Knife | Gamma Doppler (Minimal Wear)", 41 | ), 42 | ( 43 | "★ Butterfly Knife | Gamma Doppler Phase 4 (Minimal Wear)", 44 | "★ Butterfly Knife | Gamma Doppler (Minimal Wear)", 45 | ), 46 | ], 47 | ) 48 | def test_patch_market_name(src, expected): 49 | assert patch_market_name(src) == expected 50 | -------------------------------------------------------------------------------- /tests/parsers/csmoney/task_scheduler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/parsers/csmoney/task_scheduler/__init__.py -------------------------------------------------------------------------------- /tests/parsers/csmoney/test_csmoney_parser.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring import decorators 6 | from price_monitoring.models.csmoney import CsmoneyTask 7 | from price_monitoring.parsers.csmoney.csmoney_parser import CsmoneyParser 8 | 9 | 10 | @pytest.fixture() 11 | def parser_impl(): 12 | return AsyncMock() 13 | 14 | 15 | @pytest.fixture() 16 | def result_queue(): 17 | return AsyncMock() 18 | 19 | 20 | @pytest.fixture() 21 | def task_scheduler(): 22 | return AsyncMock() 23 | 24 | 25 | @pytest.fixture() 26 | def parser(parser_impl, result_queue, task_scheduler): 27 | decorators._INFINITE_RUN = False # disable infinite loops 28 | return CsmoneyParser(parser_impl, result_queue, task_scheduler) 29 | 30 | 31 | @pytest.mark.parametrize("task", [CsmoneyTask(url="1"), None]) 32 | async def test_run__task_started( 33 | disable_asyncio_sleep, parser, parser_impl, task_scheduler, result_queue, task 34 | ): 35 | task_scheduler.get_task.return_value = task 36 | 37 | await parser.run() 38 | 39 | if task: 40 | parser_impl.parse.assert_awaited_with(task.url, result_queue) 41 | else: 42 | parser_impl.parse.assert_not_awaited() 43 | 44 | 45 | async def test_run__task_renewed(disable_asyncio_sleep, parser, task_scheduler): 46 | task = CsmoneyTask(url="1") 47 | task_scheduler.get_task.return_value = task 48 | 49 | await parser.run() 50 | 51 | task_scheduler.renew_task_lock.assert_awaited_with(task) 52 | 53 | 54 | @pytest.mark.parametrize( 55 | "parser_exc, scheduler_exc, is_success", 56 | [ 57 | (None, None, True), 58 | (Exception(), None, False), 59 | (None, Exception(), False), 60 | ], 61 | ) 62 | async def test_run__task_released( 63 | disable_asyncio_sleep, 64 | parser, 65 | parser_impl, 66 | task_scheduler, 67 | parser_exc, 68 | scheduler_exc, 69 | is_success, 70 | ): 71 | task = CsmoneyTask(url="1") 72 | task_scheduler.get_task.return_value = task 73 | parser_impl.parse.side_effect = parser_exc 74 | task_scheduler.renew_task_lock.side_effect = scheduler_exc 75 | 76 | await parser.run() 77 | 78 | task_scheduler.release_task.assert_awaited_with(task, is_success) 79 | 80 | 81 | if __name__ == "__main__": 82 | pytest.main() 83 | -------------------------------------------------------------------------------- /tests/parsers/steam/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/parsers/steam/__init__.py -------------------------------------------------------------------------------- /tests/parsers/steam/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(autouse=True) 5 | def no_asyncio_sleep(disable_asyncio_sleep): 6 | ... 7 | -------------------------------------------------------------------------------- /tests/parsers/steam/name_resolver/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/parsers/steam/name_resolver/__init__.py -------------------------------------------------------------------------------- /tests/parsers/steam/name_resolver/response_of_non_existed_item.txt: -------------------------------------------------------------------------------- 1 | var g_rgAssets = []; 2 | var g_rgCurrency = []; 3 | var g_rgListingInfo = []; 4 | var g_plotPriceHistory = null; 5 | var g_timePriceHistoryEarliest = new Date(); 6 | var g_timePriceHistoryLatest = new Date(); -------------------------------------------------------------------------------- /tests/parsers/steam/name_resolver/response_with_name_id.txt: -------------------------------------------------------------------------------- 1 | $J(document).ready( function(){ 2 | Market_LoadOrderSpread( 175880636 ); // initial load 3 | PollOnUserActionAfterInterval( 'MarketOrderSpread', 5000, function() { Market_LoadOrderSpread( 175880636 ); }, 2 * 60 * 1000 ); 4 | }); -------------------------------------------------------------------------------- /tests/parsers/steam/name_resolver/test_memory_cached_name_resolver.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.parsers.steam.name_resolver.abstract_name_resolver import SkinNotFoundError 6 | from price_monitoring.parsers.steam.name_resolver.memory_cached_name_resolver import ( 7 | MemoryCachedNameResolver, 8 | ) 9 | 10 | 11 | @pytest.fixture() 12 | def root_resolver(): 13 | return AsyncMock() 14 | 15 | 16 | @pytest.fixture() 17 | def name_resolver(root_resolver): 18 | return MemoryCachedNameResolver(root_resolver) 19 | 20 | 21 | async def test_resolve__not_cached(name_resolver, root_resolver): 22 | root_resolver.resolve_market_name.return_value = 123 23 | 24 | name_id = await name_resolver.resolve_market_name("AK") 25 | 26 | assert name_id == 123 27 | root_resolver.resolve_market_name.assert_called_with("AK") 28 | 29 | 30 | async def test_resolve__cached(name_resolver, root_resolver): 31 | root_resolver.resolve_market_name.return_value = 123 32 | 33 | name_ids = [] 34 | for _ in range(10): 35 | name_ids.append(await name_resolver.resolve_market_name("AK")) 36 | 37 | for name_id in name_ids: 38 | assert name_id == 123 39 | assert root_resolver.resolve_market_name.call_count == 1 40 | root_resolver.resolve_market_name.assert_called_with("AK") 41 | 42 | 43 | async def test_resolve__not_existed_skin__not_cached(name_resolver, root_resolver): 44 | root_resolver.resolve_market_name.side_effect = SkinNotFoundError("AK") 45 | 46 | with pytest.raises(SkinNotFoundError, match="AK"): 47 | await name_resolver.resolve_market_name("AK") 48 | 49 | assert root_resolver.resolve_market_name.call_count == 1 50 | root_resolver.resolve_market_name.assert_called_with("AK") 51 | 52 | 53 | async def test_resolve__not_existed_skin__cached(name_resolver, root_resolver): 54 | root_resolver.resolve_market_name.side_effect = SkinNotFoundError("AK") 55 | 56 | for _ in range(10): 57 | with pytest.raises(SkinNotFoundError, match="AK"): 58 | await name_resolver.resolve_market_name("AK") 59 | 60 | assert root_resolver.resolve_market_name.call_count == 1 61 | root_resolver.resolve_market_name.assert_called_with("AK") 62 | 63 | 64 | if __name__ == "__main__": 65 | pytest.main() 66 | -------------------------------------------------------------------------------- /tests/parsers/steam/name_resolver/test_name_resolver.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | from aiohttp import ClientSession 5 | from aioresponses import aioresponses 6 | 7 | from price_monitoring.parsers.steam.name_resolver.abstract_name_resolver import SkinNotFoundError 8 | from price_monitoring.parsers.steam.name_resolver.name_resolver import NameResolver 9 | 10 | 11 | def response_with_name_id(): 12 | with open("tests/parsers/steam/name_resolver/response_with_name_id.txt", encoding="utf8") as f: 13 | return f.read() 14 | 15 | 16 | def response_of_non_existed_item(): 17 | with open( 18 | "tests/parsers/steam/name_resolver/response_of_non_existed_item.txt", 19 | encoding="utf8", 20 | ) as f: 21 | return f.read() 22 | 23 | 24 | @pytest.fixture() 25 | async def limiter_fixture(): 26 | session = ClientSession() 27 | m = AsyncMock() 28 | m.get_available.return_value = session 29 | yield m 30 | await session.close() 31 | 32 | 33 | @pytest.fixture() 34 | def resolver(limiter_fixture): 35 | return NameResolver(limiter_fixture) 36 | 37 | 38 | async def test_resolve(resolver): 39 | with aioresponses() as m: 40 | m.get( 41 | "https://steamcommunity.com/market/listings/730/AK47", 42 | payload=response_with_name_id(), 43 | ) 44 | name_id = await resolver.resolve_market_name("AK47") 45 | 46 | assert name_id == 175880636 47 | 48 | 49 | async def test_resolve__skin_do_not_exist(resolver): 50 | with aioresponses() as m: 51 | m.get( 52 | "https://steamcommunity.com/market/listings/730/AK47", 53 | payload=response_of_non_existed_item(), 54 | ) 55 | with pytest.raises(SkinNotFoundError): 56 | await resolver.resolve_market_name("AK47") 57 | 58 | 59 | @pytest.mark.parametrize("response", ["", "null", ""]) 60 | async def test_resolve__invalid_input(resolver, response): 61 | with aioresponses() as m: 62 | m.get("https://steamcommunity.com/market/listings/730/AK47", payload=response) 63 | with pytest.raises(ValueError): 64 | await resolver.resolve_market_name("AK47") 65 | 66 | 67 | if __name__ == "__main__": 68 | pytest.main() 69 | -------------------------------------------------------------------------------- /tests/parsers/steam/name_resolver/test_redis_cached_name_resolver.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.parsers.steam.name_resolver.abstract_name_resolver import SkinNotFoundError 6 | from price_monitoring.parsers.steam.name_resolver.redis_cached_name_resolver import ( 7 | RedisCachedNameResolver, 8 | ) 9 | 10 | 11 | @pytest.fixture() 12 | def root_resolver(): 13 | return AsyncMock() 14 | 15 | 16 | @pytest.fixture() 17 | def name_resolver(root_resolver, fake_redis): 18 | return RedisCachedNameResolver(root_resolver, fake_redis) 19 | 20 | 21 | async def test_resolve__not_cached(name_resolver, root_resolver, fake_redis): 22 | root_resolver.resolve_market_name.return_value = 123 23 | 24 | name_id = await name_resolver.resolve_market_name("AK") 25 | 26 | assert name_id == 123 27 | root_resolver.resolve_market_name.assert_called_with("AK") 28 | 29 | 30 | async def test_resolve__cached(name_resolver, root_resolver, fake_redis): 31 | root_resolver.resolve_market_name.return_value = 123 32 | 33 | name_ids = [] 34 | for _ in range(10): 35 | name_ids.append(await name_resolver.resolve_market_name("AK")) 36 | 37 | for name_id in name_ids: 38 | assert name_id == 123 39 | assert root_resolver.resolve_market_name.call_count == 1 40 | root_resolver.resolve_market_name.assert_called_with("AK") 41 | 42 | 43 | async def test_resolve__not_existed_skin__not_cached(name_resolver, root_resolver, fake_redis): 44 | root_resolver.resolve_market_name.side_effect = SkinNotFoundError("AK") 45 | 46 | with pytest.raises(SkinNotFoundError, match="AK"): 47 | await name_resolver.resolve_market_name("AK") 48 | 49 | assert root_resolver.resolve_market_name.call_count == 1 50 | root_resolver.resolve_market_name.assert_called_with("AK") 51 | 52 | 53 | async def test_resolve__not_existed_skin__cached(name_resolver, root_resolver, fake_redis): 54 | root_resolver.resolve_market_name.side_effect = SkinNotFoundError("AK") 55 | 56 | for _ in range(10): 57 | with pytest.raises(SkinNotFoundError, match="AK"): 58 | await name_resolver.resolve_market_name("AK") 59 | 60 | assert root_resolver.resolve_market_name.call_count == 1 61 | root_resolver.resolve_market_name.assert_called_with("AK") 62 | 63 | 64 | if __name__ == "__main__": 65 | pytest.main() 66 | -------------------------------------------------------------------------------- /tests/parsers/steam/parser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/parsers/steam/parser/__init__.py -------------------------------------------------------------------------------- /tests/parsers/steam/skin_scheduler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/parsers/steam/skin_scheduler/__init__.py -------------------------------------------------------------------------------- /tests/parsers/steam/skin_scheduler/test_scheduler_filler.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.models.steam import MarketNamePack 6 | from price_monitoring.parsers.steam.skin_scheduler.scheduler_filler import SchedulerFiller 7 | 8 | 9 | @pytest.fixture() 10 | def market_name_queue(): 11 | return AsyncMock() 12 | 13 | 14 | @pytest.fixture() 15 | def skin_scheduler(): 16 | return AsyncMock() 17 | 18 | 19 | @pytest.fixture() 20 | def parser(market_name_queue, skin_scheduler): 21 | return SchedulerFiller(market_name_queue, [skin_scheduler]) 22 | 23 | 24 | @pytest.mark.parametrize( 25 | "market_name, is_scheduled", 26 | [ 27 | ("AK", True), 28 | (None, False), 29 | ], 30 | ) 31 | async def test_run_market_name_reader( 32 | parser, market_name_queue, skin_scheduler, market_name, is_scheduled 33 | ): 34 | market_name_queue.get.return_value = ( 35 | MarketNamePack(items=[market_name]) if market_name else None 36 | ) 37 | 38 | await parser._run_market_name_reader() 39 | 40 | if is_scheduled: 41 | skin_scheduler.append_market_name.assert_awaited_with(market_name) 42 | else: 43 | assert skin_scheduler.append_market_name.call_count == 0 44 | 45 | 46 | async def test_run(parser): 47 | parser._run_market_name_reader = AsyncMock() 48 | 49 | await parser.run() 50 | 51 | parser._run_market_name_reader.assert_awaited() 52 | 53 | 54 | async def test_run__do_not_crash(parser, market_name_queue): 55 | market_name_queue.get.side_effect = Exception() 56 | 57 | await parser.run() 58 | 59 | 60 | if __name__ == "__main__": 61 | pytest.main() 62 | -------------------------------------------------------------------------------- /tests/parsers/steam/test_steam_parser.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.parsers.steam.steam_order_parser import SteamOrderParser 6 | 7 | 8 | @pytest.fixture() 9 | def order_parser(): 10 | return AsyncMock() 11 | 12 | 13 | @pytest.fixture() 14 | def skin_scheduler(): 15 | return AsyncMock() 16 | 17 | 18 | @pytest.fixture() 19 | def steam_result_queue(): 20 | return AsyncMock() 21 | 22 | 23 | @pytest.fixture() 24 | def parser(order_parser, skin_scheduler, steam_result_queue): 25 | return SteamOrderParser(order_parser, skin_scheduler, steam_result_queue) 26 | 27 | 28 | @pytest.mark.parametrize( 29 | "market_name, is_success", 30 | [ 31 | ("AK", True), 32 | ("AK", False), 33 | (None, None), 34 | ], 35 | ) 36 | async def test_run_steam_parser( 37 | parser, order_parser, steam_result_queue, skin_scheduler, market_name, is_success 38 | ): 39 | skin_scheduler.get_skin.return_value = market_name 40 | order_parser.fetch_orders.return_value = is_success 41 | 42 | await parser._run_steam_parser() 43 | 44 | skin_scheduler.get_skin.assert_called() 45 | if market_name: 46 | order_parser.fetch_orders.assert_awaited_with( 47 | market_name=market_name, result_queue=steam_result_queue 48 | ) 49 | skin_scheduler.release_skin.assert_awaited_with(market_name, is_success) 50 | else: 51 | order_parser.fetch_orders.assert_not_called() 52 | skin_scheduler.release_skin.assert_not_called() 53 | 54 | 55 | async def test_run_steam_parser__with_error( 56 | parser, order_parser, steam_result_queue, skin_scheduler 57 | ): 58 | skin_scheduler.get_skin.return_value = "AK" 59 | order_parser.fetch_orders.side_effect = Exception() 60 | 61 | await parser._run_steam_parser() 62 | 63 | skin_scheduler.release_skin.assert_awaited_with("AK", False) 64 | 65 | 66 | async def test_run(parser): 67 | parser._run_steam_parser = AsyncMock() 68 | 69 | await parser.run() 70 | 71 | parser._run_steam_parser.assert_awaited() 72 | 73 | 74 | async def test_run__do_not_crash(parser, skin_scheduler): 75 | skin_scheduler.get_skin.side_effect = Exception() 76 | 77 | await parser.run() 78 | 79 | 80 | if __name__ == "__main__": 81 | pytest.main() 82 | -------------------------------------------------------------------------------- /tests/queues/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/queues/__init__.py -------------------------------------------------------------------------------- /tests/queues/rabbitmq/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/queues/rabbitmq/__init__.py -------------------------------------------------------------------------------- /tests/queues/rabbitmq/test_csmoney_result_queue.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.models.csmoney import CsmoneyItemPack 6 | from price_monitoring.queues.rabbitmq.csmoney_result_queue import CsmoneyWriter, CsmoneyReader 7 | 8 | 9 | def _create_item_pack(): 10 | return CsmoneyItemPack(items=[]) 11 | 12 | 13 | @pytest.fixture() 14 | def reader(): 15 | return AsyncMock() 16 | 17 | 18 | @pytest.fixture() 19 | def queue_reader(reader): 20 | return CsmoneyReader(reader) 21 | 22 | 23 | @pytest.fixture() 24 | def publisher(): 25 | return AsyncMock() 26 | 27 | 28 | @pytest.fixture() 29 | def queue_writer(publisher): 30 | return CsmoneyWriter(publisher) 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "data, item_pack", 35 | [ 36 | (_create_item_pack().dump_bytes(), _create_item_pack()), 37 | (None, None), 38 | ], 39 | ) 40 | async def test_get(queue_reader, reader, data, item_pack): 41 | reader.read.return_value = data 42 | 43 | result = await queue_reader.get(timeout=1) 44 | 45 | assert result == item_pack 46 | reader.read.assert_called_with(timeout=1) 47 | 48 | 49 | async def test_put(queue_writer, publisher): 50 | item_pack = _create_item_pack() 51 | await queue_writer.put(item_pack) 52 | 53 | publisher.publish.assert_called_with(item_pack.dump_bytes()) 54 | -------------------------------------------------------------------------------- /tests/queues/rabbitmq/test_market_name_queue.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.models.steam import MarketNamePack 6 | from price_monitoring.queues.rabbitmq.market_name_queue import MarketNameReader, MarketNameWriter 7 | 8 | _PACK = MarketNamePack(items=["AK", "M4A1"]) 9 | 10 | 11 | @pytest.fixture() 12 | def reader(): 13 | return AsyncMock() 14 | 15 | 16 | @pytest.fixture() 17 | def queue_reader(reader): 18 | return MarketNameReader(reader) 19 | 20 | 21 | @pytest.fixture() 22 | def publisher(): 23 | return AsyncMock() 24 | 25 | 26 | @pytest.fixture() 27 | def queue_writer(publisher): 28 | return MarketNameWriter(publisher) 29 | 30 | 31 | @pytest.mark.parametrize( 32 | "data, items", 33 | [ 34 | (_PACK.dump_bytes(), _PACK), 35 | (None, None), 36 | ], 37 | ) 38 | async def test_get(queue_reader, reader, data, items): 39 | reader.read.return_value = data 40 | 41 | result = await queue_reader.get(timeout=1) 42 | 43 | assert result == items 44 | reader.read.assert_called_with(timeout=1) 45 | 46 | 47 | async def test_put(queue_writer, publisher): 48 | await queue_writer.put(_PACK) 49 | 50 | publisher.publish.assert_called_with(_PACK.dump_bytes()) 51 | -------------------------------------------------------------------------------- /tests/queues/rabbitmq/test_steam_result_queue.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.models.steam import SteamSkinHistogram 6 | from price_monitoring.queues.rabbitmq.steam_result_queue import SteamOrderWriter, SteamOrderReader 7 | 8 | 9 | def _create_skin(): 10 | return SteamSkinHistogram(market_name="AK", response=dict(foo="bar")) 11 | 12 | 13 | @pytest.fixture() 14 | def reader(): 15 | return AsyncMock() 16 | 17 | 18 | @pytest.fixture() 19 | def queue_reader(reader): 20 | return SteamOrderReader(reader) 21 | 22 | 23 | @pytest.fixture() 24 | def publisher(): 25 | return AsyncMock() 26 | 27 | 28 | @pytest.fixture() 29 | def queue_writer(publisher): 30 | return SteamOrderWriter(publisher) 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "data, skin", 35 | [ 36 | (_create_skin().dump_bytes(), _create_skin()), 37 | (None, None), 38 | ], 39 | ) 40 | async def test_get(queue_reader, reader, data, skin): 41 | reader.read.return_value = data 42 | 43 | result = await queue_reader.get(timeout=1) 44 | 45 | assert result == skin 46 | reader.read.assert_called_with(timeout=1) 47 | 48 | 49 | async def test_put(queue_writer, publisher): 50 | skin = _create_skin() 51 | await queue_writer.put(skin) 52 | 53 | publisher.publish.assert_called_with(skin.dump_bytes()) 54 | -------------------------------------------------------------------------------- /tests/queues/rabbitmq/test_steam_sell_history_queue.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.models.steam import SteamSellHistory 6 | from price_monitoring.queues.rabbitmq.steam_sell_history_queue import ( 7 | SteamSellHistoryReader, 8 | SteamSellHistoryWriter, 9 | ) 10 | 11 | 12 | def _create_history(): 13 | return SteamSellHistory(market_name="AK", encoded_data="[]") 14 | 15 | 16 | @pytest.fixture() 17 | def reader(): 18 | return AsyncMock() 19 | 20 | 21 | @pytest.fixture() 22 | def queue_reader(reader): 23 | return SteamSellHistoryReader(reader) 24 | 25 | 26 | @pytest.fixture() 27 | def publisher(): 28 | return AsyncMock() 29 | 30 | 31 | @pytest.fixture() 32 | def queue_writer(publisher): 33 | return SteamSellHistoryWriter(publisher) 34 | 35 | 36 | @pytest.mark.parametrize( 37 | "data, skin", 38 | [ 39 | (_create_history().dump_bytes(), _create_history()), 40 | (None, None), 41 | ], 42 | ) 43 | async def test_get(queue_reader, reader, data, skin): 44 | reader.read.return_value = data 45 | 46 | result = await queue_reader.get(timeout=1) 47 | 48 | assert result == skin 49 | reader.read.assert_called_with(timeout=1) 50 | 51 | 52 | async def test_put(queue_writer, publisher): 53 | skin = _create_history() 54 | await queue_writer.put(skin) 55 | 56 | publisher.publish.assert_called_with(skin.dump_bytes()) 57 | -------------------------------------------------------------------------------- /tests/storage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/storage/__init__.py -------------------------------------------------------------------------------- /tests/storage/csmoney/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/storage/csmoney/__init__.py -------------------------------------------------------------------------------- /tests/storage/proxy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/storage/proxy/__init__.py -------------------------------------------------------------------------------- /tests/storage/proxy/test_redis_proxy_storage.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from proxy_http.proxy import Proxy 4 | from price_monitoring.storage.proxy import RedisProxyStorage 5 | 6 | _KEY = "common_proxies" 7 | _PROXIES = [Proxy(proxy=f"https://1.1.1.1:{port}") for port in range(100, 105)] 8 | 9 | 10 | @pytest.fixture() 11 | def storage(fake_redis): 12 | return RedisProxyStorage(fake_redis, _KEY) 13 | 14 | 15 | async def test_add_and_get_all(storage): 16 | for proxy in _PROXIES: 17 | await storage.add(proxy) 18 | 19 | result = await storage.get_all() 20 | assert len(result) == len(_PROXIES) 21 | for proxy in _PROXIES: 22 | assert proxy in result 23 | 24 | 25 | async def test_remove(storage): 26 | await storage.add(_PROXIES[0]) 27 | await storage.add(_PROXIES[1]) 28 | 29 | await storage.remove(_PROXIES[1]) 30 | 31 | result = await storage.get_all() 32 | assert result == [_PROXIES[0]] 33 | -------------------------------------------------------------------------------- /tests/storage/steam/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/storage/steam/__init__.py -------------------------------------------------------------------------------- /tests/storage/steam/test_redis_steam_orders_storage.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.storage.steam import RedisSteamOrdersStorage 4 | from price_monitoring.storage.steam.redis_steam_orders_storage import ( 5 | _extract_orders, 6 | _key, 7 | _extract_market_name, 8 | ) 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "key,buy,sell", 13 | [ 14 | ("1:2", 1, 2), 15 | ("0.23:0.26", 0.23, 0.26), 16 | ("None:0.26", None, 0.26), 17 | ("0.23:None", 0.23, None), 18 | ("None:None", None, None), 19 | ], 20 | ) 21 | def test_extract_orders(key, buy, sell): 22 | assert _extract_orders(key) == (buy, sell) 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "key,name", 27 | [ 28 | ( 29 | "prices:csmoney:★ StatTrak™ M9 Bayonet | Crimson Web (Factory New)", 30 | "★ StatTrak™ M9 Bayonet | Crimson Web (Factory New)", 31 | ), 32 | ( 33 | "prices:csmoney:★ Sport Gloves | Vice (Factory New)", 34 | "★ Sport Gloves | Vice (Factory New)", 35 | ), 36 | ( 37 | "prices:csmoney:Music Kit | Scarlxrd: King, Scar", 38 | "Music Kit | Scarlxrd: King, Scar", 39 | ), 40 | ], 41 | ) 42 | def test_extract_market_name(key, name): 43 | assert _extract_market_name(key) == name 44 | 45 | 46 | @pytest.fixture() 47 | def storage(fake_redis): 48 | return RedisSteamOrdersStorage(fake_redis) 49 | 50 | 51 | async def test_empty(storage): 52 | assert await storage.get_all() == {} 53 | 54 | 55 | async def test_update(storage): 56 | await storage.update_skin_order("AK", 1.05, 1.07) 57 | 58 | assert await storage.get_all() == {"AK": (1.05, 1.07)} 59 | 60 | 61 | async def test_several_update(storage): 62 | await storage.update_skin_order("AK", 1.05, 1.07) 63 | await storage.update_skin_order("AK", 1.06, 1.09) 64 | 65 | assert await storage.get_all() == {"AK": (1.06, 1.09)} 66 | 67 | 68 | async def test_ttl(fake_redis, storage): 69 | await storage.update_skin_order("AK", 1.05, 1.07) 70 | 71 | assert await fake_redis.ttl(_key("AK")) > 0 72 | -------------------------------------------------------------------------------- /tests/storage/steam/test_redis_steam_sell_history_storage.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.models.steam import SkinSellHistory 4 | from price_monitoring.storage.steam import RedisSteamSellHistoryStorage 5 | from price_monitoring.storage.steam.redis_steam_sell_history_storage import ( 6 | _key, 7 | _extract_market_name, 8 | ) 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "key,name", 13 | [ 14 | ( 15 | "prices:csmoney:★ StatTrak™ M9 Bayonet | Crimson Web (Factory New)", 16 | "★ StatTrak™ M9 Bayonet | Crimson Web (Factory New)", 17 | ), 18 | ( 19 | "prices:csmoney:★ Sport Gloves | Vice (Factory New)", 20 | "★ Sport Gloves | Vice (Factory New)", 21 | ), 22 | ( 23 | "prices:csmoney:Music Kit | Scarlxrd: King, Scar", 24 | "Music Kit | Scarlxrd: King, Scar", 25 | ), 26 | ], 27 | ) 28 | def test_extract_market_name(key, name): 29 | assert _extract_market_name(key) == name 30 | 31 | 32 | @pytest.fixture() 33 | def storage(fake_redis): 34 | return RedisSteamSellHistoryStorage(fake_redis) 35 | 36 | 37 | async def test_empty(storage): 38 | assert await storage.get_all() == {} 39 | 40 | 41 | _history1 = SkinSellHistory( 42 | market_name="AK", 43 | is_stable=True, 44 | sold_per_week=125, 45 | summary={}, 46 | ) 47 | _history2 = SkinSellHistory( 48 | market_name="AK", 49 | is_stable=False, 50 | sold_per_week=25, 51 | summary={}, 52 | ) 53 | 54 | 55 | async def test_update(storage): 56 | await storage.update_skin(_history1) 57 | 58 | assert await storage.get_all() == {"AK": _history1} 59 | 60 | 61 | async def test_several_update(storage): 62 | await storage.update_skin(_history1) 63 | await storage.update_skin(_history2) 64 | 65 | assert await storage.get_all() == {"AK": _history2} 66 | 67 | 68 | async def test_ttl(fake_redis, storage): 69 | await storage.update_skin(_history1) 70 | 71 | assert await fake_redis.ttl(_key("AK")) > 0 72 | -------------------------------------------------------------------------------- /tests/telegram/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/telegram/__init__.py -------------------------------------------------------------------------------- /tests/telegram/bot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/telegram/bot/__init__.py -------------------------------------------------------------------------------- /tests/telegram/bot/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/telegram/bot/commands/__init__.py -------------------------------------------------------------------------------- /tests/telegram/bot/commands/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | 6 | class MessageStub: 7 | def __init__(self, args: str): 8 | self._args = args 9 | self.reply = AsyncMock() 10 | 11 | def get_args(self): 12 | return self._args 13 | 14 | 15 | @pytest.fixture() 16 | def message(): 17 | return MessageStub("") 18 | -------------------------------------------------------------------------------- /tests/telegram/bot/commands/test_set_limit.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.telegram.bot.commands.set_limit import SetLimit 6 | from price_monitoring.telegram.models import NotificationSettings 7 | from tests.telegram.bot.commands.conftest import MessageStub 8 | 9 | 10 | @pytest.fixture() 11 | def settings_provider(): 12 | return AsyncMock() 13 | 14 | 15 | @pytest.fixture() 16 | def command(settings_provider): 17 | return SetLimit(settings_provider) 18 | 19 | 20 | @pytest.mark.parametrize("text", ["15.3", "0", "-1.2", "-12.34", "1"]) 21 | async def test__limit_updated(settings_provider, command, text): 22 | settings_provider.get.return_value = NotificationSettings() 23 | percentage = float(text) 24 | message = MessageStub(text) 25 | 26 | await command.handler(message) 27 | 28 | settings = settings_provider.set.call_args[0][0] 29 | assert settings.max_threshold == percentage 30 | message.reply.assert_awaited_with(f"Limit for {percentage}% successfully set!") 31 | 32 | 33 | @pytest.mark.parametrize("text", ["-15,3", "--15", "abc", "", "15.3%"]) 34 | async def test__failed_to_parse_args(settings_provider, command, text): 35 | message = MessageStub(text) 36 | 37 | await command.handler(message) 38 | 39 | message.reply.assert_awaited_with(f"could not convert string to float: '{text}'") 40 | 41 | 42 | async def test__reply_with_error(settings_provider, command): 43 | message = MessageStub("15.3") 44 | settings_provider.get.side_effect = ValueError("Some error!") 45 | 46 | await command.handler(message) 47 | 48 | message.reply.assert_awaited_with("Some error!") 49 | 50 | 51 | async def test__settings_not_loaded(settings_provider, command): 52 | message = MessageStub("15.3") 53 | settings_provider.get.return_value = None 54 | 55 | await command.handler(message) 56 | 57 | message.reply.assert_awaited_with("Failed to load settings!") 58 | -------------------------------------------------------------------------------- /tests/telegram/bot/commands/test_set_min_price.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.telegram.bot.commands.set_min_price import SetMinPrice 6 | from price_monitoring.telegram.models import NotificationSettings 7 | from tests.telegram.bot.commands.conftest import MessageStub 8 | 9 | 10 | @pytest.fixture() 11 | def settings_provider(): 12 | return AsyncMock() 13 | 14 | 15 | @pytest.fixture() 16 | def command(settings_provider): 17 | return SetMinPrice(settings_provider) 18 | 19 | 20 | @pytest.mark.parametrize("text", ["15.3", "0", "1", "12.34"]) 21 | async def test__min_price_updated(settings_provider, command, text): 22 | settings_provider.get.return_value = NotificationSettings() 23 | min_price = float(text) 24 | message = MessageStub(text) 25 | 26 | await command.handler(message) 27 | 28 | settings = settings_provider.set.call_args[0][0] 29 | assert settings.min_price == min_price 30 | message.reply.assert_awaited_with(f"Minimal price ${min_price} successfully set!") 31 | 32 | 33 | @pytest.mark.parametrize("text", ["15,3", "--15", "abc", "", "$15.3"]) 34 | async def test__failed_to_parse_args(settings_provider, command, text): 35 | message = MessageStub(text) 36 | 37 | await command.handler(message) 38 | 39 | assert settings_provider.set.call_count == 0 40 | message.reply.assert_awaited_with(f"could not convert string to float: '{text}'") 41 | 42 | 43 | async def test__negative_value_not_allowed(settings_provider, command): 44 | message = MessageStub("-2.34") 45 | 46 | await command.handler(message) 47 | 48 | assert settings_provider.set.call_count == 0 49 | message.reply.assert_awaited_with("Negative values are not allowed!") 50 | 51 | 52 | async def test__reply_with_error(settings_provider, command): 53 | message = MessageStub("15.3") 54 | settings_provider.get.side_effect = ValueError("Some error!") 55 | 56 | await command.handler(message) 57 | 58 | assert settings_provider.set.call_count == 0 59 | message.reply.assert_awaited_with("Some error!") 60 | 61 | 62 | async def test__settings_not_loaded(settings_provider, command): 63 | message = MessageStub("15.3") 64 | settings_provider.get.return_value = None 65 | 66 | await command.handler(message) 67 | 68 | message.reply.assert_awaited_with("Failed to load settings!") 69 | -------------------------------------------------------------------------------- /tests/telegram/bot/commands/test_settings.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.telegram.bot.commands.settings import Settings 6 | from price_monitoring.telegram.models import NotificationSettings 7 | 8 | 9 | @pytest.fixture() 10 | def settings_provider(): 11 | return AsyncMock() 12 | 13 | 14 | @pytest.fixture() 15 | def command(settings_provider): 16 | return Settings(settings_provider) 17 | 18 | 19 | async def test__offer_provider_call(settings_provider, command, message): 20 | settings = NotificationSettings() 21 | settings_provider.get.return_value = settings 22 | 23 | await command.handler(message) 24 | 25 | message.reply.assert_awaited_with(f"Current settings: {str(settings)}") 26 | 27 | 28 | async def test__reply_with_error(settings_provider, command, message): 29 | settings_provider.get.side_effect = ValueError("There is an error!") 30 | 31 | await command.handler(message) 32 | 33 | message.reply.assert_awaited_with("There is an error!") 34 | -------------------------------------------------------------------------------- /tests/telegram/bot/test_notification_formatter.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from price_monitoring.telegram.bot.notification_formatter import to_markdown, several_to_markdown 6 | from price_monitoring.telegram.models import ItemOfferNotification 7 | 8 | 9 | @pytest.fixture() 10 | def notification(): 11 | return ItemOfferNotification( 12 | market_name="StatTrak™ M249 | Aztec (Field-Tested)", 13 | orig_price=0.25, 14 | sell_price=0.21, 15 | short_title="AUTOBUY", 16 | ) 17 | 18 | 19 | @pytest.fixture(autouse=True, scope="function") 20 | def mock_add_fee(): 21 | with mock.patch("price_monitoring.telegram.steam_fee.SteamFee.add_fee") as add_fee: 22 | add_fee.return_value = 0.27 23 | yield add_fee 24 | 25 | 26 | def test_to_markdown(notification): 27 | text = to_markdown(notification) 28 | 29 | assert ( 30 | text 31 | == """*\\-16\\.0%* $0\\.25 \\-\\> $0\\.21 \\($0\\.27\\) _\rAUTOBUY_\r 32 | `StatTrak™ M249 \\| Aztec \\(Field\\-Tested\\)` 33 | [StatTrak™ M249 \\| Aztec \\(Field\\-Tested\\)](https://steamcommunity\\.com/market/listings/730/StatTrak™ M249 \\| Aztec \\(Field\\-Tested\\))""" 34 | ) 35 | 36 | 37 | def test_several_to_markdown(notification): 38 | with mock.patch("price_monitoring.telegram.bot.notification_formatter.to_markdown") as func: 39 | func.return_value = "123" 40 | result = several_to_markdown([notification for _ in range(3)]) 41 | 42 | assert result == "\n\n".join(["123"] * 3) 43 | -------------------------------------------------------------------------------- /tests/telegram/bot/test_redis_settings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.telegram.bot.redis_settings import RedisSettings 4 | from price_monitoring.telegram.models import NotificationSettings 5 | 6 | 7 | @pytest.fixture() 8 | def storage(fake_redis): 9 | return RedisSettings(fake_redis, "key123") 10 | 11 | 12 | async def test__right_key_used(storage, fake_redis): 13 | await storage.set_default() 14 | 15 | assert await fake_redis.keys() == [b"key123"] 16 | 17 | 18 | async def test_get__empty(storage): 19 | assert await storage.get() is None 20 | 21 | 22 | async def test_set(storage): 23 | default = NotificationSettings() 24 | 25 | await storage.set(default) 26 | 27 | assert await storage.get() == default 28 | 29 | 30 | async def test_set_default(storage): 31 | default = NotificationSettings() 32 | 33 | await storage.set_default() 34 | 35 | assert await storage.get() == default 36 | -------------------------------------------------------------------------------- /tests/telegram/bot/test_redis_whitelist.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.telegram.bot.redis_whitelist import RedisWhitelist 4 | 5 | 6 | @pytest.fixture 7 | def whitelist(fake_redis): 8 | return RedisWhitelist(fake_redis, "key123") 9 | 10 | 11 | async def test_key(whitelist, fake_redis): 12 | await whitelist.add_member(1) 13 | 14 | keys = await fake_redis.keys("*") 15 | 16 | assert keys == [b"key123"] 17 | 18 | 19 | async def test_add_member_and_get_members(whitelist, fake_redis): 20 | await whitelist.add_member(1) 21 | await whitelist.add_member(7) 22 | await whitelist.add_member(4) 23 | result = await whitelist.get_members() 24 | 25 | assert len(result) == 3 26 | for i in [1, 7, 4]: 27 | assert i in result 28 | assert all(isinstance(x, int) for x in result) 29 | 30 | 31 | async def test_get_members__no_members(whitelist, fake_redis): 32 | result = await whitelist.get_members() 33 | 34 | assert result == [] 35 | 36 | 37 | async def test_remove_member(whitelist, fake_redis): 38 | await whitelist.add_member(1) 39 | await whitelist.add_member(7) 40 | 41 | await whitelist.remove_member(1) 42 | result = await whitelist.get_members() 43 | 44 | assert result == [7] 45 | -------------------------------------------------------------------------------- /tests/telegram/fresh_filter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/telegram/fresh_filter/__init__.py -------------------------------------------------------------------------------- /tests/telegram/fresh_filter/test_redis_filter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.telegram.fresh_filter.redis_filter import RedisFilter 4 | from price_monitoring.telegram.offers import BaseItemOffer 5 | 6 | _offers = [ 7 | BaseItemOffer(market_name="AK", orig_price=1, sell_price=2), 8 | BaseItemOffer(market_name="AK2", orig_price=2, sell_price=3), 9 | BaseItemOffer(market_name="AK3", orig_price=2, sell_price=3), 10 | BaseItemOffer(market_name="AK4", orig_price=2, sell_price=3), 11 | ] 12 | 13 | 14 | @pytest.fixture() 15 | def filter_(fake_redis): 16 | return RedisFilter(fake_redis) 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "loaded, passed, expected", 21 | [ 22 | ([], _offers, _offers), 23 | (_offers[:1], _offers, _offers[1:]), 24 | (_offers[:2], _offers, _offers[2:]), 25 | (_offers[:3], _offers, _offers[3:]), 26 | ], 27 | ) 28 | async def test_filter(filter_, loaded, passed, expected): 29 | await filter_.append_offers(loaded) 30 | 31 | new_offers = await filter_.filter_new_offers(_offers) 32 | 33 | assert new_offers == expected 34 | -------------------------------------------------------------------------------- /tests/telegram/offer_provider/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/telegram/offer_provider/__init__.py -------------------------------------------------------------------------------- /tests/telegram/offer_provider/test_chain_provider.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | from price_monitoring.telegram.offer_provider.chain_provider import ChainProvider 4 | 5 | 6 | async def test_get_items__existence(): 7 | provider1 = AsyncMock() 8 | provider1.get_items.return_value = [1] 9 | provider2 = AsyncMock() 10 | provider2.get_items.return_value = [2] 11 | provider = ChainProvider([provider1, provider2]) 12 | 13 | items = await provider.get_items(-100) 14 | 15 | assert set(items) == {1, 2} 16 | -------------------------------------------------------------------------------- /tests/telegram/offer_provider/test_settings_based_provider.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.telegram.models import NotificationSettings 6 | from price_monitoring.telegram.offer_provider.settings_based_provider import SettingsBasedProvider 7 | 8 | 9 | @pytest.fixture() 10 | def settings_provider(): 11 | return AsyncMock() 12 | 13 | 14 | @pytest.fixture() 15 | def offer_provider(): 16 | return AsyncMock() 17 | 18 | 19 | @pytest.fixture() 20 | def main_provider(settings_provider, offer_provider): 21 | return SettingsBasedProvider(settings_provider, offer_provider) 22 | 23 | 24 | @pytest.mark.parametrize("percentage_limit", [None, -10]) 25 | @pytest.mark.parametrize("min_price", [None, 20]) 26 | async def test__offer_provider_call( 27 | settings_provider, offer_provider, main_provider, percentage_limit, min_price 28 | ): 29 | settings = NotificationSettings(min_price=15.12, max_threshold=-12.34) 30 | settings_provider.get.return_value = settings 31 | offer_provider.get_items.return_value = [1, 2] 32 | 33 | result = await main_provider.get_items(percentage_limit=percentage_limit, min_price=min_price) 34 | 35 | assert result == [1, 2] 36 | expected_percentage_limit = percentage_limit or settings.max_threshold 37 | expected_min_price = min_price or settings.min_price 38 | offer_provider.get_items.assert_awaited_with( 39 | percentage_limit=expected_percentage_limit, min_price=expected_min_price 40 | ) 41 | 42 | 43 | async def test_no_settings(settings_provider, main_provider): 44 | settings_provider.get.return_value = None 45 | 46 | with pytest.raises(ValueError, match="Failed to load settings!"): 47 | await main_provider.get_items() 48 | -------------------------------------------------------------------------------- /tests/telegram/offers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/telegram/offers/__init__.py -------------------------------------------------------------------------------- /tests/telegram/offers/test_base_item_offer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.telegram.offers.base_item_offer import BaseItemOffer 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "offer, diff", 8 | [ 9 | (BaseItemOffer("", 10, 10), 0), 10 | (BaseItemOffer("", 10, 9), -1), 11 | (BaseItemOffer("", 10, 11.5), 1.5), 12 | (BaseItemOffer("", 10.01, 10.02), 0.01), 13 | ], 14 | ) 15 | def test_compute_difference(offer, diff): 16 | assert offer.compute_difference() == diff 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "offer, percentage", 21 | [ 22 | (BaseItemOffer("", 10, 10), 0), 23 | (BaseItemOffer("", 10, 9), -10), 24 | (BaseItemOffer("", 10, 11.5), 15), 25 | (BaseItemOffer("", 10, 10.1), 1), 26 | (BaseItemOffer("", 10, 10.01), 0.1), 27 | ], 28 | ) 29 | def test_compute_percentage(offer, percentage): 30 | assert offer.compute_percentage() == percentage 31 | 32 | 33 | def test_create_notification(): 34 | offer = BaseItemOffer("AK", 10, 11.5) 35 | 36 | notification = offer.create_notification() 37 | 38 | assert notification.market_name == offer.market_name 39 | assert notification.orig_price == offer.orig_price 40 | assert notification.sell_price == offer.sell_price 41 | assert notification.short_title == "UNKNOWN" 42 | -------------------------------------------------------------------------------- /tests/telegram/offers/test_steam_orders_offer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.telegram.offers import SteamOrdersOffer 4 | 5 | 6 | @pytest.fixture() 7 | def offer(): 8 | return SteamOrdersOffer(market_name="AK", orig_price=1, buy_order=1.2) 9 | 10 | 11 | def test_create_notification(offer): 12 | notification = offer.create_notification() 13 | 14 | assert notification.short_title == "AUTOBUY" 15 | assert notification.market_name == "AK" 16 | assert notification.orig_price == 1 17 | assert notification.sell_price == 1.2 18 | -------------------------------------------------------------------------------- /tests/telegram/offers/test_steam_sell_history_offer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.telegram.offers import SteamSellHistoryOffer 4 | 5 | 6 | @pytest.fixture() 7 | def offer(): 8 | return SteamSellHistoryOffer( 9 | market_name="AK", 10 | orig_price=1, 11 | suggested_price=1.2, 12 | mean_price=1.21, 13 | sold_per_week=100, 14 | ) 15 | 16 | 17 | @pytest.mark.parametrize("lock_status", ["TRADEBAN", None]) 18 | def test_create_notification(offer, lock_status): 19 | offer.lock_status = lock_status 20 | notification = offer.create_notification() 21 | 22 | if lock_status: 23 | assert notification.short_title == "AVG $1.21 | 100 SOLD IN WEEK | TRADEBAN" 24 | else: 25 | assert notification.short_title == "AVG $1.21 | 100 SOLD IN WEEK" 26 | assert notification.market_name == "AK" 27 | assert notification.orig_price == 1 28 | assert notification.sell_price == 1.2 29 | -------------------------------------------------------------------------------- /tests/telegram/runner/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/telegram/runner/__init__.py -------------------------------------------------------------------------------- /tests/telegram/runner/test_runner_impl.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.telegram.offers import BaseItemOffer 6 | from price_monitoring.telegram.runner.runner_impl import Runner 7 | 8 | 9 | @pytest.fixture() 10 | def bot(): 11 | return AsyncMock() 12 | 13 | 14 | @pytest.fixture() 15 | def price_provider(): 16 | return AsyncMock() 17 | 18 | 19 | @pytest.fixture() 20 | def filter_(): 21 | return AsyncMock() 22 | 23 | 24 | @pytest.fixture() 25 | def runner(bot, price_provider, filter_, disable_asyncio_sleep): 26 | return Runner(bot=bot, price_provider=price_provider, filter_=filter_) 27 | 28 | 29 | async def test_filter_called(runner, price_provider, filter_): 30 | offers = [BaseItemOffer("AK", 10, 9)] 31 | price_provider.get_items.return_value = offers 32 | 33 | await runner.run() 34 | 35 | filter_.filter_new_offers.assert_called_with(offers) 36 | 37 | 38 | async def test_filter_applied_to_offers(runner, price_provider, filter_): 39 | offers = [BaseItemOffer("AK", 10, 9)] 40 | price_provider.get_items.return_value = offers 41 | filter_.filter_new_offers.return_value = [] 42 | 43 | await runner.run() 44 | 45 | filter_.append_offers.assert_called_with(offers) 46 | 47 | 48 | async def test_new_offers_sent(runner, price_provider, bot, filter_): 49 | offer1 = BaseItemOffer("AK", 10, 9) 50 | offer2 = BaseItemOffer("M4A1", 11, 12) 51 | price_provider.get_items.return_value = [offer1, offer2] 52 | filter_.filter_new_offers.return_value = [offer1] 53 | 54 | await runner.run() 55 | 56 | bot.notify.assert_called_once_with(offer1.create_notification()) 57 | -------------------------------------------------------------------------------- /tests/telegram/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.telegram.models import ItemOfferNotification 4 | 5 | 6 | @pytest.fixture() 7 | def notification(): 8 | return ItemOfferNotification( 9 | market_name="StatTrak™ M249 | Aztec (Field-Tested)", 10 | orig_price=0.25, 11 | sell_price=0.21, 12 | short_title="AUTOBUY", 13 | ) 14 | 15 | 16 | def test_compute_percentage_diff(notification): 17 | diff = notification.compute_percentage_diff() 18 | 19 | assert diff == -16 20 | -------------------------------------------------------------------------------- /tests/telegram/test_steam_fee.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from price_monitoring.telegram.steam_fee import SteamFee 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "orig, expected", 8 | [ 9 | (0.01, 0.00), 10 | (0.02, 0.00), 11 | (0.03, 0.01), 12 | (0.04, 0.02), 13 | (0.23, 0.2), 14 | (0.22, 0.19), 15 | (0.21, 0.19), 16 | (0.20, 0.18), 17 | (0.19, 0.17), 18 | (1.49, 1.3), 19 | (2.3, 2), 20 | (3.45, 3), 21 | (4.6, 4), 22 | (5.75, 5), 23 | (14.29, 12.43), 24 | (148.84, 129.43), 25 | ], 26 | ) 27 | def test_subtract_fee(orig, expected): 28 | assert SteamFee.subtract_fee(orig) == expected 29 | 30 | 31 | def test_subtract_fee_high(): 32 | # test all prices from $0.01 to $10 33 | prev_price = None 34 | for i in range(1, 1001): 35 | price = round(i / 100, 2) 36 | price_with_fee = SteamFee.add_fee(price) 37 | assert SteamFee.subtract_fee(price_with_fee) == price 38 | # need to check corner cases, like: 39 | # 0.19 -> 0.21 40 | # 0.19 -> 0.22 41 | if prev_price: 42 | while price - 0.01 > prev_price: 43 | prev_price = round(prev_price + 0.01, 2) 44 | price_with_fee = SteamFee.add_fee(prev_price) 45 | assert SteamFee.subtract_fee(price_with_fee) == price 46 | prev_price = price 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "orig, expected", 51 | [ 52 | (0.01, 0.03), 53 | (0.09, 0.11), 54 | (0.18, 0.2), 55 | (0.19, 0.21), 56 | (0.2, 0.23), 57 | (0.59, 0.66), 58 | (0.6, 0.69), 59 | (1.3, 1.49), 60 | (2, 2.3), 61 | (3, 3.45), 62 | (4, 4.60), 63 | (5, 5.75), 64 | (12.43, 14.29), 65 | (129.43, 148.84), 66 | ], 67 | ) 68 | def test_add_fee(orig, expected): 69 | assert SteamFee.add_fee(orig) == expected 70 | -------------------------------------------------------------------------------- /tests/worker/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/worker/__init__.py -------------------------------------------------------------------------------- /tests/worker/processing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/worker/processing/__init__.py -------------------------------------------------------------------------------- /tests/worker/processing/sell_history/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/tests/worker/processing/sell_history/__init__.py -------------------------------------------------------------------------------- /tests/worker/processing/sell_history/test_analyzer.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | 4 | import pytest 5 | 6 | from price_monitoring.worker.processing.sell_history.analyzer import SellHistoryAnalyzer 7 | 8 | 9 | def _load_data(name: str) -> str: 10 | with open(f"tests/worker/processing/sell_history/{name}.json", "r", encoding="utf8") as f: 11 | return f.read() 12 | 13 | 14 | @pytest.mark.parametrize("name", ["test_data", "test_data_2", "test_data_9"]) 15 | def test_analyze_history(name): 16 | analyzer = SellHistoryAnalyzer(_load_data(name)) 17 | expected = { 18 | float(k): int(v) for k, v in json.loads(_load_data(name + "_expected"))["pairs"].items() 19 | } 20 | assert analyzer.analyze_history(current_dt=datetime(2022, 4, 22, 18)) == expected 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "name, expected", 25 | [ 26 | ("test_data", 391982), 27 | ("test_data_2", 205), 28 | ], 29 | ) 30 | def test_get_sold_amount_for_week(name, expected): 31 | analyzer = SellHistoryAnalyzer(_load_data(name)) 32 | assert analyzer.get_sold_amount_for_week(current_dt=datetime(2022, 4, 22, 18)) == expected 33 | 34 | 35 | def test_dump(): 36 | encoded = '[["Sep 22 2021 01: +0",0.633,"286"],["Sep 23 2021 01: +0",0.675,"242"]]' 37 | analyzer = SellHistoryAnalyzer(encoded) 38 | assert analyzer.dump() == encoded 39 | 40 | 41 | @pytest.mark.parametrize( 42 | "data, is_stable", 43 | [ 44 | ("test_data", True), 45 | ("test_data_2", False), 46 | ("test_data_3", True), 47 | ("test_data_4", True), 48 | ("test_data_5", False), 49 | ("test_data_6", False), 50 | ("test_data_7", True), 51 | ("test_data_8", False), 52 | ("test_data_9", True), 53 | ("test_data_10", False), 54 | ], 55 | ) 56 | def test_is_stable(data, is_stable): 57 | analyzer = SellHistoryAnalyzer(_load_data(data)) 58 | assert analyzer.is_stable(current_dt=datetime(2022, 4, 23, 18)) == is_stable 59 | -------------------------------------------------------------------------------- /tests/worker/processing/sell_history/test_data_2_expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "pairs": { 3 | "0.82": 100.0, 4 | "0.83": 100.0, 5 | "0.84": 98.0, 6 | "0.85": 98.0, 7 | "0.86": 97.0, 8 | "0.87": 97.0, 9 | "0.88": 96.0, 10 | "0.89": 94.0, 11 | "0.9": 91.0, 12 | "0.92": 89.0, 13 | "0.94": 88.0, 14 | "0.96": 84.0, 15 | "0.97": 83.0, 16 | "0.98": 80.0, 17 | "0.99": 79.0, 18 | "1.0": 75.0, 19 | "1.01": 69.0, 20 | "1.02": 61.0, 21 | "1.03": 56.0, 22 | "1.05": 55.0, 23 | "1.06": 54.0, 24 | "1.07": 49.0, 25 | "1.08": 47.0, 26 | "1.09": 39.0, 27 | "1.1": 38.0, 28 | "1.12": 38.0, 29 | "1.13": 37.0, 30 | "1.14": 34.0, 31 | "1.15": 30.0, 32 | "1.16": 29.0, 33 | "1.19": 29.0, 34 | "1.21": 14.0, 35 | "1.25": 13.0, 36 | "1.26": 10.0, 37 | "1.28": 8.0, 38 | "1.29": 5.0, 39 | "1.3": 1.0 40 | } 41 | } -------------------------------------------------------------------------------- /tests/worker/processing/sell_history/test_data_expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "pairs": { 3 | "1.26": 100.0, 4 | "1.27": 96.0, 5 | "1.28": 91.0, 6 | "1.29": 81.0, 7 | "1.3": 68.0, 8 | "1.31": 49.0, 9 | "1.32": 32.0, 10 | "1.33": 22.0, 11 | "1.34": 19.0, 12 | "1.35": 14.0, 13 | "1.36": 8.0, 14 | "1.37": 6.0, 15 | "1.38": 5.0, 16 | "1.39": 3.0, 17 | "1.4": 1.0 18 | } 19 | } -------------------------------------------------------------------------------- /tests/worker/processing/test_csmoney_item_processor.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from unittest.mock import AsyncMock 3 | 4 | import pytest 5 | 6 | from price_monitoring.models.csmoney import CsmoneyItem, CsmoneyItemPack, CsmoneyItemCategory 7 | from price_monitoring.worker.processing.csmoney_item_processor import CsmoneyItemProcessor 8 | 9 | _item1 = CsmoneyItem( 10 | name="AK", 11 | price=0.5, 12 | unlock_timestamp=None, 13 | type_=CsmoneyItemCategory.RIFLE, 14 | asset_id="1", 15 | name_id=1, 16 | ) 17 | _item2 = CsmoneyItem( 18 | name="M4A1", 19 | price=0.5, 20 | unlock_timestamp=datetime.datetime(2022, 5, 25, 10, 0, 0), 21 | type_=CsmoneyItemCategory.RIFLE, 22 | asset_id="3", 23 | name_id=2, 24 | ) 25 | 26 | 27 | @pytest.fixture() 28 | def unlocked_item_storage(): 29 | return AsyncMock() 30 | 31 | 32 | @pytest.fixture() 33 | def locked_item_storage(): 34 | return AsyncMock() 35 | 36 | 37 | @pytest.fixture() 38 | def processor(unlocked_item_storage, locked_item_storage): 39 | return CsmoneyItemProcessor( 40 | unlocked_storage=unlocked_item_storage, locked_storage=locked_item_storage 41 | ) 42 | 43 | 44 | _TEST_DATA = [ 45 | (_item1, False), 46 | (_item2, True), 47 | ] 48 | 49 | 50 | @pytest.mark.parametrize("item, is_locked", _TEST_DATA) 51 | async def test_process(processor, unlocked_item_storage, locked_item_storage, item, is_locked): 52 | pack = CsmoneyItemPack(items=[item]) 53 | 54 | await processor.process(pack) 55 | 56 | storage = locked_item_storage if is_locked else unlocked_item_storage 57 | unused_storage = locked_item_storage if not is_locked else unlocked_item_storage 58 | storage.update_item.assert_awaited_once_with(item.name, item.price) 59 | unused_storage.update_item.assert_not_called() 60 | -------------------------------------------------------------------------------- /tests/worker/processing/test_market_name_extractor.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.models.csmoney import CsmoneyItem, CsmoneyItemPack, CsmoneyItemCategory 6 | from price_monitoring.worker.processing.market_name_extractor import MarketNameExtractor 7 | 8 | _item1 = CsmoneyItem( 9 | name="AK", 10 | price=0.5, 11 | unlock_timestamp=None, 12 | type_=CsmoneyItemCategory.RIFLE, 13 | asset_id="1", 14 | name_id=1, 15 | ) 16 | _item2 = CsmoneyItem( 17 | name="AK", 18 | price=0.5, 19 | unlock_timestamp=None, 20 | type_=CsmoneyItemCategory.RIFLE, 21 | asset_id="2", 22 | name_id=1, 23 | ) 24 | _item3 = CsmoneyItem( 25 | name="M4A1", 26 | price=0.5, 27 | unlock_timestamp=None, 28 | type_=CsmoneyItemCategory.RIFLE, 29 | asset_id="3", 30 | name_id=2, 31 | ) 32 | 33 | 34 | @pytest.fixture() 35 | def market_name_queue(): 36 | return AsyncMock() 37 | 38 | 39 | @pytest.fixture() 40 | def processor(market_name_queue): 41 | return MarketNameExtractor(market_name_queue=market_name_queue) 42 | 43 | 44 | _TEST_DATA = [ 45 | ([_item1, _item2, _item3], ("AK", "M4A1")), 46 | ([_item1, _item3], ("AK", "M4A1")), 47 | ([_item1, _item2], ("AK",)), 48 | ([_item3], ("M4A1",)), 49 | ] 50 | 51 | 52 | @pytest.mark.parametrize("items, market_names", _TEST_DATA) 53 | async def test_process(processor, market_name_queue, market_names, items): 54 | pack = CsmoneyItemPack(items=items) 55 | 56 | await processor.process(pack) 57 | 58 | market_name_queue.put.assert_awaited_once() 59 | pack = market_name_queue.put.call_args[0][0] 60 | assert sorted(pack.items) == sorted(market_names) 61 | -------------------------------------------------------------------------------- /tests/worker/processing/test_overpay_extractor.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.features.overpay.worker.overpay_extractor import OverpayExtractor 6 | from price_monitoring.models.csmoney import ( 7 | CsmoneyItem, 8 | CsmoneyItemPack, 9 | CsmoneyItemOverpay, 10 | CsmoneyItemCategory, 11 | ) 12 | 13 | 14 | @pytest.fixture() 15 | def overpay_storage(): 16 | return AsyncMock() 17 | 18 | 19 | @pytest.fixture() 20 | def processor(overpay_storage): 21 | return OverpayExtractor(overpay_storage=overpay_storage) 22 | 23 | 24 | _TEST_DATA = [ 25 | ( 26 | CsmoneyItem( 27 | name="AK", 28 | price=0.5, 29 | asset_id="1", 30 | float_="0.0123", 31 | type_=CsmoneyItemCategory.RIFLE, 32 | overpay_float=0.1, 33 | name_id=1, 34 | ), 35 | CsmoneyItemOverpay(market_name="AK", float_="0.0123", name_id=1, overpay=0.1), 36 | ), 37 | ( 38 | CsmoneyItem( 39 | name="AK", 40 | price=0.5, 41 | asset_id="1", 42 | float_="0.0123", 43 | type_=CsmoneyItemCategory.RIFLE, 44 | overpay_float=None, 45 | name_id=1, 46 | ), 47 | None, 48 | ), 49 | ] 50 | 51 | 52 | @pytest.mark.parametrize("item, item_overpay", _TEST_DATA) 53 | async def test_process(processor, overpay_storage, item, item_overpay): 54 | pack = CsmoneyItemPack(items=[item]) 55 | 56 | await processor.process(pack) 57 | 58 | if item_overpay: 59 | overpay_storage.add_overpay.assert_awaited_with(item_overpay) 60 | else: 61 | overpay_storage.add_overpay.assert_not_called() 62 | -------------------------------------------------------------------------------- /tests/worker/processing/test_steam_sell_history_processor.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, patch, Mock 2 | 3 | import pytest 4 | 5 | from price_monitoring.models.steam import SteamSellHistory, SkinSellHistory 6 | from price_monitoring.worker.processing.steam_sell_history_processor import ( 7 | SteamSellHistoryProcessor, 8 | ) 9 | 10 | 11 | @pytest.fixture() 12 | def steam_order_storage(): 13 | return AsyncMock() 14 | 15 | 16 | @pytest.fixture() 17 | def processor(steam_order_storage): 18 | return SteamSellHistoryProcessor(steam_order_storage) 19 | 20 | 21 | async def test_process(processor, steam_order_storage): 22 | history = SteamSellHistory( 23 | market_name="AK", encoded_data='[["Mar 18 2016 01: +0",6.211,"120"]]' 24 | ) 25 | summary = {1.01: 60, 1.02: 50} 26 | 27 | with patch( 28 | "price_monitoring.worker.processing.steam_sell_history_processor.SellHistoryAnalyzer" 29 | ) as stub: 30 | mock = Mock() 31 | mock.get_sold_amount_for_week.return_value = 125 32 | mock.is_stable.return_value = True 33 | mock.analyze_history.return_value = summary 34 | stub.return_value = mock 35 | await processor.process(history) 36 | 37 | steam_order_storage.update_skin.assert_called_with( 38 | SkinSellHistory( 39 | market_name=history.market_name, 40 | is_stable=True, 41 | sold_per_week=125, 42 | summary=summary, 43 | ) 44 | ) 45 | -------------------------------------------------------------------------------- /tests/worker/test_worker.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | 5 | from price_monitoring.worker.worker import Worker, WorkerThread 6 | 7 | 8 | @pytest.fixture() 9 | def processor(): 10 | return AsyncMock() 11 | 12 | 13 | @pytest.fixture() 14 | def reader(): 15 | return AsyncMock() 16 | 17 | 18 | @pytest.fixture() 19 | def worker(disable_asyncio_sleep, processor, reader): 20 | return Worker([WorkerThread(reader=reader, delay_duration=0.1, processors=[processor])]) 21 | 22 | 23 | @pytest.mark.parametrize("item", [None, 1]) 24 | async def test_run_steam_skin_processor(worker, reader, processor, item): 25 | reader.get.return_value = item 26 | 27 | await worker.run() 28 | 29 | if item: 30 | processor.process.assert_awaited_with(item) 31 | else: 32 | processor.process.assert_not_called() 33 | 34 | 35 | async def test_run__do_not_crash(worker, reader): 36 | reader.get.side_effect = Exception() 37 | 38 | await worker.run() 39 | 40 | 41 | if __name__ == "__main__": 42 | pytest.main() 43 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soniclev/steam_csmoney/1f8091db6fb5cfc7a56beb47516c10802954f12e/utils/__init__.py -------------------------------------------------------------------------------- /utils/create_csmoney_tasks.env: -------------------------------------------------------------------------------- 1 | REDIS_HOST=localhost 2 | REDIS_PORT=6379 3 | REDIS_DB=1 4 | REDIS_PASSWORD=25a7edbfdd5bc5d7f8ce -------------------------------------------------------------------------------- /utils/create_csmoney_tasks.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from common.env_var import EnvVar 4 | from common.redis_connector import RedisConnector 5 | from price_monitoring.models.csmoney import CsmoneyTask 6 | from price_monitoring.parsers.csmoney.task_scheduler import RedisTaskScheduler 7 | 8 | 9 | def generate_tasks() -> list[CsmoneyTask]: 10 | fmt = ( 11 | "https://inventories.cs.money/5.0/load_bots_inventory/730?hasTradeLock=false" 12 | "&hasTradeLock=true&isMarket=false&limit=60&maxPrice={max_price}&minPrice={min_price}" 13 | "&withStack=true" 14 | ) 15 | 16 | result = [] 17 | value = 0.2 18 | step = 0.1 19 | 20 | while value < 500: 21 | new_value = round(value + step, 2) 22 | step = value 23 | url = fmt.format(min_price=value, max_price=new_value) 24 | result.append(CsmoneyTask(url=url)) 25 | value = new_value 26 | return result 27 | 28 | 29 | async def main(): 30 | redis = RedisConnector.create( 31 | host=EnvVar.get("REDIS_HOST"), 32 | port=EnvVar.get("REDIS_PORT"), 33 | db=EnvVar.get("REDIS_DB"), 34 | password=EnvVar.get("REDIS_PASSWORD"), 35 | ) 36 | scheduler = RedisTaskScheduler(redis) 37 | await scheduler.clear() 38 | tasks = generate_tasks() 39 | print(f"Generated {len(tasks)} tasks.") 40 | for task in tasks: 41 | await scheduler.append_task(task) 42 | 43 | 44 | if __name__ == "__main__": 45 | asyncio.run(main()) 46 | -------------------------------------------------------------------------------- /utils/upload_proxies.env: -------------------------------------------------------------------------------- 1 | REDIS_HOST=localhost 2 | REDIS_PORT=6379 3 | REDIS_DB=1 4 | REDIS_PASSWORD=25a7edbfdd5bc5d7f8ce -------------------------------------------------------------------------------- /utils/upload_proxies.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from common.env_var import EnvVar 4 | from common.redis_connector import RedisConnector 5 | from proxy_http.proxy import Proxy 6 | from price_monitoring.storage.proxy import RedisProxyStorage 7 | 8 | _STEAM_PROXIES = "utils_mount/steam_proxies.txt" 9 | _CSMONEY_PROXIES = "utils_mount/csmoney_proxies.txt" 10 | _STEAM_PROXIES_KEY = "steam_proxies" 11 | _CSMONEY_PROXIES_KEY = "csmoney_proxies" 12 | 13 | 14 | async def fill_proxies(redis, file, key): 15 | storage = RedisProxyStorage(redis, key) 16 | proxies = await storage.get_all() 17 | for proxy in proxies: 18 | await storage.remove(proxy) 19 | with open(file, "r", encoding="utf8") as f: 20 | while f.readable(): 21 | line = f.readline().strip() 22 | if not line: 23 | break 24 | proxy = Proxy(proxy=line) 25 | await storage.add(proxy) 26 | print(f"Successfully filled {len(proxies)} proxies") 27 | 28 | 29 | async def main(): 30 | redis = RedisConnector.create( 31 | host=EnvVar.get("REDIS_HOST"), 32 | port=EnvVar.get("REDIS_PORT"), 33 | db=EnvVar.get("REDIS_DB"), 34 | password=EnvVar.get("REDIS_PASSWORD"), 35 | ) 36 | for file, key in zip( 37 | [_STEAM_PROXIES, _CSMONEY_PROXIES], [_STEAM_PROXIES_KEY, _CSMONEY_PROXIES_KEY] 38 | ): 39 | await fill_proxies(redis, file, key) 40 | 41 | 42 | if __name__ == "__main__": 43 | asyncio.run(main()) 44 | -------------------------------------------------------------------------------- /worker.dev.env: -------------------------------------------------------------------------------- 1 | REDIS_HOST=localhost 2 | REDIS_PORT=6379 3 | REDIS_DB=0 4 | REDIS_PASSWORD=25a7edbfdd5bc5d7f8ce 5 | RABBITMQ_HOST=localhost 6 | RABBITMQ_PORT=5672 7 | RABBITMQ_LOGIN=b89064ff8c4016862f05 8 | RABBITMQ_PASSWORD=1933952ebd3fca7d973e 9 | WORKERS_COUNT=10 --------------------------------------------------------------------------------