.
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Instascope
4 |
5 |
6 | []("Python")
7 | []("OpenSource")
8 | []("MadeWithLove")
9 |
10 |
11 |
12 | ## Install
13 | - Download [python 3.7 or high](https://python.org/download)
14 | - Clone this repo: `git clone https://github.com/Ythosa/instascope`
15 | - Create virtual environment: `python -m venv env`
16 | - Activate environment: `. ./env/bin/activate`
17 | - Install dependencies: `pip3 install -r requirements.txt`
18 | - Start bot: `python3 ./bot.py`
19 |
20 |
21 | ## Description
22 | - Telegram bot-creator pictures with horoscope text for you symbol and
23 | funny picture (meme)
24 | - You can install and run bot and write `/help` command in chat to learn more
25 |
26 |
27 |
28 |
29 | Copyright 2020 Ythosa / Ethosa
30 |
31 |
--------------------------------------------------------------------------------
/bot.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from aiogram import Bot, Dispatcher, types, executor, filters
4 |
5 | from config import TELEGRAM_TOKEN
6 | from data import DataWorker
7 | from horoscope_generator import HoroscopeGenerator
8 | from models import HoroscopeList
9 |
10 | # Configure logging
11 | logging.basicConfig(level=logging.INFO)
12 |
13 | # Initialize bot and dispatcher
14 | bot = Bot(token=TELEGRAM_TOKEN)
15 | dp = Dispatcher(bot)
16 |
17 | data_worker = DataWorker() # Init data worker
18 | horoscope_list = HoroscopeList() # Init horoscope_list
19 |
20 | # Init horoscope image creator
21 | horoscope_image_creator = HoroscopeGenerator(horoscope_list, data_worker)
22 |
23 |
24 | @dp.message_handler(commands=['start', 'help'])
25 | async def send_welcome(message: types.Message):
26 | """
27 | This handler will be called when user sends `/start` or `/help` command
28 | :param message:
29 | :return:
30 | """
31 | await message.reply("Instascope is bot - generator of horoscopes!\n\n"
32 | "You can:\n"
33 | "\t\t- write /help to get some information;\n"
34 | "\t\t- write /horoscope to get horoscope for any sign;\n"
35 | "\t\t- write /horoscope_{some_sign} - to get horoscope for this sign;\n"
36 | "\t\t- write /signs to get list of all signs.\n\n"
37 | "Developer: Ythosa [ythosa.github.io]")
38 |
39 |
40 | @dp.message_handler(commands=['signs'])
41 | async def send_available_sings(message: types.Message):
42 | """
43 | This handler will be called when user sends `/sings` command
44 | :param message:
45 | :return:
46 | """
47 |
48 | await message.reply(str(horoscope_list))
49 |
50 |
51 | @dp.message_handler(filters.RegexpCommandsFilter(regexp_commands=['horoscope_([a-z]+)']))
52 | async def send_horoscope(message: types.Message, regexp_command):
53 | """
54 | This handler will be called when user sends `/horoscope {sign}` command
55 | :param message:
56 | :param regexp_command:
57 | :return:
58 | """
59 | sign = regexp_command.group(1)
60 | if not horoscope_list.is_contains(sign):
61 | await message.reply('Invalid sign')
62 | return
63 |
64 | picture_path = f"./instascope/.results/_{sign}.png"
65 | horoscope_image_creator.create(picture_path, sign)
66 |
67 | await message.reply_document(open(picture_path, 'rb'))
68 |
69 |
70 | @dp.callback_query_handler(text='horoscope_taurus')
71 | @dp.callback_query_handler(text='horoscope_aries')
72 | @dp.callback_query_handler(text='horoscope_gemini')
73 | @dp.callback_query_handler(text='horoscope_cancer')
74 | @dp.callback_query_handler(text='horoscope_leo')
75 | @dp.callback_query_handler(text='horoscope_libra')
76 | @dp.callback_query_handler(text='horoscope_sagittarius')
77 | @dp.callback_query_handler(text='horoscope_capricorn')
78 | @dp.callback_query_handler(text='horoscope_aquarius')
79 | @dp.callback_query_handler(text='horoscope_pisces')
80 | @dp.callback_query_handler(text='horoscope_virgo')
81 | @dp.callback_query_handler(text='horoscope_scorpio')
82 | async def inline_kb_answer_callback_handler(query: types.CallbackQuery):
83 | """
84 | This handler will be called when user presses some button from `/horoscope` command
85 | :param query:
86 | :return:
87 | """
88 | sign = query.data.split('_')[1]
89 | if not horoscope_list.is_contains(sign):
90 | await query.answer('Invalid sign')
91 | return
92 |
93 | picture_path = f"./.results/_{sign}.png"
94 | horoscope_image_creator.create(picture_path, sign)
95 |
96 | await bot.send_document(query.from_user.id, open(picture_path, 'rb'))
97 |
98 |
99 | @dp.message_handler(commands='horoscope')
100 | async def start_cmd_handler(message: types.Message):
101 | keyboard_markup = types.InlineKeyboardMarkup(row_width=3)
102 |
103 | text_and_data = [(s.emoji, f'horoscope_{s.en_translate}') for s in horoscope_list.get_horoscope_signs()]
104 |
105 | for i in range(0, len(text_and_data) // 4 + 1):
106 | row_btns = []
107 | for j in range(i * 3, i * 3 + 3):
108 | text, data = text_and_data[j]
109 | row_btns.append(types.InlineKeyboardButton(text, callback_data=data))
110 | keyboard_markup.row(*row_btns)
111 |
112 | await message.reply("Choose horoscope sign!", reply_markup=keyboard_markup)
113 |
114 |
115 | if __name__ == "__main__":
116 | executor.start_polling(dp, skip_updates=True)
117 |
--------------------------------------------------------------------------------
/config/__init__.py:
--------------------------------------------------------------------------------
1 | from .config import REDIS_PORT, REDIS_HOST, HOROSCOPE_GENERATOR_URL, TELEGRAM_TOKEN, VK_TOKEN, FONT_PATH, \
2 | get_horoscope_signs, get_public_pages
3 |
--------------------------------------------------------------------------------
/config/config.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | import os
4 | import yaml
5 |
6 | from models import HoroscopeSign, PublicPage
7 |
8 | VK_TOKEN = os.getenv("VK_TOKEN")
9 | TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN")
10 |
11 | REDIS_HOST = os.getenv("REDIS_HOST")
12 | REDIS_PORT = os.getenv("REDIS_PORT")
13 |
14 | HOROSCOPE_GENERATOR_URL = "https://1001goroskop.ru/?znak="
15 | FONT_PATH = "./fonts/DroidSans.ttf"
16 | CONFIG_FILE_PATH = "./config/config.yaml"
17 |
18 |
19 | def _parse_yaml() -> dict:
20 | with open(CONFIG_FILE_PATH) as f:
21 | data = yaml.load(f, Loader=yaml.FullLoader)
22 | return data
23 |
24 |
25 | def get_horoscope_signs() -> List[HoroscopeSign]:
26 | horoscope_signs: List[HoroscopeSign] = []
27 | for ru, bio in dict(CONFIG['signs']).items():
28 | bio = dict(bio)
29 | en = bio['named']
30 | emoji = bio['emoji']
31 | horoscope_signs.append(HoroscopeSign(ru, en, emoji))
32 |
33 | return horoscope_signs
34 |
35 |
36 | def get_public_pages() -> List[PublicPage]:
37 | public_pages: List[PublicPage] = []
38 | for name, bio in dict(CONFIG['public_pages']).items():
39 | public_pages.append(
40 | PublicPage(name=name, owner_id=int(bio['owner_id']), album_id=bio['album_id']))
41 |
42 | return public_pages
43 |
44 |
45 | CONFIG = _parse_yaml()
46 |
--------------------------------------------------------------------------------
/config/config.yaml:
--------------------------------------------------------------------------------
1 | signs:
2 | телец:
3 | named: taurus
4 | emoji: ♉
5 | овен:
6 | named: aries
7 | emoji: ♈
8 | близнецы:
9 | named: gemini
10 | emoji: ♊
11 | рак:
12 | named: cancer
13 | emoji: ♋
14 | лев:
15 | named: leo
16 | emoji: ♌
17 | весы:
18 | named: libra
19 | emoji: ♎
20 | стрелец:
21 | named: sagittarius
22 | emoji: ♐
23 | козерог:
24 | named: capricorn
25 | emoji: ♑
26 | водолей:
27 | named: aquarius
28 | emoji: ♒
29 | рыбы:
30 | named: pisces
31 | emoji: ♓
32 | дева:
33 | named: virgo
34 | emoji: ♍
35 | скорпион:
36 | named: scorpio
37 | emoji: ♏
38 |
39 | public_pages:
40 | 4ch:
41 | owner_id: -45745333
42 | album_id: 262436923
43 | this_is_not_nerves:
44 | owner_id: -176864224
45 | album_id: wall
46 | eblan_kingdom:
47 | owner_id: -29606875
48 | album_id: wall
49 | postironia:
50 | owner_id: -144918406
51 | album_id: wall
52 |
--------------------------------------------------------------------------------
/data/__init__.py:
--------------------------------------------------------------------------------
1 | from .data_worker import DataWorker
2 |
--------------------------------------------------------------------------------
/data/data_worker.py:
--------------------------------------------------------------------------------
1 | import json
2 | from datetime import datetime
3 | from typing import Optional
4 |
5 | import redis
6 |
7 | from config import REDIS_HOST, REDIS_PORT
8 |
9 |
10 | class DataWorker:
11 | def __init__(self):
12 | self._db = redis.Redis(host=REDIS_HOST, port=REDIS_PORT)
13 |
14 | def update_sign_horoscope(self, sign, horoscope):
15 | self._db.set(sign, json.dumps({
16 | 'horoscope': horoscope,
17 | 'update_day': datetime.now().day
18 | }))
19 |
20 | def get_horoscope_for_sign(self, sign) -> Optional[str]:
21 | v = self._db.get(sign)
22 | if v is None:
23 | return None
24 |
25 | v = bytearray(v).decode("utf-8")
26 | v = dict(json.loads(v))
27 | if v['update_day'] != datetime.now().day:
28 | return None
29 |
30 | return v['horoscope']
31 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | instascope_bot:
5 | build: ./
6 | container_name: instascope_bot
7 | command: python3 ./bot.py
8 | restart: unless-stopped
9 | environment:
10 | - TELEGRAM_TOKEN=
11 | - VK_TOKEN=
12 | - REDIS_HOST=instascope_db
13 | - REDIS_PORT=6379
14 | links:
15 | - instascope_db
16 | depends_on:
17 | - instascope_db
18 | networks:
19 | - instascope
20 |
21 | instascope_db:
22 | container_name: instascope_db
23 | image: redis:6.0.9-alpine
24 | networks:
25 | - instascope
26 | volumes:
27 | - instascope_db:/data
28 |
29 | volumes:
30 | instascope_db:
31 |
32 | networks:
33 | instascope:
34 | driver: bridge
35 |
--------------------------------------------------------------------------------
/fonts/DroidSans.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ythosa/instascope/a8271446c08a4bec6f6e78f5385ce803193aa204/fonts/DroidSans.ttf
--------------------------------------------------------------------------------
/horoscope_generator/__init__.py:
--------------------------------------------------------------------------------
1 | from .horoscope_generator import HoroscopeGenerator
2 |
--------------------------------------------------------------------------------
/horoscope_generator/horoscope_generator.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | from random import randint, choice
4 | from textwrap import wrap
5 | from urllib.request import urlopen
6 |
7 | import requests
8 | from PIL import Image, ImageDraw, ImageFont
9 | from bs4 import BeautifulSoup
10 | from saya import Vk
11 |
12 | from config import FONT_PATH, HOROSCOPE_GENERATOR_URL, VK_TOKEN, get_public_pages
13 | from data import DataWorker
14 | from models import Horoscope, HoroscopeList
15 |
16 | TITLE_FONT = ImageFont.truetype(FONT_PATH, 128)
17 | FONT = ImageFont.truetype(FONT_PATH, 32 + 16)
18 |
19 |
20 | class HoroscopeGenerator:
21 | def __init__(self, horoscope_list: HoroscopeList, data_worker: DataWorker):
22 | self.horoscope_list = horoscope_list
23 | self._data_worker = data_worker
24 | self.public_pages = get_public_pages()
25 |
26 | def create(self, horoscope_picture_path: str = "pic.png", sign: str = "libra"):
27 | """
28 | Creates horoscope picture with title (sign) horoscope for this sign and meme
29 | :param horoscope_picture_path:
30 | :param sign:
31 | :return:
32 | """
33 | # Parse content
34 | (title, description) = self._get_horoscope(sign, HOROSCOPE_GENERATOR_URL)
35 |
36 | meme_path = 'meme.png'
37 | self._get_meme(meme_path)
38 |
39 | # params
40 | width, height = 1080, 1920
41 | meme_size = 900
42 | formatted_description = "\n".join(wrap(description, 41))
43 | start_height = 180
44 |
45 | back = Image.new("RGBA", (width, height), color="#282a36")
46 | meme = Image.open(meme_path).resize((meme_size, meme_size))
47 | draw = ImageDraw.Draw(back)
48 |
49 | # title
50 | w, h = draw.textsize(title, font=TITLE_FONT)
51 | draw.text(
52 | (width // 2 - w // 2, start_height),
53 | title, font=TITLE_FONT, fill="#f8f8f2")
54 |
55 | # description
56 | w1, h1 = draw.multiline_textsize(formatted_description, font=FONT)
57 | draw.multiline_text(
58 | (width // 2 - w1 // 2, start_height + h + (470 - h1) // 2),
59 | formatted_description, font=FONT, fill="#f8f8f2", align="center")
60 | back.paste(meme, (90, 930))
61 |
62 | back.save(horoscope_picture_path)
63 | if horoscope_picture_path != meme_path:
64 | os.remove(meme_path)
65 |
66 | def _get_meme(self, meme_path: str):
67 | """
68 | Gets random picture from random public page and writes it in the file.
69 | :param meme_path:
70 | :return:
71 | """
72 | choiced_public = choice(self.public_pages)
73 | vk = Vk(token=VK_TOKEN)
74 | photos = vk.photos.get( # Gets all photos from album
75 | owner_id=choiced_public.owner_id, album_id=choiced_public.album_id,
76 | rev=randint(0, 1), offset=randint(0, 500), count=1000)
77 | photo = choice(photos["response"]["items"]) # Gets random photo from photos list.
78 | w = h = 0
79 | url = ""
80 | for size in photo["sizes"]: # Gets max photo size.
81 | if size["width"] > w and size["height"] > h:
82 | w = size["width"]
83 | h = size["height"]
84 | url = size["url"]
85 | if url:
86 | # Write photo in file, if available.
87 | content = None
88 | while not content:
89 | try:
90 | content = requests.get(url).content
91 | except requests.exceptions.ConnectionError:
92 | continue
93 | with open(meme_path, "wb") as f:
94 | f.write(content)
95 |
96 | def _get_horoscope(self, sign: str, url: str) -> Horoscope:
97 | """
98 | Returns horoscope for passed sign
99 | :param sign:
100 | :param url:
101 | :return:
102 | """
103 | if not self.horoscope_list.is_contains(sign):
104 | raise ValueError("passed symbol must be one of horoscope symbols")
105 |
106 | sign = self.horoscope_list.get_en_translate_of_sign(sign)
107 |
108 | horoscope = self._data_worker.get_horoscope_for_sign(sign)
109 | if horoscope is not None:
110 | sign = self.horoscope_list.get_ru_translate_of_sign(sign)
111 | return Horoscope(str(sign).capitalize(), horoscope)
112 |
113 | html_doc = urlopen(url + sign).read()
114 | soup = BeautifulSoup(html_doc, features="html.parser")
115 | soup = str(soup.find('p'))[3:-4]
116 |
117 | horoscope = "".join(re.split(r"([!?.]+)", soup, 3)[:4])
118 | self._data_worker.update_sign_horoscope(sign, horoscope)
119 | sign = self.horoscope_list.get_ru_translate_of_sign(sign)
120 |
121 | return Horoscope(str(sign).capitalize(), horoscope)
122 |
--------------------------------------------------------------------------------
/models/__init__.py:
--------------------------------------------------------------------------------
1 | from .horoscope import Horoscope
2 | from .horoscope_sign import HoroscopeSign
3 | from .horoscope_list import HoroscopeList
4 | from .public_page import PublicPage
5 |
--------------------------------------------------------------------------------
/models/horoscope.py:
--------------------------------------------------------------------------------
1 | from typing import NamedTuple
2 |
3 |
4 | class Horoscope(NamedTuple):
5 | title: str
6 | description: str
7 |
--------------------------------------------------------------------------------
/models/horoscope_list.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from config import config
4 | from models import HoroscopeSign
5 |
6 |
7 | class HoroscopeList:
8 | _sign_list: List[HoroscopeSign] = []
9 |
10 | def __init__(self):
11 | self._sign_list = config.get_horoscope_signs()
12 |
13 | def __str__(self):
14 | return "\n".join([str(h) for h in self._sign_list])
15 |
16 | def is_contains(self, sign_name: str) -> bool:
17 | """
18 | Returns true is sign with name equals sign_name contains in self._sign_list
19 | :param sign_name: name of finding sign
20 | :return: is sign contains
21 | """
22 | for i in self._sign_list:
23 | if i.en_translate == sign_name or i.ru_translate == sign_name:
24 | return True
25 |
26 | return False
27 |
28 | def get_horoscope_signs(self) -> List[HoroscopeSign]:
29 | return self._sign_list
30 |
31 | def get_ru_translate_of_sign(self, sign_name: str) -> str:
32 | """
33 | Returns russian translate of passed sign_name
34 | :param sign_name:
35 | :return:
36 | """
37 | for s in self._sign_list:
38 | if sign_name == s:
39 | return s.ru_translate
40 |
41 | def get_en_translate_of_sign(self, sign_name: str) -> str:
42 | """
43 | Returns english translate of passed sign_name
44 | :param sign_name:
45 | :return:
46 | """
47 | for s in self._sign_list:
48 | if sign_name == s:
49 | return s.en_translate
50 |
51 | def _get_sign_index(self, sign_name: str) -> int:
52 | """
53 | Returns index of passed sign_name in _sign_list
54 | :param sign_name:
55 | :return:
56 | """
57 | index = 0
58 | for s in self._sign_list:
59 | if s == sign_name:
60 | break
61 | index += 1
62 | return index
63 |
64 | def get_ru_translate_signs(self) -> List[str]:
65 | """
66 | Returns all signs translated to russian
67 | :return:
68 | """
69 | return [s.ru_translate for s in self._sign_list]
70 |
71 | def get_en_translate_signs(self) -> List[str]:
72 | """
73 | Returns all signs translated to english
74 | :return:
75 | """
76 | return [s.en_translate for s in self._sign_list]
77 |
--------------------------------------------------------------------------------
/models/horoscope_sign.py:
--------------------------------------------------------------------------------
1 | from models import Horoscope
2 |
3 |
4 | class HoroscopeSign:
5 | horoscope: Horoscope
6 |
7 | def __init__(self, ru_translate, en_translate, emoji):
8 | self.ru_translate = ru_translate
9 | self.en_translate = en_translate
10 | self.emoji = emoji
11 |
12 | def __str__(self):
13 | return f"{self.emoji} - {self.ru_translate} - {self.en_translate}"
14 |
15 | def __eq__(self, sign: str) -> bool:
16 | return self.ru_translate == sign or self.en_translate == sign
17 |
--------------------------------------------------------------------------------
/models/public_page.py:
--------------------------------------------------------------------------------
1 | from typing import NamedTuple
2 |
3 |
4 | class PublicPage(NamedTuple):
5 | name: str
6 | owner_id: int
7 | album_id: str
8 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | requests~=2.21.0
2 | retranslator~=0.1.2
3 | regex~=2020.10.23
4 | beautifulsoup4~=4.9.3
5 | Pillow>=7.1.0
6 | saya~=0.3.2
7 | PyYAML~=5.3.1
8 | aiogram~=2.10.1
9 | redis~=3.5.3
10 |
--------------------------------------------------------------------------------