feat(daily_report): 新增日报模块及相关资源文件

- 新增日报模块,包括HTML、CSS、字体、图标等资源文件
- 添加以图搜图功能模块
- 修改图片插件以支持日报图片生成
- 更新requirements.txt依赖
- 新增日期处理、配置、数据源等辅助模块
This commit is contained in:
SlyAimer 2025-02-14 10:40:22 +08:00
parent 107567f71b
commit 3519138e9c
21 changed files with 860 additions and 1 deletions

View file

@ -17,6 +17,11 @@ pycryptodome
PyExecJS PyExecJS
gradio_client gradio_client
tortoise-orm tortoise-orm
urllib3==1.26.5
zhdate
chinese_calendar
lunardate
playwright
requests requests
pillow pillow

BIN
src/clover_image/s.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -0,0 +1,79 @@
import requests
def saucenao_search(image_path=None, image_url=None, api_key='your_api_key', numres=5):
"""
使用 SauceNAO API 进行以图搜图
:param image_path: 本地图片的文件路径若提供图片 URL 则此参数可省略
:param image_url: 图片的网络 URL若提供本地图片路径则此参数可省略
:param api_key: SauceNAO API Key
:param numres: 要返回的搜索结果数量
:return: 搜索结果的 JSON 数据
"""
base_url = 'https://saucenao.com/search.php'
params = {
'output_type': 2, # 输出类型为 JSON
'numres': numres, # 返回的结果数量
'api_key': api_key
}
if image_url:
params['url'] = image_url
elif image_path:
files = {'file': open(image_path, 'rb')}
response = requests.post(base_url, params=params, files=files)
else:
raise ValueError("url错误")
if image_url:
response = requests.get(base_url, params=params)
if response.status_code == 200:
return response.json()
else:
print(f"请求失败,状态码: {response.status_code}")
return None
def parse_saucenao_result(result):
"""
解析 SauceNAO 的搜索结果
"""
if not result or 'results' not in result:
print("未找到有效的搜索结果。")
return
print(result)
for index, item in enumerate(result['results'], start=1):
header = item['header']
data = item['data']
similarity = header.get('similarity', '未知')
thumbnail = header.get('thumbnail', '未知')
title = data.get('title', '未知')
author = data.get('member_name', data.get('author_name', '未知'))
ext_urls = data.get('ext_urls', [])
pixiv_id = data.get('pixiv_id', '未知')
print(f"结果 {index}:")
print(f" 相似度: {similarity}%")
print(f"预览图{thumbnail} ")
print(f" 标题: {title}")
print(f" 作者: {author}")
if ext_urls:
print(f" 来源链接: {ext_urls[0]}")
else:
print(" 来源链接: 未知")
print(f"pixiv_id:{pixiv_id}")
print()
if __name__ == "__main__":
# 若使用本地图片,提供图片路径
image_path = 's.png'
# 若使用网络图片,提供图片 URL
# image_url = 'https://example.com/your_image.jpg'
api_key = 'b2f961a88d854dc457a235c55d8486a764e8ff7d'
result = saucenao_search(image_path=image_path, api_key=api_key)
if result:
parse_saucenao_result(result)

View file

@ -0,0 +1,75 @@
from pydantic import BaseModel
class Hitokoto(BaseModel):
id: int
"""id"""
uuid: str
"""uuid"""
hitokoto: str
"""一言"""
type: str
"""类型"""
from_who: str | None
"""作者"""
creator: str
"""创建者"""
creator_uid: int
"""创建者id"""
reviewer: int
"""审核者"""
commit_from: str
"""提交来源"""
created_at: str
"""创建日期"""
length: int
"""长度"""
class SixDataTo(BaseModel):
news: list[str]
"""新闻"""
tip: str
"""tip"""
updated: int
"""更新日期"""
url: str
"""链接"""
cover: str
"""图片"""
class SixData(BaseModel):
status: int
"""状态码"""
message: str
"""返回内容"""
data: SixDataTo
"""数据"""
class WeekDay(BaseModel):
en: str
"""英文"""
cn: str
"""中文"""
ja: str
"""日本称呼"""
id: int
"""ID"""
class AnimeItem(BaseModel):
name: str
name_cn: str
images: dict | None
@property
def image(self) -> str:
return self.images["large"] if self.images else ""
class Anime(BaseModel):
weekday: WeekDay
items: list[AnimeItem]

View file

