Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
* Stats per user (message counts; word counts including _nouns, verbs and adjectives_)
* Stats per channel (message counts; chatty users)
* Stats per server (message counts; chatty users)

### Extra Fun Features!

* [CapyCoin](https://github.com/lmartinking/capycoin) integration (optional!)
* Mars Weather
* Fortunes!
* Tarot readings
* Daily horoscope for Western & Chinese signs

## Screenshots

Expand Down
18 changes: 18 additions & 0 deletions duckbot/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from . capycoin import action_signup, action_funds, action_send
from . weather import latest_mars_weather
from . tarot import draw_cards as draw_tarot_cards
from . horoscope import get_horoscope


log = logging.getLogger('cmd')
Expand Down Expand Up @@ -189,6 +190,7 @@ async def help_command(ctx: commands.Context):
• `fortune` - show a random fortune!
• `weather` - show the latest Mars weather report
• `tarot` - draw some tarot cards
• `horoscope <sign>` - show the daily western or chinese horoscope for a particular sign (e.g. `horoscope aries` or `horoscope dragon`)
""")
if CAPYCOIN_HOST:
message += textwrap.dedent("""
Expand Down Expand Up @@ -248,6 +250,21 @@ async def tarot_command(ctx: commands.Context):
await asyncio.sleep(1) # Add a small delay between card reveals for effect


async def horoscope_command(ctx: commands.Context):
channel: TextChannel = ctx.channel

if not ctx.args:
await channel.send(f"The `horoscope` command takes 1 parameter: the sign (e.g. `horoscope aries`)")
return

sign = ctx.args[0]
message = await get_horoscope(sign)
if message:
await channel.send(message)
else:
await channel.send(f"Sorry, I couldn't fetch the horoscope for `{sign}`. Make sure you entered a valid sign (e.g. `aries`, `rat`, `dragon`, etc.)")


async def process_message(guild: Guild, channel: TextChannel, user: User, message: Message, bot: commands.Bot):
cmd_toks = [t for t in message.content.split()]

Expand All @@ -268,6 +285,7 @@ async def process_message(guild: Guild, channel: TextChannel, user: User, messag
'coin': capycoin_command,
'weather': weather_command,
'tarot': tarot_command,
'horoscope': horoscope_command,
}

cmd = cmd_map.get(cmd, unhandled_command)
Expand Down
104 changes: 104 additions & 0 deletions duckbot/horoscope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from typing import Optional, Callable

import aiohttp
import logging
from async_lru import alru_cache

from parsel import Selector


log = logging.getLogger("bot")


SCOPES_WESTERN = ("aries", "taurus", "gemini", "cancer", "leo", "virgo", "libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces")
SCOPES_WESTERN_ICONS = ("♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓")

SCOPES_CHINESE = ("rat", "ox", "tiger", "rabbit", "dragon", "snake", "horse", "goat", "monkey", "rooster", "dog", "pig")
SCOPES_CHINESE_ICONS = ("🐀", "🐂", "🐅", "🐇", "🐉", "🐍", "🐎", "🐐", "🐒", "🐓", "🐕", "🐖")


def icon_for_sign(sign: str) -> Optional[str]:
if sign.lower() in SCOPES_WESTERN:
return SCOPES_WESTERN_ICONS[SCOPES_WESTERN.index(sign.lower())]
if sign.lower() in SCOPES_CHINESE:
return SCOPES_CHINESE_ICONS[SCOPES_CHINESE.index(sign.lower())]
return None


def url_for_sign(sign: str, tomorrow: bool = False) -> str:
if sign in SCOPES_WESTERN:
day = "daily/tomorrow" if tomorrow else "daily"
return f"https://www.astrology.com/horoscope/{day}/{sign.lower()}.html"
if sign in SCOPES_CHINESE:
# These are not in "calendar" order, so we need to map them to the correct index
idx_map = {
"ox": 1,
"goat": 2,
"rat": 3,
"snake": 4,
"dragon": 5,
"tiger": 6,
"rabbit": 7,
"horse": 8,
"monkey": 9,
"rooster": 10,
"dog": 11,
"pig": 12,
}
idx = idx_map[sign.lower()]
day = "tomorrow" if tomorrow else "today"
return f"https://www.horoscope.com/us/horoscopes/chinese/horoscope-chinese-daily-{day}.aspx?sign={idx}"


def parser_for_sign(sign: str) -> Optional[Callable]:
if sign in SCOPES_WESTERN:
return parse_astrology_com_scope_page
if sign in SCOPES_CHINESE:
return parse_horoscope_com_scope_page
return None


async def fetch_html(url: str) -> str:
async with aiohttp.ClientSession() as session:
async with session.get(url) as r:
return await r.text()


def parse_astrology_com_scope_page(html: str) -> Optional[str]:
sel = Selector(text=html)
if p := sel.css("div#content p").get():
date = sel.css("#content-date ::text").get().strip()
date = f"{date}: " if date else ""
inner = Selector(text=p)
inner_text = " ".join(inner.css("::text").getall()).strip()
return f"{date}{inner_text}"


def parse_horoscope_com_scope_page(html: str) -> Optional[str]:
sel = Selector(text=html)
if p := sel.css("div.main-horoscope p").get():
inner = Selector(text=p)
return " ".join(inner.css("::text").getall()).strip()


async def fetch_and_parse_horoscope(sign: str, tomorrow: bool = False) -> Optional[str]:
url = url_for_sign(sign, tomorrow=tomorrow)
html = await fetch_html(url)
parser = parser_for_sign(sign)
return parser(html) if parser else None


@alru_cache(ttl=3600)
async def get_horoscope(sign: str) -> Optional[str]:
if sign.lower() not in (SCOPES_WESTERN + SCOPES_CHINESE):
return None

today_text = await fetch_and_parse_horoscope(sign, tomorrow=False)
tomorrow_text = await fetch_and_parse_horoscope(sign, tomorrow=True)

if not any((today_text, tomorrow_text)):
return None

icon = icon_for_sign(sign) or ""
text = f"{icon} **{sign.capitalize()}**: {today_text}\n\n{tomorrow_text}"
return text
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ dependencies = [
"sparklines>=0.4.2,<0.5",
"async-lru>=2.0.4,<3",
"numpy<2", # Limited by spacy compatability
"kola>=2.2.0"
"kola>=2.2.0",
"parsel>=1.11.0",
]

[dependency-groups]
Expand Down
29 changes: 29 additions & 0 deletions tests/test_horoscope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import pytest

from duckbot.horoscope import get_horoscope, icon_for_sign, fetch_and_parse_horoscope, SCOPES_WESTERN, SCOPES_CHINESE


@pytest.mark.asyncio
async def test_fetch_and_parse_horoscope(subtests):
for sign in SCOPES_WESTERN + SCOPES_CHINESE:
today_text = None
for tomorrow in (False, True):
with subtests.test(sign=sign, tomorrow=tomorrow):
horoscope = await fetch_and_parse_horoscope(sign, tomorrow=tomorrow)
assert horoscope is not None, f"Horoscope for {sign} should not be None"

if not tomorrow:
today_text = horoscope
else:
assert horoscope != today_text, f"Tomorrow's horoscope for {sign} should be different from today's"


@pytest.mark.asyncio
async def test_get_horoscope(subtests):
for sign in SCOPES_WESTERN + SCOPES_CHINESE:
with subtests.test(sign=sign):
icon = icon_for_sign(sign)
horoscope = await get_horoscope(sign)
assert horoscope is not None, f"Horoscope for {sign} should not be None"
assert sign.lower() in horoscope.lower(), f"Horoscope text for {sign} should contain the sign name"
assert icon in horoscope, f"Horoscope text for {sign} should contain the icon {icon}"
71 changes: 71 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading