feat: 初始化项目并提交一次代码
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
__pycache__
|
||||
data
|
||||
fonts/*.ttf
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 zhanghoulin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
45
README.md
Normal file
45
README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
## 城科水电监控
|
||||
|
||||
**进行水费和电费的实时监控程序, 默认获取前24条数据进行绘图**
|
||||
|
||||
### 实现功能
|
||||
|
||||
- 获取**水费**和**电费**使用情况
|
||||
- 使用 `matplotlib` 进行绘图可视化
|
||||
- 使用企业微信机器人进行推送图片
|
||||
|
||||
### 使用
|
||||
|
||||
- 配置当前目录下的 `config.py` 文件
|
||||
|
||||
- 手动去 Github 下载 [LXGWWenKai-Medium 字体](https://github.com/lxgw/LxgwWenKai) 保存到fonts文件夹中,或者使用 wget 进行获取,然后修改配置文件
|
||||
|
||||
```sh
|
||||
curl -L -o ./fonts/LXGWWenKaiMono-Medium.ttf https://github.5700.cf/https://github.com/lxgw/LxgwWenKai/releases/download/v1.521/LXGWWenKaiMono-Medium.ttf
|
||||
```
|
||||
|
||||
- `UID` 获取,使用支付宝进行扫描下方二维码即可获得 `uid`
|
||||
|
||||

|
||||
|
||||
|
||||
- `USER_NO` 获取,通过抓包获取,使用抓包软件进行抓取微信小程序**扫呗团餐**,对响应体进行搜索`user_no`即可
|
||||
|
||||

|
||||
|
||||
- `SCHOOL_ACCOUNT` 这是学号(可选)
|
||||
- `USER_NAME` 这是姓名(可选)
|
||||
- `WECHAT_COMANY_BOT_KEY` 企业微信机器人 key
|
||||
|
||||
|
||||
- 安装依赖
|
||||
|
||||
```sh
|
||||
pip install aiosqlite apscheduler httpx matplotlib
|
||||
```
|
||||
|
||||
- 通过 `scheduler.py` 修改监控时长配置(不懂的请问 AI)
|
||||
|
||||
### 📜 License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
19
config.py
Normal file
19
config.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
DB_PATH = os.getenv("DB_PATH", "data/data.db")
|
||||
FONT_PATH = os.path.join(os.getcwd(), "fonts", "LXGWWenKai-Medium.ttf")
|
||||
|
||||
# 获取水电信息配置
|
||||
UID = os.getenv("UID", "1111111111111111") # 支付宝的 uid
|
||||
USER_NO = os.getenv("USER_NO", "11111111111111111111111111111111") # 小程序的 user_no
|
||||
SCHOOL_ACCOUNT = os.getenv("SCHOOL_ACCOUNT", "1111111111") # 学号
|
||||
USER_NAME = os.getenv("USER_NAME", "张三") # 姓名
|
||||
|
||||
# 推送相关参数
|
||||
WECHAT_COMANY_BOT_KEY = os.getenv(
|
||||
"WECHAT_COMANY_BOT_KEY", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
)
|
||||
10
database.py
Normal file
10
database.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import aiosqlite
|
||||
import config
|
||||
import os
|
||||
|
||||
|
||||
def get_conn():
|
||||
if not os.path.exists(config.DB_PATH):
|
||||
open(config.DB_PATH, "w").close()
|
||||
|
||||
return aiosqlite.connect(config.DB_PATH, timeout=30)
|
||||
0
fonts/.gitkeep
Normal file
0
fonts/.gitkeep
Normal file
BIN
images/image.png
Normal file
BIN
images/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
images/image2.png
Normal file
BIN
images/image2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
40
main.py
Normal file
40
main.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# main.py / 启动入口
|
||||
import asyncio
|
||||
import os
|
||||
from tasks.water_ammeter import SD
|
||||
import config
|
||||
from models.water_usage import WaterUsageModel
|
||||
from models.ammeter_usage import AmmeterUsageModel
|
||||
from scheduler import start_scheduler
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
warnings.filterwarnings("ignore", message="Glyph .* missing from font")
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
)
|
||||
|
||||
|
||||
# 确保数据库目录存在
|
||||
db_dir = os.path.dirname(config.DB_PATH)
|
||||
if db_dir and not os.path.exists(db_dir):
|
||||
os.makedirs(db_dir)
|
||||
|
||||
|
||||
async def main():
|
||||
# await WaterUsageModel.create_table()
|
||||
# await AmmeterUsageModel.create_table()
|
||||
|
||||
# start_scheduler()
|
||||
|
||||
await SD().push()
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
0
models/__init__.py
Normal file
0
models/__init__.py
Normal file
55
models/ammeter_usage.py
Normal file
55
models/ammeter_usage.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import time
|
||||
import aiosqlite
|
||||
from database import get_conn
|
||||
|
||||
|
||||
class AmmeterUsageModel:
|
||||
@classmethod
|
||||
async def create_table(cls):
|
||||
async with get_conn() as conn:
|
||||
conn.row_factory = aiosqlite.Row
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ammeter_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER,
|
||||
room_name TEXT,
|
||||
left_ele REAL,
|
||||
left_money REAL,
|
||||
left_free_ele REAL,
|
||||
left_free_money REAL,
|
||||
ele_price REAL,
|
||||
mon_time INTEGER,
|
||||
created_at INTEGER UNIQUE
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
@classmethod
|
||||
async def insert(cls, data: dict):
|
||||
print(data)
|
||||
async with get_conn() as conn:
|
||||
conn.row_factory = aiosqlite.Row
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO ammeter_usage (
|
||||
room_id, room_name,
|
||||
left_ele, left_money,
|
||||
left_free_ele, left_free_money,
|
||||
ele_price, mon_time, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
data["roomId"],
|
||||
data["roomName"],
|
||||
data["leftEle"],
|
||||
data["leftMoney"],
|
||||
data["leftFreeEle"],
|
||||
data["leftFreeMoney"],
|
||||
data["elePrice"],
|
||||
data["monTime"],
|
||||
int(time.time() * 1000),
|
||||
),
|
||||
)
|
||||
await conn.commit()
|
||||
55
models/water_usage.py
Normal file
55
models/water_usage.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import time
|
||||
import aiosqlite
|
||||
from database import get_conn
|
||||
|
||||
|
||||
class WaterUsageModel:
|
||||
@classmethod
|
||||
async def create_table(cls):
|
||||
async with get_conn() as conn:
|
||||
conn.row_factory = aiosqlite.Row
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS water_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER,
|
||||
room_name TEXT,
|
||||
left_water REAL,
|
||||
left_money REAL,
|
||||
left_free_water REAL,
|
||||
left_free_money REAL,
|
||||
water_price REAL,
|
||||
mon_time INTEGER,
|
||||
created_at INTEGER UNIQUE
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
@classmethod
|
||||
async def insert(cls, data: dict):
|
||||
print(data)
|
||||
async with get_conn() as conn:
|
||||
conn.row_factory = aiosqlite.Row
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO water_usage (
|
||||
room_id, room_name,
|
||||
left_water, left_money,
|
||||
left_free_water, left_free_money,
|
||||
water_price, mon_time, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
data["roomId"],
|
||||
data["roomName"],
|
||||
data["leftWater"],
|
||||
data["leftMoney"],
|
||||
data["leftFreeWater"],
|
||||
data["leftFreeMoney"],
|
||||
data["coldWaterPrice"],
|
||||
data["monTime"],
|
||||
int(time.time() * 1000),
|
||||
),
|
||||
)
|
||||
await conn.commit()
|
||||
12
notify/wechat_company.py
Normal file
12
notify/wechat_company.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import httpx
|
||||
import config
|
||||
|
||||
|
||||
async def send_image(base64_str: str, md5: str):
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={config.WECHAT_COMANY_BOT_KEY}"
|
||||
|
||||
payload = {"msgtype": "image", "image": {"base64": base64_str, "md5": md5}}
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(url, json=payload)
|
||||
resp.raise_for_status()
|
||||
41
scheduler.py
Normal file
41
scheduler.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from tasks.water_ammeter import SD
|
||||
|
||||
logger = logging.getLogger("scheduler")
|
||||
|
||||
sd = SD()
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
logger.info("初始化 AsyncIOScheduler(Asia/Shanghai)")
|
||||
|
||||
scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||
|
||||
scheduler.add_job(
|
||||
sd.fetch_and_save,
|
||||
trigger="cron",
|
||||
minute="*",
|
||||
id="water_ammeter_job",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
logger.info("已添加任务:water_ammeter_job(每小时整点执行)")
|
||||
|
||||
scheduler.add_job(
|
||||
sd.push,
|
||||
trigger="cron",
|
||||
hour=19,
|
||||
minute=0,
|
||||
id="push_gzh",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
logger.info("已添加任务:push_gzh(每天 08:00 执行)")
|
||||
|
||||
scheduler.start()
|
||||
logger.info("调度器启动完成")
|
||||
|
||||
return scheduler
|
||||
13
services/collector.py
Normal file
13
services/collector.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# services/collector.py
|
||||
from models.water_usage import WaterUsageModel
|
||||
from models.ammeter_usage import AmmeterUsageModel
|
||||
|
||||
|
||||
async def handle_water_response(resp: dict):
|
||||
if resp.get("statusCode") == "200":
|
||||
await WaterUsageModel.insert(resp["resultObject"])
|
||||
|
||||
|
||||
async def handle_ammeter_response(resp: dict):
|
||||
if resp.get("statusCode") == "200":
|
||||
await AmmeterUsageModel.insert(resp["resultObject"])
|
||||
31
services/plot.py
Normal file
31
services/plot.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib import font_manager
|
||||
import config
|
||||
import logging
|
||||
|
||||
logging.getLogger("matplotlib").setLevel(logging.WARNING)
|
||||
|
||||
# 从字体文件创建字体对象
|
||||
FONT = font_manager.FontProperties(fname=config.FONT_PATH)
|
||||
|
||||
# 负号修复
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
|
||||
|
||||
def plot_line(times, values, title, ylabel, out_path):
|
||||
plt.figure(figsize=(8, 4))
|
||||
plt.plot(times, values, marker="o")
|
||||
|
||||
plt.title(title, fontproperties=FONT)
|
||||
plt.xlabel("时间", fontproperties=FONT)
|
||||
plt.ylabel(ylabel, fontproperties=FONT)
|
||||
|
||||
plt.xticks(rotation=45, fontproperties=FONT)
|
||||
plt.grid(True)
|
||||
plt.tight_layout()
|
||||
plt.savefig(out_path, dpi=150)
|
||||
plt.close()
|
||||
40
services/render.py
Normal file
40
services/render.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from services.statistics import get_ammeter_left_ele, get_water_left
|
||||
from services.plot import plot_line
|
||||
|
||||
|
||||
async def render_ammeter_chart():
|
||||
times, values = await get_ammeter_left_ele(limit=24)
|
||||
|
||||
if not times:
|
||||
return None
|
||||
|
||||
out = "data/ammeter.png"
|
||||
|
||||
plot_line(
|
||||
times=times,
|
||||
values=values,
|
||||
title="宿舍电量剩余趋势",
|
||||
ylabel="剩余电量(度)",
|
||||
out_path=out,
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
async def render_water_chart():
|
||||
times, values = await get_water_left(limit=24)
|
||||
|
||||
if not times:
|
||||
return None
|
||||
|
||||
out = "data/water.png"
|
||||
|
||||
plot_line(
|
||||
times=times,
|
||||
values=values,
|
||||
title="宿舍水量剩余趋势",
|
||||
ylabel="剩余水量(吨)",
|
||||
out_path=out,
|
||||
)
|
||||
|
||||
return out
|
||||
48
services/statistics.py
Normal file
48
services/statistics.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import time
|
||||
|
||||
import aiosqlite
|
||||
from database import get_conn
|
||||
|
||||
|
||||
async def get_ammeter_left_ele(limit=24):
|
||||
async with get_conn() as conn:
|
||||
conn.row_factory = aiosqlite.Row
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT created_at, left_ele
|
||||
FROM ammeter_usage
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
rows.reverse()
|
||||
|
||||
times = [time.strftime("%H:%M", time.localtime(r[0] / 1000)) for r in rows]
|
||||
values = [r[1] for r in rows]
|
||||
|
||||
return times, values
|
||||
|
||||
|
||||
async def get_water_left(limit=24):
|
||||
async with get_conn() as conn:
|
||||
conn.row_factory = aiosqlite.Row
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT created_at, left_water
|
||||
FROM water_usage
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
rows.reverse()
|
||||
|
||||
times = [time.strftime("%H:%M", time.localtime(r[0] / 1000)) for r in rows]
|
||||
values = [r[1] for r in rows]
|
||||
|
||||
return times, values
|
||||
152
tasks/water_ammeter.py
Normal file
152
tasks/water_ammeter.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
import logging
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from notify.wechat_company import send_image
|
||||
from services.render import render_ammeter_chart, render_water_chart
|
||||
from services import collector
|
||||
import config
|
||||
|
||||
|
||||
logger = logging.getLogger("task.sd")
|
||||
|
||||
|
||||
def image_to_base64_md5(image_path: str) -> tuple[str, str]:
|
||||
logger.debug("读取图片并计算 MD5/Base64: %s", image_path)
|
||||
|
||||
with open(image_path, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
md5 = hashlib.md5(data).hexdigest()
|
||||
b64 = base64.b64encode(data).decode("utf-8")
|
||||
|
||||
logger.debug("图片处理完成 md5=%s", md5)
|
||||
return b64, md5
|
||||
|
||||
|
||||
class SD:
|
||||
def __init__(self) -> None:
|
||||
logger.info("初始化 SD 客户端")
|
||||
|
||||
self.client = httpx.AsyncClient(
|
||||
base_url="https://zhsd.cqcst.edu.cn",
|
||||
timeout=30,
|
||||
proxy="http://127.0.0.1:9000",
|
||||
verify=False,
|
||||
headers={
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Encoding": "gzip",
|
||||
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15",
|
||||
"Connection": "Keep-Alive",
|
||||
"Host": "zhsd.cqcst.edu.cn",
|
||||
},
|
||||
)
|
||||
|
||||
async def get_cookie(self):
|
||||
logger.info("获取登录 Cookie")
|
||||
|
||||
resp = await self.client.get(
|
||||
"/cqbn/service/weixin/thirdLogin",
|
||||
params={
|
||||
"uid": config.UID,
|
||||
"userNo": config.USER_NO,
|
||||
"schoolAccount": config.SCHOOL_ACCOUNT,
|
||||
"userName": config.USER_NAME,
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug("Cookie 响应状态码: %s", resp.status_code)
|
||||
# resp.raise_for_status()
|
||||
|
||||
async def get_user_info(self):
|
||||
logger.debug("获取用户信息")
|
||||
|
||||
resp = await self.client.get("/cqbn/service/find/userinfo")
|
||||
resp.raise_for_status()
|
||||
|
||||
result = resp.json()
|
||||
logger.debug("用户信息返回成功")
|
||||
return result
|
||||
|
||||
async def get_water(self):
|
||||
logger.info("请求水表数据")
|
||||
|
||||
resp = await self.client.get(
|
||||
"/cqbn/service/waterBalance",
|
||||
params={"type": "3", "systemType": "1"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
result = resp.json()
|
||||
logger.info("水表数据获取成功")
|
||||
return result
|
||||
|
||||
async def get_ammeter(self):
|
||||
logger.info("请求电表数据")
|
||||
|
||||
resp = await self.client.get(
|
||||
"/cqbn/service/ammeterBalance",
|
||||
params={"type": "1"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
result = resp.json()
|
||||
logger.info("电表数据获取成功")
|
||||
return result
|
||||
|
||||
async def fetch_and_save(self):
|
||||
logger.info("===== 开始采集水电数据 =====")
|
||||
|
||||
try:
|
||||
await self.get_cookie()
|
||||
|
||||
water = await self.get_water()
|
||||
await collector.handle_water_response(water)
|
||||
logger.info("水表数据已入库")
|
||||
|
||||
ammeter = await self.get_ammeter()
|
||||
await collector.handle_ammeter_response(ammeter)
|
||||
logger.info("电表数据已入库")
|
||||
|
||||
logger.info("===== 水电数据采集完成 =====")
|
||||
|
||||
except Exception:
|
||||
logger.exception("水电数据采集失败")
|
||||
|
||||
async def push(self):
|
||||
logger.info("===== 开始推送水电图表 =====")
|
||||
|
||||
try:
|
||||
ele_img_path = await render_ammeter_chart()
|
||||
if ele_img_path:
|
||||
logger.info("生成电表图表成功: %s", ele_img_path)
|
||||
ele_b64, ele_md5 = image_to_base64_md5(ele_img_path)
|
||||
await send_image(ele_b64, ele_md5)
|
||||
logger.info("电表图表推送完成")
|
||||
else:
|
||||
logger.warning("未生成电表图表,跳过推送")
|
||||
|
||||
water_img_path = await render_water_chart()
|
||||
if water_img_path:
|
||||
logger.info("生成水表图表成功: %s", water_img_path)
|
||||
water_b64, water_md5 = image_to_base64_md5(water_img_path)
|
||||
await send_image(water_b64, water_md5)
|
||||
logger.info("水表图表推送完成")
|
||||
else:
|
||||
logger.warning("未生成水表图表,跳过推送")
|
||||
|
||||
logger.info("===== 图表推送完成 =====")
|
||||
|
||||
except Exception:
|
||||
logger.exception("水电图表推送失败")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
)
|
||||
|
||||
asyncio.run(SD().fetch_and_save())
|
||||
Reference in New Issue
Block a user