@ -0,0 +1,307 @@
body {
position: absolute;
left: -8px;
top: -8px;
}
@font-face {
font-family: "Noto Sans SC B";
src: url("./res/font/NotoSansSC-Bold.otf") format("opentype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Noto Sans SC";
src: url("./res/font/NotoSansSC-Regular.otf") format("opentype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SSFangTangTi";
src: url("./res/font/SSFangTangTi.ttf");
}
.wrapper {
/* height: 1885px; */
width: 578px;
background-color: #45667d;
position: relative;
padding: 15px;
/* box-sizing: border-box; */
}
.main {
/* height: 1860px; */
width: 555px;
background-color: #edf8ff;
position: relative;
border-radius: 10px;
padding: 10px;
}
.top-border {
display: flex;
}
.zx-img {
height: 155px;
}
.top-title {
position: relative;
width: 322px;
}
.zx-title {
font-family: "SSFangTangTi";
color: #45667d;
font-size: 72px;
position: absolute;
top: -73px;
left: 10px;
}
.zx-tip {
color: #45667d;
font-family: "Noto Sans SC B";
font-size: 33px;
position: absolute;
top: 73px;
left: 20px;
}
.top-date {
background-color: #c1e0ef;
border-radius: 10px;
height: 108px;
width: 120px;
margin-top: 30px;
/* margin-left: 67px; */
position: relative;
box-shadow: 4px 4px 8px rgb(181, 223, 240);
}
.top-date-week {
color: #45667d;
font-size: 35px;
font-family: "Noto Sans SC B";
position: absolute;
left: 40px;
top: -34px;
}
.top-date-date {
color: #45667d;
font-family: "Noto Sans SC B";
font-size: 12px;
background-color: #a0d7ec;
margin-top: 50px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
}
.top-date-cn {
font-family: "Noto Sans SC B";
color: #45667d;
font-size: 15px;
display: flex;
align-items: center;
justify-content: center;
line-height: 0;
}
.one-border {
display: flex;
height: 320px;
margin-top: 50px;
font-family: "Noto Sans SC";
}
.moyu-border {
font-family: "Noto Sans SC";
height: 240px;
padding: 15px 10px;
border-radius: 10px;
border: #45667d 3px solid;
width: 157px;
position: relative;
}
.moyu-title {
border: #45667d 2px solid;
position: absolute;
background-color: #ebf6fd;
align-items: center;
justify-content: center;
border-radius: 10px;
color: #45667d;
padding: 5px 10px;
font-size: 14px;
font-family: "Noto Sans SC B";
top: -19px;
left: 37px;
display: flex;
}
.title-img {
height: 15px;
width: 15px;
margin-right: 5px;
}
.moyu-inner {
display: flex;
line-height: 15px;
font-size: 13px;
}
.moyu-day {
width: 37px;
display: flex;
align-items: center;
justify-content: center;
}
.moyu-day-name {
color: #45667d;
background-color: #daf8ff;
display: flex;
align-items: center;
justify-content: center;
width: 55px;
}
.bili-border {
font-family: "Noto Sans SC";
border: #45667d 3px solid;
height: 240px;
margin-left: 15px;
padding: 15px 10px 15px 40px;
border-radius: 10px;
width: 300px;
position: relative;
font-size: 13px;
}
.bili-text {
display: flex;
align-items: center;
line-height: 5px;
margin-top: 7px;
margin-bottom: 6px;
}
.hot-text {
background-color: #b5deef;
height: 17px;
width: 17px;
color: white;
display: flex;
align-items: center;
justify-content: center;
margin-right: 5px;
border-radius: 2px;
}
.two-border {
border: #45667d 3px solid;
width: 512px;
height: 370px;
border-radius: 10px;
position: relative;
padding: 17px;
font-family: "Noto Sans SC";
background-color: #edf8ff;
}
.two-border-border {
float: left;
}
.anime-border {
height: 170px;
width: 105px;
font-size: 10px;
font-size: 13px;
text-align: center;
line-height: 3px;
float: left;
margin: 8px 10px;
line-height: 15px;
}
.anime-text {
overflow: hidden;
margin-top: 3px;
height: 48px;
}
.anime-img {
height: 130px;
width: 105px;
}
.three-border {
border: #45667d 3px solid;
width: 552px;
border-radius: 10px;
position: relative;
/* float: left; */
padding: 17px;
font-family: "Noto Sans SC";
margin-top: 46px;
background-color: #edf8ff;
font-size: 15px;
box-sizing: border-box;
}
.four-border {
border: #45667d 3px solid;
width: 512px;
border-radius: 10px;
position: relative;
/* float: left; */
padding: 17px;
font-family: "Noto Sans SC";
margin-top: 46px;
background-color: #edf8ff;
font-size: 15px;
}
.five-border {
border: #45667d 3px solid;
width: 520px;
border-radius: 10px;
position: relative;
/* float: left; */
padding: 13px;
font-family: "Noto Sans SC";
margin-top: 46px;
font-size: 19px;
color: #45667d;
font-weight: 600;
background-color: #edf8ff;
}
.normal-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* width: 450px; */
}
ul {
list-style-position: inside; /* 将项目符号放在内容内部 */
/* width: 450px; */
}
li::marker {
display: inline-block; /* 确保项目符号可见 */
}

View file

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Test</title>
<link rel="stylesheet" href="main.css">
</head>
<body>
<div class="wrapper">
<div class="main">
<div class="top-border">
<div class="zx-img">
<img src="./res/image/no_bg.png" alt="ZhenXun" class="zx-img">
</div>
<div class="top-title">
<p class="zx-title">日报</p>
<p class="zx-tip">NEWS</p>
</div>
<div class="top-date">
<p class="top-date-week">{{data.week}}</p>
<p class="top-date-date">{{data.date}}</p>
<p class="top-date-cn">{{data.zh_date}}</p>
</div>
</div>
<div class="one-border">
<div class="moyu-border">
<div class="moyu-title"><img src="./res/icon/fish.png" class="title-img">摸鱼日历</div>
{% for fes in data.data_festival %}
<p class="moyu-inner">距离<span class="moyu-day-name">{{fes[1]}}</span>还剩<span
class="moyu-day">{{fes[0]}}</span>
</p>
{% endfor %}
</div>
<div class="bili-border">
<div class="moyu-title" style="left: 128px;"><img src="./res/icon/bilibili.png"
class="title-img">B站热点</div>
{% for s in data.data_bili %}
<p class="bili-text"><span class="hot-text"></span>{{s}}</p>
{% endfor %}
</div>
</div>
<div class="two-border">
<div class="moyu-title" style="left: -3px;"><img src="./res/icon/bgm.png" class="title-img">今日新番</div>
<div class="two-border-border">
{% for s in data.data_anime %}
<div class="anime-border">
<img src="{{s[1]}}" class="anime-img">
<p class="anime-text">{{s[0]}}</p>
</div>
{% endfor %}
</div>
</div>
<div class="three-border">
<div class="moyu-title" style="left: -3px;"><img src="./res/icon/60.png" class="title-img">60S读世界</div>
<ul style="line-height: 30px; line-height: 25px; margin-left: -25px;">
{% for s in data.data_six %}
{% if data.full_show %}
<li class="full-show-text">{{s}}</li>
{% else %}
<li class="normal-text">{{s}}</li>
{% endif %}
{% endfor %}
</ul>
</div>
<div class="four-border">
<div class="moyu-title" style="left: -3px;"><img src="./res/icon/it.png" class="title-img">IT资讯</div>
<ul style="line-height: 30px; line-height: 25px; margin-left: -25px;">
{% for s in data.data_it %}
{% if data.full_show %}
<li class="full-show-text">{{s}}</li>
{% else %}
<li class="normal-text">{{s}}</li>
{% endif %}
{% endfor %}
</ul>
</div>
<div class="five-border">
<div class="moyu-title" style="left: -3px;"><img src="./res/icon/hitokoto.png" class="title-img">今日一言
</div>
<p style="margin-top: 5px;margin-bottom: 5px;">“{{data.data_hitokoto}}”</p>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View file

@ -0,0 +1,205 @@
import asyncio
import os
from datetime import datetime
import xml.etree.ElementTree as ET
from os import getcwd
from pathlib import Path
import httpx
from httpx import ConnectError, HTTPStatusError, Response, TimeoutException
from nonebot.log import logger
from nonebot_plugin_htmlrender import template_to_pic
import tenacity
from tenacity import retry, stop_after_attempt, wait_fixed
from zhdate import ZhDate
from .config import Anime, Hitokoto, SixData
from .date import get_festivals_dates
from src.configs.path_config import daily_news_path
from playwright.async_api import async_playwright
class AsyncHttpx:
@classmethod
@retry(
stop=stop_after_attempt(3),
wait=wait_fixed(1),
retry=(
tenacity.retry_if_exception_type(
(TimeoutException, ConnectError, HTTPStatusError)
)
),
)
async def get(cls, url: str) -> Response:
async with httpx.AsyncClient() as client:
try:
response = await client.get(url)
response.raise_for_status()
return response
except (TimeoutException, ConnectError, HTTPStatusError) as e:
logger.error(f"Request to {url} failed due to: {e}")
raise
@classmethod
@retry(
stop=stop_after_attempt(3),
wait=wait_fixed(1),
retry=(
tenacity.retry_if_exception_type(
(TimeoutException, ConnectError, HTTPStatusError)
)
),
)
async def post(
cls, url: str, data: dict[str, str], headers: dict[str, str]
) -> Response:
async with httpx.AsyncClient() as client:
try:
response = await client.post(url, data=data, headers=headers)
response.raise_for_status()
return response
except (TimeoutException, ConnectError, HTTPStatusError) as e:
logger.error(f"Request to {url} failed due to: {e}")
raise
async def save_img(data: bytes):
"""
保存日报图片
:param data:
:return:
"""
file_path = daily_news_path + f"{datetime.now().date()}.png"
with open(file_path, "wb") as file:
file.write(data)
class Report:
hitokoto_url = "https://v1.hitokoto.cn/?c=a"
alapi_url = "https://v2.alapi.cn/api/zaobao"
alapi_token = "48x3u7iqryztowlnwbnrwjucebzieu"
six_url = "https://60s.viki.moe/?v2=1"
game_url = "https://www.4gamers.com.tw/rss/latest-news"
bili_url = "https://s.search.bilibili.com/main/hotword"
it_url = "https://www.ithome.com/rss/"
anime_url = "https://api.bgm.tv/calendar"
week = { # noqa: RUF012
0: "",
1: "",
2: "",
3: "",
4: "",
5: "",
6: "",
}
@classmethod
async def get_report_image(cls) -> bytes:
"""获取数据"""
now = datetime.now()
file = Path() / daily_news_path / f"{now.date()}.png"
if os.path.exists(file):
with file.open("rb") as image_file:
return image_file.read()
zhdata = ZhDate.from_datetime(now)
result = await asyncio.gather(
*[
cls.get_hitokoto(),
cls.get_bili(),
cls.get_six(),
cls.get_anime(),
cls.get_it(),
]
)
data = {
"data_festival": get_festivals_dates(),
"data_hitokoto": result[0],
"data_bili": result[1],
"data_six": result[2],
"data_anime": result[3],
"data_it": result[4],
"week": cls.week[now.weekday()],
"date": now.date(),
"zh_date": zhdata.chinese().split()[0][5:],
"full_show": True,
}
async with async_playwright() as p:
browser = await p.chromium.launch()
image_bytes = await template_to_pic(
template_path=getcwd() + "/src/clover_report/daily_report",
template_name="main.html",
templates={"data": data},
pages={
"viewport": {"width": 578, "height": 1885},
"base_url": f"file://{getcwd()}",
},
wait=2,
)
await save_img(image_bytes)
await browser.close()
return image_bytes
@classmethod
async def get_hitokoto(cls) -> str:
"""获取一言"""
res = await AsyncHttpx.get(cls.hitokoto_url)
data = Hitokoto(**res.json())
return data.hitokoto
@classmethod
async def get_bili(cls) -> list[str]:
"""获取哔哩哔哩热搜"""
res = await AsyncHttpx.get(cls.bili_url)
data = res.json()
return [item["keyword"] for item in data["list"]]
@classmethod
async def get_alapi_data(cls) -> list[str]:
"""获取alapi数据"""
payload = {"token": cls.alapi_token, "format": "json"}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
res = await AsyncHttpx.post(cls.alapi_url, data=payload, headers=headers)
if res.status_code != 200:
return ["Error: Unable to fetch data"]
data = res.json()
news_items = data.get("data", {}).get("news", [])
return news_items[:11] if len(news_items) > 11 else news_items
@classmethod
async def get_six(cls) -> list[str]:
"""获取60s数据"""
if True:
return await cls.get_alapi_data()
res = await AsyncHttpx.get(cls.six_url)
data = SixData(**res.json())
return data.data.news[:11] if len(
data.data.news) > 11 else data.data.news
@classmethod
async def get_it(cls) -> list[str]:
"""获取it数据"""
res = await AsyncHttpx.get(cls.it_url)
root = ET.fromstring(res.text)
titles = []
for item in root.findall("./channel/item"):
title_element = item.find("title")
if title_element is not None:
titles.append(title_element.text)
return titles[:11] if len(titles) > 11 else titles
@classmethod
async def get_anime(cls) -> list[tuple[str, str]]:
"""获取动漫数据"""
res = await AsyncHttpx.get(cls.anime_url)
data_list = []
week = datetime.now().weekday()
try:
anime = Anime(**res.json()[week])
except IndexError:
anime = Anime(**res.json()[-1])
data_list.extend(
(data.name_cn or data.name, data.image) for data in anime.items
)
return data_list[:8] if len(data_list) > 8 else data_list

95
src/clover_report/date.py Normal file
View file

@ -0,0 +1,95 @@
from datetime import date, timedelta
import chinese_calendar as calendar
import lunardate
# 定义2025年农历节日的农历日期
lunar_festivals = {
"春节": (1, 1), # 春节 (农历正月初一)
"端午节": (5, 5), # 端午节 (农历五月初五)
"中秋节": (8, 15), # 中秋节 (农历八月十五)
}
# 固定日期的节日
fixed_festivals_dates = {
"劳动节": date(2025, 5, 1), # 劳动节
"国庆节": date(2025, 10, 1), # 国庆节
"元旦": date(2025, 1, 1), # 元旦
}
def get_next_year_festival_date(
festival_name: str, current_festival_date: date
) -> date:
"""获取下一个该节日的日期"""
if festival_name in lunar_festivals:
# 对于农历节日使用lunardate库转换为下一年的公历日期
next_year = current_festival_date.year + 1
month, day = lunar_festivals[festival_name]
next_festival_date = lunardate.LunarDate(next_year, month, day).toSolarDate()
else:
# 对于固定日期的节日,直接增加一年
next_festival_date = current_festival_date.replace(
year=current_festival_date.year + 1
)
return next_festival_date
def find_tomb_sweeping_day(year: int) -> date:
# 春分通常在3月20日或21日
start_date = date(year, 3, 20)
# 查找春分的确切日期
spring_equinox = next(
(
start_date + timedelta(days=i)
for i in range(3)
if calendar.get_holiday_detail(start_date + timedelta(days=i))[1] == "春分"
),
start_date,
)
return spring_equinox + timedelta(days=15)
def days_until_festival(festival_name: str, today: date, festival_date: date) -> int:
if festival_date < today:
# 如果节日已经过去,计算下一个该节日的到来时间
next_festival_date = get_next_year_festival_date(festival_name, festival_date)
delta = next_festival_date - today
return delta.days
else:
delta = festival_date - today
return delta.days
# 获取农历节日对应的公历日期
def get_lunar_festivals_dates(today: date):
year = today.year
return {
name: lunardate.LunarDate(year, month, day).toSolarDate()
for name, (month, day) in lunar_festivals.items()
}
def get_festivals_dates() -> list[tuple[int, str]]:
today = date.today()
lunar_festivals_dates = get_lunar_festivals_dates(today)
# 添加清明节到节日字典中
lunar_festivals_dates["清明节"] = find_tomb_sweeping_day(today.year)
# 合并两个字典
festivals_dates = {**lunar_festivals_dates, **fixed_festivals_dates}
sort_name = ["春节", "端午节", "中秋节", "清明节", "劳动节", "国庆节", "元旦"]
# 计算到每个节日的天数,并检查是否为法定假日
data_list = []
for name in sort_name:
if name in festivals_dates:
days_left = days_until_festival(name, today, festivals_dates[name])
data_list.append((days_left, name))
else:
data_list.append((-1, name))
data_list.sort(key=lambda x: x[0])
return data_list

View file

@ -18,6 +18,9 @@ good_bad = path+'/image/good_bad_news/'
#谁说 生成图片路径 #谁说 生成图片路径
who_say_path = path+'/image/who_say/' who_say_path = path+'/image/who_say/'
# 日报
daily_news_path = path+'/image/report/'
# 字体路径 # 字体路径
font_path = path + '/font/' font_path = path + '/font/'

View file

@ -6,11 +6,12 @@ from nonebot.adapters.qq import MessageSegment,MessageEvent
from src.clover_image.get_image import get_image_names from src.clover_image.get_image import get_image_names
from src.clover_image.download_image import download_image from src.clover_image.download_image import download_image
from src.configs.path_config import temp_path from src.configs.path_config import temp_path
from src.clover_report.data_source import Report
image = on_command("", rule=to_me(), priority=10, block=True) image = on_command("", rule=to_me(), priority=10, block=True)
@image.handle() @image.handle()
async def handle_function(): async def handle_function():
await Report.get_report_image()
local_image_path = get_image_names() local_image_path = get_image_names()
await image.finish(MessageSegment.file_image(Path(local_image_path))) await image.finish(MessageSegment.file_image(Path(local_image_path)))