From b9ae87b6621c72c429202eac543a40906c52c309 Mon Sep 17 00:00:00 2001 From: tie4453 Date: Sat, 11 Jan 2025 15:12:00 +0800 Subject: [PATCH 01/21] Update weather_report.py --- weather_report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/weather_report.py b/weather_report.py index 06a2c6f8..3eca4df3 100644 --- a/weather_report.py +++ b/weather_report.py @@ -130,4 +130,4 @@ def weather_report(this_city): if __name__ == '__main__': - weather_report("淄博") \ No newline at end of file + weather_report("吉安") From 9d952500e8adee07bb526bbb0d39126308ba3638 Mon Sep 17 00:00:00 2001 From: tie4453 Date: Mon, 28 Apr 2025 13:25:25 +0800 Subject: [PATCH 02/21] Create main.yml --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..0fca8ecc --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,2 @@ +- name: Versatile PyInstaller + uses: sayyid5416/pyinstaller@v1.8.0 From 6e8e1baea2efa973713c878d4793178aa12e6ed2 Mon Sep 17 00:00:00 2001 From: tie4453 Date: Mon, 28 Apr 2025 13:27:13 +0800 Subject: [PATCH 03/21] Update main.yml --- .github/workflows/main.yml | 159 ++++++++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0fca8ecc..710b51b6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,2 +1,157 @@ -- name: Versatile PyInstaller - uses: sayyid5416/pyinstaller@v1.8.0 +name: Versatile PyInstaller +author: '@sayyid5416' +description: Customisable GitHub Action to package python scripts into executables for different OS's +branding: + icon: hard-drive + color: yellow + + +inputs: + spec: + description: > + path of your '.py' or '.spec' file. + - This file will be used to create executable. + - If .py: Generated spec file will also be uploaded as artifact + required: true + default: '' + requirements: + description: path of your requirements.txt file + default: '' + options: + description: > + Options to set for pyinstaller command + Ex: options: '--onedir, -F' (seperated by comma and space) + - Supported options: Check readme + default: '' + spec_options: + description: > + Custom parameters for spec file. (won't work with .py spec file) + Ex: spec_options: '--debug' + default: '' + python_ver: + description: specific python version you want to use + default: '3.10' + python_arch: + description: specific python architecture you want to use + default: 'x64' + pyinstaller_ver: + description: specific pyinstaller version you want to use + default: '' + exe_path: + description: Path on runner-os, where generated executable files are stored + default: './dist' + upload_exe_with_name: + description: If passed, uploads executable artifact with this name. Else, artifact won't be uploaded. + default: '' + clean_checkout: + description: 'If true, perform a clean checkout; if false, skip cleaning. Cleaning will remove all existing local files not in the repository during checkout. If you use utilities like pyinstaller-versionfile, set this to false.' + default: true + lfs: + description: Whether to download Git-LFS files (passed to actions/checkout) + default: false + compression_level: + description: 'Level of compression for archive (between 0 and 9). 0 = No compression, 9 = Max compression.' + default: 6 + + +outputs: + executable_path: + description: path on runner-os, where generated executable files are stored + value: ${{ inputs.exe_path }} + is_uploaded: + description: true, if packaged executable has been uploaded as artifact + value: ${{ steps.exe_uploading.outputs.uploaded }} + + + +runs: + using: 'composite' + steps: + + - name: (Install) python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python_ver }} + architecture: ${{ inputs.python_arch }} + + - name: (Install) python dev tools + shell: bash + run: python -m pip install pip wheel setuptools + + - name: checks for inputs + shell: bash + run: python "${{ github.action_path }}/src/checks.py" + env: + spec: ${{ inputs.spec }} + upload_exe_with_name: ${{ inputs.upload_exe_with_name }} + + - name: (Set) modified outputs + id: mods + shell: bash + run: python "${{ github.action_path }}/src/mods.py" + env: + spec: ${{ inputs.spec }} + options: ${{ inputs.options }} + spec_options: ${{ inputs.spec_options }} + + - name: Checkout repository + uses: actions/checkout@v4 + with: + clean: ${{ inputs.clean_checkout }} + lfs: ${{ inputs.lfs }} + + - name: (Install) dependencies + if: inputs.requirements != '' + run: python -m pip install -r "${{ inputs.requirements }}" + shell: bash + + - name: (Install) pyinstaller + shell: bash + run: pip install pyinstaller${{ inputs.pyinstaller_ver }} + + - name: (Create) Executable + shell: bash + run: | + pyinstaller \ + --clean \ + --noconfirm \ + --dist ${{ inputs.exe_path }} \ + ${{ steps.mods.outputs.supported_options }} \ + "${{ inputs.spec }}" \ + ${{ steps.mods.outputs.supported_spec_options }} + + echo "✔️ Executable created successfully at _'${{ inputs.exe_path }}'_" >> $GITHUB_STEP_SUMMARY + echo " - Python version used: '${{ inputs.python_ver }}'" >> $GITHUB_STEP_SUMMARY + echo " - Python architecture used: '${{ inputs.python_arch }}'" >> $GITHUB_STEP_SUMMARY + + - name: (Upload) Executable + id: artifact_upload + if: inputs.upload_exe_with_name != '' + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.upload_exe_with_name }} + path: ${{ inputs.exe_path }} + compression-level: ${{ inputs.compression_level }} + + - name: (Upload) generated spec file - if .py + if: endsWith(inputs.spec, '.py') + uses: actions/upload-artifact@v4 + with: + name: Generated spec file for ${{ inputs.upload_exe_with_name }} + path: ${{ steps.mods.outputs.spec_path }} + + - name: If executable upload success + id: exe_uploading + if: steps.artifact_upload.conclusion == 'success' + shell: bash + run: | + echo "✔️ Executable **_(${{ inputs.upload_exe_with_name }})_** uploaded successfully" >> $GITHUB_STEP_SUMMARY + echo "uploaded='true'" >> $GITHUB_OUTPUT + + - name: If executable upload fails + if: failure() && steps.artifact_upload.conclusion == 'failure' + shell: bash + run: | + echo "::warning title=Failed-Upload::\ + Executable couldn't upload. \ + Check available storage at: 'settings > billing > Storage for Actions and Packages'." From 9174af5266ff56e0554afe94bcddafa7f62a9e13 Mon Sep 17 00:00:00 2001 From: tie4453 Date: Mon, 28 Apr 2025 13:28:56 +0800 Subject: [PATCH 04/21] Create pyinstaller --- pyinstaller | 157 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 pyinstaller diff --git a/pyinstaller b/pyinstaller new file mode 100644 index 00000000..710b51b6 --- /dev/null +++ b/pyinstaller @@ -0,0 +1,157 @@ +name: Versatile PyInstaller +author: '@sayyid5416' +description: Customisable GitHub Action to package python scripts into executables for different OS's +branding: + icon: hard-drive + color: yellow + + +inputs: + spec: + description: > + path of your '.py' or '.spec' file. + - This file will be used to create executable. + - If .py: Generated spec file will also be uploaded as artifact + required: true + default: '' + requirements: + description: path of your requirements.txt file + default: '' + options: + description: > + Options to set for pyinstaller command + Ex: options: '--onedir, -F' (seperated by comma and space) + - Supported options: Check readme + default: '' + spec_options: + description: > + Custom parameters for spec file. (won't work with .py spec file) + Ex: spec_options: '--debug' + default: '' + python_ver: + description: specific python version you want to use + default: '3.10' + python_arch: + description: specific python architecture you want to use + default: 'x64' + pyinstaller_ver: + description: specific pyinstaller version you want to use + default: '' + exe_path: + description: Path on runner-os, where generated executable files are stored + default: './dist' + upload_exe_with_name: + description: If passed, uploads executable artifact with this name. Else, artifact won't be uploaded. + default: '' + clean_checkout: + description: 'If true, perform a clean checkout; if false, skip cleaning. Cleaning will remove all existing local files not in the repository during checkout. If you use utilities like pyinstaller-versionfile, set this to false.' + default: true + lfs: + description: Whether to download Git-LFS files (passed to actions/checkout) + default: false + compression_level: + description: 'Level of compression for archive (between 0 and 9). 0 = No compression, 9 = Max compression.' + default: 6 + + +outputs: + executable_path: + description: path on runner-os, where generated executable files are stored + value: ${{ inputs.exe_path }} + is_uploaded: + description: true, if packaged executable has been uploaded as artifact + value: ${{ steps.exe_uploading.outputs.uploaded }} + + + +runs: + using: 'composite' + steps: + + - name: (Install) python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python_ver }} + architecture: ${{ inputs.python_arch }} + + - name: (Install) python dev tools + shell: bash + run: python -m pip install pip wheel setuptools + + - name: checks for inputs + shell: bash + run: python "${{ github.action_path }}/src/checks.py" + env: + spec: ${{ inputs.spec }} + upload_exe_with_name: ${{ inputs.upload_exe_with_name }} + + - name: (Set) modified outputs + id: mods + shell: bash + run: python "${{ github.action_path }}/src/mods.py" + env: + spec: ${{ inputs.spec }} + options: ${{ inputs.options }} + spec_options: ${{ inputs.spec_options }} + + - name: Checkout repository + uses: actions/checkout@v4 + with: + clean: ${{ inputs.clean_checkout }} + lfs: ${{ inputs.lfs }} + + - name: (Install) dependencies + if: inputs.requirements != '' + run: python -m pip install -r "${{ inputs.requirements }}" + shell: bash + + - name: (Install) pyinstaller + shell: bash + run: pip install pyinstaller${{ inputs.pyinstaller_ver }} + + - name: (Create) Executable + shell: bash + run: | + pyinstaller \ + --clean \ + --noconfirm \ + --dist ${{ inputs.exe_path }} \ + ${{ steps.mods.outputs.supported_options }} \ + "${{ inputs.spec }}" \ + ${{ steps.mods.outputs.supported_spec_options }} + + echo "✔️ Executable created successfully at _'${{ inputs.exe_path }}'_" >> $GITHUB_STEP_SUMMARY + echo " - Python version used: '${{ inputs.python_ver }}'" >> $GITHUB_STEP_SUMMARY + echo " - Python architecture used: '${{ inputs.python_arch }}'" >> $GITHUB_STEP_SUMMARY + + - name: (Upload) Executable + id: artifact_upload + if: inputs.upload_exe_with_name != '' + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.upload_exe_with_name }} + path: ${{ inputs.exe_path }} + compression-level: ${{ inputs.compression_level }} + + - name: (Upload) generated spec file - if .py + if: endsWith(inputs.spec, '.py') + uses: actions/upload-artifact@v4 + with: + name: Generated spec file for ${{ inputs.upload_exe_with_name }} + path: ${{ steps.mods.outputs.spec_path }} + + - name: If executable upload success + id: exe_uploading + if: steps.artifact_upload.conclusion == 'success' + shell: bash + run: | + echo "✔️ Executable **_(${{ inputs.upload_exe_with_name }})_** uploaded successfully" >> $GITHUB_STEP_SUMMARY + echo "uploaded='true'" >> $GITHUB_OUTPUT + + - name: If executable upload fails + if: failure() && steps.artifact_upload.conclusion == 'failure' + shell: bash + run: | + echo "::warning title=Failed-Upload::\ + Executable couldn't upload. \ + Check available storage at: 'settings > billing > Storage for Actions and Packages'." From 73edda459d6637ac00a8a38e11b1706fbc16bbb1 Mon Sep 17 00:00:00 2001 From: tie4453 Date: Mon, 28 Apr 2025 16:42:21 +0800 Subject: [PATCH 05/21] Update weather_report.py --- weather_report.py | 339 ++++++++++++++++++++++++++++++---------------- 1 file changed, 226 insertions(+), 113 deletions(-) diff --git a/weather_report.py b/weather_report.py index 3eca4df3..b98e6621 100644 --- a/weather_report.py +++ b/weather_report.py @@ -1,133 +1,246 @@ -# 安装依赖 pip3 install requests html5lib bs4 schedule import os import requests import json from bs4 import BeautifulSoup - -# 从测试号信息获取 -appID = os.environ.get("APP_ID") -appSecret = os.environ.get("APP_SECRET") -# 收信人ID即 用户列表中的微信号 -openId = os.environ.get("OPEN_ID") -# 天气预报模板ID -weather_template_id = os.environ.get("TEMPLATE_ID") +import logging +import datetime + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname)s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) + +# 配置信息 +Configure = { + "APP_ID": os.environ.get("APP_ID"), + "APP_SECRET": os.environ.get("APP_SECRET"), + "OPEN_ID": os.environ.get("OPEN_ID"), + "TEMPLATE_ID": os.environ.get("TEMPLATE_ID"), + "CITY": "吉安" +} def get_weather(my_city): - urls = ["http://www.weather.com.cn/textFC/hb.shtml", + """ + 从中国天气网获取指定城市的天气信息(详细文档:http://www.weather.com.cn) + Args: + my_city (str): 要查询的城市名称 + Returns: + tuple: (城市名, 温度, 天气类型, 风力) 或 None + """ + try: + urls = [ + "http://www.weather.com.cn/textFC/hb.shtml", "http://www.weather.com.cn/textFC/db.shtml", "http://www.weather.com.cn/textFC/hd.shtml", "http://www.weather.com.cn/textFC/hz.shtml", "http://www.weather.com.cn/textFC/hn.shtml", "http://www.weather.com.cn/textFC/xb.shtml", "http://www.weather.com.cn/textFC/xn.shtml" - ] - for url in urls: - resp = requests.get(url) - text = resp.content.decode("utf-8") - soup = BeautifulSoup(text, 'html5lib') - div_conMidtab = soup.find("div", class_="conMidtab") - tables = div_conMidtab.find_all("table") - for table in tables: - trs = table.find_all("tr")[2:] - for index, tr in enumerate(trs): - tds = tr.find_all("td") - # 这里倒着数,因为每个省会的td结构跟其他不一样 - city_td = tds[-8] - this_city = list(city_td.stripped_strings)[0] - if this_city == my_city: - - high_temp_td = tds[-5] - low_temp_td = tds[-2] - weather_type_day_td = tds[-7] - weather_type_night_td = tds[-4] - wind_td_day = tds[-6] - wind_td_day_night = tds[-3] - - high_temp = list(high_temp_td.stripped_strings)[0] - low_temp = list(low_temp_td.stripped_strings)[0] - weather_typ_day = list(weather_type_day_td.stripped_strings)[0] - weather_type_night = list(weather_type_night_td.stripped_strings)[0] - - wind_day = list(wind_td_day.stripped_strings)[0] + list(wind_td_day.stripped_strings)[1] - wind_night = list(wind_td_day_night.stripped_strings)[0] + list(wind_td_day_night.stripped_strings)[1] - - # 如果没有白天的数据就使用夜间的 - temp = f"{low_temp}——{high_temp}摄氏度" if high_temp != "-" else f"{low_temp}摄氏度" - weather_typ = weather_typ_day if weather_typ_day != "-" else weather_type_night - wind = f"{wind_day}" if wind_day != "--" else f"{wind_night}" - return this_city, temp, weather_typ, wind - + ] + + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.3', + 'Referer': 'http://www.weather.com.cn/' + } + + for url in urls: + try: + resp = requests.get(url, headers=headers, timeout=5) + resp.raise_for_status() + text = resp.content.decode("utf-8") + soup = BeautifulSoup(text, 'html5lib') + div_conMidtab = soup.find("div", class_="conMidtab") + if div_conMidtab is None: + continue + tables = div_conMidtab.find_all("table") + for table in tables: + trs = table.find_all("tr")[2:] # 跳过前两行 + for tr in trs: + tds = tr.find_all("td") + city_td = tds[-8] + this_city = list(city_td.stripped_strings)[0] + if this_city == my_city: + high_temp = list(tds[-5].stripped_strings)[0] if tds[-5] else '-' + low_temp = list(tds[-2].stripped_strings)[0] if tds[-2] else '-' + weather_typ_day = list(tds[-7].stripped_strings)[0] if tds[-7] else '-' + weather_type_night = list(tds[-4].stripped_strings)[0] if tds[-4] else '-' + wind_day = list(tds[-6].stripped_strings) if tds[-6] else [] + wind_day = wind_day[0] + wind_day[1] if len(wind_day) > 1 else '' + wind_night = list(tds[-3].stripped_strings) if tds[-3] else [] + wind_night = wind_night[0] + wind_night[1] if len(wind_night) > 1 else '' + + temp = f"{low_temp}——{high_temp}摄氏度" if high_temp != "-" else f"{low_temp}摄氏度" + weather_typ = weather_typ_day if weather_typ_day != "-" else weather_type_night + wind = wind_day if wind_day != "--" else wind_night + + return (this_city, temp, weather_typ, wind) + except requests.exceptions.RequestException as e: + logging.error(f"获取天气数据失败:{e}", exc_info=True) + return None + except Exception as e: + logging.error(f"天气获取逻辑错误:{e}", exc_info=True) + return None def get_access_token(): - # 获取access token的url - url = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={}&secret={}' \ - .format(appID.strip(), appSecret.strip()) - response = requests.get(url).json() - print(response) - access_token = response.get('access_token') - return access_token - + """ + 获取微信公众号access_token + Returns: + str: access_token 或 None + """ + try: + app_id = Configure["APP_ID"] + app_secret = Configure["APP_SECRET"] + if not app_id or not app_secret: + logging.error("未配置APP_ID或APP_SECRET") + return None + url = f'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={app_id}&secret={app_secret}' + response = requests.get(url, timeout=5) + response.raise_for_status() + result = response.json() + if 'errcode' in result and result['errcode'] != 0: + logging.error(f"获取access_token失败:{result['errmsg']}") + return None + return result.get('access_token') + except requests.exceptions.RequestException as e: + logging.error(f"获取access_token失败:{e}", exc_info=True) + return None + except Exception as e: + logging.error(f"获取access_token逻辑错误:{e}", exc_info=True) + return None def get_daily_love(): - # 每日一句情话 - url = "https://api.lovelive.tools/api/SweetNothings/Serialization/Json" - r = requests.get(url) - all_dict = json.loads(r.text) - sentence = all_dict['returnObj'][0] - daily_love = sentence - return daily_love - - -def send_weather(access_token, weather): - # touser 就是 openID - # template_id 就是模板ID - # url 就是点击模板跳转的url - # data就按这种格式写,time和text就是之前{{time.DATA}}中的那个time,value就是你要替换DATA的值 - - import datetime - today = datetime.date.today() - today_str = today.strftime("%Y年%m月%d日") - - body = { - "touser": openId.strip(), - "template_id": weather_template_id.strip(), - "url": "https://weixin.qq.com", - "data": { - "date": { - "value": today_str - }, - "region": { - "value": weather[0] - }, - "weather": { - "value": weather[2] - }, - "temp": { - "value": weather[1] - }, - "wind_dir": { - "value": weather[3] - }, - "today_note": { - "value": get_daily_love() + """ + 获取每日情话 + Returns: + str: 情话内容 + """ + try: + url = "https://api.lovelive.tools/api/SweetNothings/Serialization/Json" + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ', + 'Referer': 'https://api.lovelive.tools/' + } + response = requests.get(url, headers=headers, timeout=5) + response.raise_for_status() + data = response.json() + sentence = data.get('returnObj', [])[0] if data.get('returnObj', []) else "" + daily_love = sentence if sentence else "每天都要开心哦!" + return daily_love + except requests.exceptions.RequestException as e: + logging.error(f"获取情话失败:{e}", exc_info=True) + return "每天都要开心哦!" + except Exception as e: + logging.error(f"情话获取逻辑错误:{e}", exc_info=True) + return "每天都要开心哦!" + +def send_weather(access_token, weather_info): + """ + 发送天气预报 + Args: + access_token (str): 微信公众号access_token + weather_info (tuple): 天气信息(城市名, 温度, 天气类型, 风力) + Returns: + bool: 是否成功发送 + """ + try: + if not weather_info: + logging.error("天气信息为空") + return False + today = datetime.date.today() + today_str = today.strftime("%Y年%m月%d日") + body = { + "touser": Configure["OPEN_ID"], + "template_id": Configure["TEMPLATE_ID"], + "url": "https://weixin.qq.com", + "data": { + "date": { + "value": today_str + }, + "region": { + "value": weather_info[0] + }, + "weather": { + "value": weather_info[2] + }, + "temp": { + "value": weather_info[1] + }, + "wind_dir": { + "value": weather_info[3] + }, + "today_note": { + "value": get_daily_love() + } } } - } - url = 'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={}'.format(access_token) - print(requests.post(url, json.dumps(body)).text) - - - -def weather_report(this_city): - # 1.获取access_token - access_token = get_access_token() - # 2. 获取天气 - weather = get_weather(this_city) - print(f"天气信息: {weather}") - # 3. 发送消息 - send_weather(access_token, weather) - - + if not access_token: + logging.error("access_token为空") + return False + url = f'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={access_token}' + response = requests.post(url, json=body, timeout=10) + response.raise_for_status() + result = response.json() + if result.get('errcode') == 0: + logging.info("天气预报发送成功") + return True + else: + logging.error(f"发送失败:{result.get('errmsg', '未知错误')}") + return False + except requests.exceptions.RequestException as e: + logging.error(f"发送天气预报失败:{e}", exc_info=True) + return False + except Exception as e: + logging.error(f"发送天气预报逻辑错误:{e}", exc_info=True) + return False + +def weather_report(): + """ + 主函数,执行完整的天气报告流程 + """ + try: + logging.info("开始执行天气报告任务") + # 检查配置 + for key, value in Configure.items(): + if not value: + logging.error(f"配置项{key}未设置") + return + + # 1. 获取天气信息 + weather_info = get_weather(Configure["CITY"]) + if not weather_info: + logging.error("无法获取天气信息") + return + logging.info(f"获取到天气信息:{weather_info}") + + # 2. 获取access_token + access_token = get_access_token() + if not access_token: + logging.error("无法获取access_token") + return + + # 3. 发送天气预报 + success = send_weather(access_token, weather_info) + if success: + logging.info("天气预报发送完成") + else: + logging.error("天气预报发送失败") + except Exception as e: + logging.error(f"主程序错误:{e}", exc_info=True) + +def scheduler(): + """ + 定时任务调度函数 + """ + weather_report() + # 使用schedule库可以进行定时任务设置 + # 示例: + # schedule.every().day.at("08:00").do(job) # 每天8点执行 + # while True: + # schedule.run_pending() + # time.sleep(1) if __name__ == '__main__': - weather_report("吉安") + weather_report() + From ea40f2cfb4c8b21717fd3e42447bbe2be6663b9a Mon Sep 17 00:00:00 2001 From: tie4453 Date: Mon, 28 Apr 2025 16:49:17 +0800 Subject: [PATCH 06/21] Update weather_report.py --- weather_report.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/weather_report.py b/weather_report.py index b98e6621..50e4eded 100644 --- a/weather_report.py +++ b/weather_report.py @@ -23,7 +23,7 @@ def get_weather(my_city): """ - 从中国天气网获取指定城市的天气信息(详细文档:http://www.weather.com.cn) + 从中国天气网获取指定城市的天气信息 Args: my_city (str): 要查询的城市名称 Returns: @@ -229,18 +229,5 @@ def weather_report(): except Exception as e: logging.error(f"主程序错误:{e}", exc_info=True) -def scheduler(): - """ - 定时任务调度函数 - """ - weather_report() - # 使用schedule库可以进行定时任务设置 - # 示例: - # schedule.every().day.at("08:00").do(job) # 每天8点执行 - # while True: - # schedule.run_pending() - # time.sleep(1) - if __name__ == '__main__': weather_report() - From 02b70f6b97c691187a3e1217be29908c5e2293c4 Mon Sep 17 00:00:00 2001 From: tie4453 Date: Mon, 28 Apr 2025 16:52:23 +0800 Subject: [PATCH 07/21] Create tianqi --- tianqi | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tianqi diff --git a/tianqi b/tianqi new file mode 100644 index 00000000..3eca4df3 --- /dev/null +++ b/tianqi @@ -0,0 +1,133 @@ +# 安装依赖 pip3 install requests html5lib bs4 schedule +import os +import requests +import json +from bs4 import BeautifulSoup + +# 从测试号信息获取 +appID = os.environ.get("APP_ID") +appSecret = os.environ.get("APP_SECRET") +# 收信人ID即 用户列表中的微信号 +openId = os.environ.get("OPEN_ID") +# 天气预报模板ID +weather_template_id = os.environ.get("TEMPLATE_ID") + +def get_weather(my_city): + urls = ["http://www.weather.com.cn/textFC/hb.shtml", + "http://www.weather.com.cn/textFC/db.shtml", + "http://www.weather.com.cn/textFC/hd.shtml", + "http://www.weather.com.cn/textFC/hz.shtml", + "http://www.weather.com.cn/textFC/hn.shtml", + "http://www.weather.com.cn/textFC/xb.shtml", + "http://www.weather.com.cn/textFC/xn.shtml" + ] + for url in urls: + resp = requests.get(url) + text = resp.content.decode("utf-8") + soup = BeautifulSoup(text, 'html5lib') + div_conMidtab = soup.find("div", class_="conMidtab") + tables = div_conMidtab.find_all("table") + for table in tables: + trs = table.find_all("tr")[2:] + for index, tr in enumerate(trs): + tds = tr.find_all("td") + # 这里倒着数,因为每个省会的td结构跟其他不一样 + city_td = tds[-8] + this_city = list(city_td.stripped_strings)[0] + if this_city == my_city: + + high_temp_td = tds[-5] + low_temp_td = tds[-2] + weather_type_day_td = tds[-7] + weather_type_night_td = tds[-4] + wind_td_day = tds[-6] + wind_td_day_night = tds[-3] + + high_temp = list(high_temp_td.stripped_strings)[0] + low_temp = list(low_temp_td.stripped_strings)[0] + weather_typ_day = list(weather_type_day_td.stripped_strings)[0] + weather_type_night = list(weather_type_night_td.stripped_strings)[0] + + wind_day = list(wind_td_day.stripped_strings)[0] + list(wind_td_day.stripped_strings)[1] + wind_night = list(wind_td_day_night.stripped_strings)[0] + list(wind_td_day_night.stripped_strings)[1] + + # 如果没有白天的数据就使用夜间的 + temp = f"{low_temp}——{high_temp}摄氏度" if high_temp != "-" else f"{low_temp}摄氏度" + weather_typ = weather_typ_day if weather_typ_day != "-" else weather_type_night + wind = f"{wind_day}" if wind_day != "--" else f"{wind_night}" + return this_city, temp, weather_typ, wind + + +def get_access_token(): + # 获取access token的url + url = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={}&secret={}' \ + .format(appID.strip(), appSecret.strip()) + response = requests.get(url).json() + print(response) + access_token = response.get('access_token') + return access_token + + +def get_daily_love(): + # 每日一句情话 + url = "https://api.lovelive.tools/api/SweetNothings/Serialization/Json" + r = requests.get(url) + all_dict = json.loads(r.text) + sentence = all_dict['returnObj'][0] + daily_love = sentence + return daily_love + + +def send_weather(access_token, weather): + # touser 就是 openID + # template_id 就是模板ID + # url 就是点击模板跳转的url + # data就按这种格式写,time和text就是之前{{time.DATA}}中的那个time,value就是你要替换DATA的值 + + import datetime + today = datetime.date.today() + today_str = today.strftime("%Y年%m月%d日") + + body = { + "touser": openId.strip(), + "template_id": weather_template_id.strip(), + "url": "https://weixin.qq.com", + "data": { + "date": { + "value": today_str + }, + "region": { + "value": weather[0] + }, + "weather": { + "value": weather[2] + }, + "temp": { + "value": weather[1] + }, + "wind_dir": { + "value": weather[3] + }, + "today_note": { + "value": get_daily_love() + } + } + } + url = 'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={}'.format(access_token) + print(requests.post(url, json.dumps(body)).text) + + + +def weather_report(this_city): + # 1.获取access_token + access_token = get_access_token() + # 2. 获取天气 + weather = get_weather(this_city) + print(f"天气信息: {weather}") + # 3. 发送消息 + send_weather(access_token, weather) + + + +if __name__ == '__main__': + weather_report("吉安") From 65538e8b47373bacc55302ae75e811e52f6a6009 Mon Sep 17 00:00:00 2001 From: tie4453 Date: Thu, 14 Aug 2025 12:19:42 +0800 Subject: [PATCH 08/21] Create 1-1 --- .github/workflows/1-1 | 157 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 .github/workflows/1-1 diff --git a/.github/workflows/1-1 b/.github/workflows/1-1 new file mode 100644 index 00000000..710b51b6 --- /dev/null +++ b/.github/workflows/1-1 @@ -0,0 +1,157 @@ +name: Versatile PyInstaller +author: '@sayyid5416' +description: Customisable GitHub Action to package python scripts into executables for different OS's +branding: + icon: hard-drive + color: yellow + + +inputs: + spec: + description: > + path of your '.py' or '.spec' file. + - This file will be used to create executable. + - If .py: Generated spec file will also be uploaded as artifact + required: true + default: '' + requirements: + description: path of your requirements.txt file + default: '' + options: + description: > + Options to set for pyinstaller command + Ex: options: '--onedir, -F' (seperated by comma and space) + - Supported options: Check readme + default: '' + spec_options: + description: > + Custom parameters for spec file. (won't work with .py spec file) + Ex: spec_options: '--debug' + default: '' + python_ver: + description: specific python version you want to use + default: '3.10' + python_arch: + description: specific python architecture you want to use + default: 'x64' + pyinstaller_ver: + description: specific pyinstaller version you want to use + default: '' + exe_path: + description: Path on runner-os, where generated executable files are stored + default: './dist' + upload_exe_with_name: + description: If passed, uploads executable artifact with this name. Else, artifact won't be uploaded. + default: '' + clean_checkout: + description: 'If true, perform a clean checkout; if false, skip cleaning. Cleaning will remove all existing local files not in the repository during checkout. If you use utilities like pyinstaller-versionfile, set this to false.' + default: true + lfs: + description: Whether to download Git-LFS files (passed to actions/checkout) + default: false + compression_level: + description: 'Level of compression for archive (between 0 and 9). 0 = No compression, 9 = Max compression.' + default: 6 + + +outputs: + executable_path: + description: path on runner-os, where generated executable files are stored + value: ${{ inputs.exe_path }} + is_uploaded: + description: true, if packaged executable has been uploaded as artifact + value: ${{ steps.exe_uploading.outputs.uploaded }} + + + +runs: + using: 'composite' + steps: + + - name: (Install) python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python_ver }} + architecture: ${{ inputs.python_arch }} + + - name: (Install) python dev tools + shell: bash + run: python -m pip install pip wheel setuptools + + - name: checks for inputs + shell: bash + run: python "${{ github.action_path }}/src/checks.py" + env: + spec: ${{ inputs.spec }} + upload_exe_with_name: ${{ inputs.upload_exe_with_name }} + + - name: (Set) modified outputs + id: mods + shell: bash + run: python "${{ github.action_path }}/src/mods.py" + env: + spec: ${{ inputs.spec }} + options: ${{ inputs.options }} + spec_options: ${{ inputs.spec_options }} + + - name: Checkout repository + uses: actions/checkout@v4 + with: + clean: ${{ inputs.clean_checkout }} + lfs: ${{ inputs.lfs }} + + - name: (Install) dependencies + if: inputs.requirements != '' + run: python -m pip install -r "${{ inputs.requirements }}" + shell: bash + + - name: (Install) pyinstaller + shell: bash + run: pip install pyinstaller${{ inputs.pyinstaller_ver }} + + - name: (Create) Executable + shell: bash + run: | + pyinstaller \ + --clean \ + --noconfirm \ + --dist ${{ inputs.exe_path }} \ + ${{ steps.mods.outputs.supported_options }} \ + "${{ inputs.spec }}" \ + ${{ steps.mods.outputs.supported_spec_options }} + + echo "✔️ Executable created successfully at _'${{ inputs.exe_path }}'_" >> $GITHUB_STEP_SUMMARY + echo " - Python version used: '${{ inputs.python_ver }}'" >> $GITHUB_STEP_SUMMARY + echo " - Python architecture used: '${{ inputs.python_arch }}'" >> $GITHUB_STEP_SUMMARY + + - name: (Upload) Executable + id: artifact_upload + if: inputs.upload_exe_with_name != '' + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.upload_exe_with_name }} + path: ${{ inputs.exe_path }} + compression-level: ${{ inputs.compression_level }} + + - name: (Upload) generated spec file - if .py + if: endsWith(inputs.spec, '.py') + uses: actions/upload-artifact@v4 + with: + name: Generated spec file for ${{ inputs.upload_exe_with_name }} + path: ${{ steps.mods.outputs.spec_path }} + + - name: If executable upload success + id: exe_uploading + if: steps.artifact_upload.conclusion == 'success' + shell: bash + run: | + echo "✔️ Executable **_(${{ inputs.upload_exe_with_name }})_** uploaded successfully" >> $GITHUB_STEP_SUMMARY + echo "uploaded='true'" >> $GITHUB_OUTPUT + + - name: If executable upload fails + if: failure() && steps.artifact_upload.conclusion == 'failure' + shell: bash + run: | + echo "::warning title=Failed-Upload::\ + Executable couldn't upload. \ + Check available storage at: 'settings > billing > Storage for Actions and Packages'." From 223ceba3699b76d862f685444d7e714107308100 Mon Sep 17 00:00:00 2001 From: tie4453 Date: Fri, 19 Dec 2025 22:42:34 +0800 Subject: [PATCH 09/21] Update weather_report.py --- weather_report.py | 643 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 453 insertions(+), 190 deletions(-) diff --git a/weather_report.py b/weather_report.py index 50e4eded..81a06ac7 100644 --- a/weather_report.py +++ b/weather_report.py @@ -4,230 +4,493 @@ from bs4 import BeautifulSoup import logging import datetime +from typing import Optional, Tuple, Dict, Any +from dataclasses import dataclass +import time +from urllib.parse import quote -# 配置日志 -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s %(levelname)s %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) +# 配置日志 - 改进为更专业的配置 +def setup_logging(): + """设置日志配置""" + logger = logging.getLogger(__name__) + logger.setLevel(logging.INFO) + + # 避免重复添加handler + if not logger.handlers: + # 控制台处理器 + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + + # 文件处理器(可选) + file_handler = logging.FileHandler( + filename=f'weather_report_{datetime.date.today().strftime("%Y%m%d")}.log', + encoding='utf-8' + ) + file_handler.setLevel(logging.INFO) + + # 格式化器 + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + console_handler.setFormatter(formatter) + file_handler.setFormatter(formatter) + + logger.addHandler(console_handler) + logger.addHandler(file_handler) + + return logger -# 配置信息 -Configure = { - "APP_ID": os.environ.get("APP_ID"), - "APP_SECRET": os.environ.get("APP_SECRET"), - "OPEN_ID": os.environ.get("OPEN_ID"), - "TEMPLATE_ID": os.environ.get("TEMPLATE_ID"), - "CITY": "吉安" -} +logger = setup_logging() -def get_weather(my_city): - """ - 从中国天气网获取指定城市的天气信息 - Args: - my_city (str): 要查询的城市名称 - Returns: - tuple: (城市名, 温度, 天气类型, 风力) 或 None - """ - try: - urls = [ - "http://www.weather.com.cn/textFC/hb.shtml", - "http://www.weather.com.cn/textFC/db.shtml", - "http://www.weather.com.cn/textFC/hd.shtml", - "http://www.weather.com.cn/textFC/hz.shtml", - "http://www.weather.com.cn/textFC/hn.shtml", - "http://www.weather.com.cn/textFC/xb.shtml", - "http://www.weather.com.cn/textFC/xn.shtml" - ] - - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.3', - 'Referer': 'http://www.weather.com.cn/' - } +# 使用数据类存储配置 +@dataclass +class Config: + """配置信息类""" + APP_ID: str = os.environ.get("APP_ID", "") + APP_SECRET: str = os.environ.get("APP_SECRET", "") + OPEN_ID: str = os.environ.get("OPEN_ID", "") + TEMPLATE_ID: str = os.environ.get("TEMPLATE_ID", "") + CITY: str = os.environ.get("CITY", "吉安") + REQUEST_TIMEOUT: int = 10 + MAX_RETRIES: int = 3 + RETRY_DELAY: float = 1.0 + + def validate(self) -> bool: + """验证配置是否完整""" + required_fields = ["APP_ID", "APP_SECRET", "OPEN_ID", "TEMPLATE_ID"] + missing_fields = [] + + for field in required_fields: + if not getattr(self, field): + missing_fields.append(field) + + if missing_fields: + logger.error(f"缺少必要的配置项: {', '.join(missing_fields)}") + return False + + if not self.CITY: + logger.error("未配置城市信息") + return False + + return True + +# 初始化配置 +config = Config() + +class WeatherFetcher: + """天气信息获取器""" + + # 天气网站URL列表 + WEATHER_URLS = [ + "http://www.weather.com.cn/textFC/hb.shtml", # 华北 + "http://www.weather.com.cn/textFC/db.shtml", # 东北 + "http://www.weather.com.cn/textFC/hd.shtml", # 华东 + "http://www.weather.com.cn/textFC/hz.shtml", # 华中 + "http://www.weather.com.cn/textFC/hn.shtml", # 华南 + "http://www.weather.com.cn/textFC/xb.shtml", # 西北 + "http://www.weather.com.cn/textFC/xn.shtml", # 西南 + ] + + HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + } + + @staticmethod + def _make_request(url: str, max_retries: int = 3) -> Optional[str]: + """发送HTTP请求并返回响应内容""" + for attempt in range(max_retries): + try: + response = requests.get( + url, + headers=WeatherFetcher.HEADERS, + timeout=config.REQUEST_TIMEOUT + ) + response.raise_for_status() + response.encoding = 'utf-8' # 确保编码正确 + return response.text + except requests.exceptions.Timeout: + logger.warning(f"请求超时 ({attempt + 1}/{max_retries}): {url}") + if attempt < max_retries - 1: + time.sleep(config.RETRY_DELAY * (attempt + 1)) + except requests.exceptions.RequestException as e: + logger.error(f"请求失败: {url}, 错误: {e}") + if attempt < max_retries - 1: + time.sleep(config.RETRY_DELAY) + else: + return None + return None + + @classmethod + def get_weather(cls, city: str) -> Optional[Tuple[str, str, str, str]]: + """ + 从中国天气网获取指定城市的天气信息 + + Args: + city (str): 要查询的城市名称 + + Returns: + Optional[Tuple]: (城市名, 温度, 天气类型, 风力) 或 None + """ + logger.info(f"开始获取{city}的天气信息") - for url in urls: + for url in cls.WEATHER_URLS: try: - resp = requests.get(url, headers=headers, timeout=5) - resp.raise_for_status() - text = resp.content.decode("utf-8") - soup = BeautifulSoup(text, 'html5lib') + logger.debug(f"尝试从 {url} 获取天气数据") + html_content = cls._make_request(url) + if not html_content: + continue + + soup = BeautifulSoup(html_content, 'html.parser') div_conMidtab = soup.find("div", class_="conMidtab") - if div_conMidtab is None: + + if not div_conMidtab: + logger.debug(f"{url} 中未找到天气数据") continue + tables = div_conMidtab.find_all("table") + found_city = False + for table in tables: - trs = table.find_all("tr")[2:] # 跳过前两行 + trs = table.find_all("tr")[2:] # 跳过前两行标题行 for tr in trs: - tds = tr.find_all("td") - city_td = tds[-8] - this_city = list(city_td.stripped_strings)[0] - if this_city == my_city: - high_temp = list(tds[-5].stripped_strings)[0] if tds[-5] else '-' - low_temp = list(tds[-2].stripped_strings)[0] if tds[-2] else '-' - weather_typ_day = list(tds[-7].stripped_strings)[0] if tds[-7] else '-' - weather_type_night = list(tds[-4].stripped_strings)[0] if tds[-4] else '-' - wind_day = list(tds[-6].stripped_strings) if tds[-6] else [] - wind_day = wind_day[0] + wind_day[1] if len(wind_day) > 1 else '' - wind_night = list(tds[-3].stripped_strings) if tds[-3] else [] - wind_night = wind_night[0] + wind_night[1] if len(wind_night) > 1 else '' - - temp = f"{low_temp}——{high_temp}摄氏度" if high_temp != "-" else f"{low_temp}摄氏度" - weather_typ = weather_typ_day if weather_typ_day != "-" else weather_type_night - wind = wind_day if wind_day != "--" else wind_night - - return (this_city, temp, weather_typ, wind) - except requests.exceptions.RequestException as e: - logging.error(f"获取天气数据失败:{e}", exc_info=True) - return None - except Exception as e: - logging.error(f"天气获取逻辑错误:{e}", exc_info=True) + try: + tds = tr.find_all("td") + if len(tds) < 8: # 确保有足够的列 + continue + + city_td = tds[-8] + this_city = next(city_td.stripped_strings, "") + + # 支持简写匹配(比如"北京"匹配"北京市") + if city in this_city or this_city.startswith(city): + # 提取天气信息 + high_temp = next(tds[-5].stripped_strings, "-") + low_temp = next(tds[-2].stripped_strings, "-") + weather_day = next(tds[-7].stripped_strings, "-") + weather_night = next(tds[-4].stripped_strings, "-") + + # 提取风向风速 + wind_day_td = tds[-6] + wind_day_parts = list(wind_day_td.stripped_strings) + wind_day = "".join(wind_day_parts[:2]) if len(wind_day_parts) >= 2 else "" + + wind_night_td = tds[-3] + wind_night_parts = list(wind_night_td.stripped_strings) + wind_night = "".join(wind_night_parts[:2]) if len(wind_night_parts) >= 2 else "" + + # 格式化输出 + if high_temp != "-" and low_temp != "-": + temperature = f"{low_temp}~{high_temp}℃" + else: + temperature = f"{low_temp if low_temp != '-' else '未知'}℃" + + weather_type = weather_day if weather_day != "-" else weather_night + wind = wind_day if wind_day else wind_night + + logger.info(f"成功获取{city}天气: {temperature}, {weather_type}, {wind}") + return (this_city, temperature, weather_type, wind) + + except (IndexError, StopIteration, AttributeError) as e: + logger.debug(f"解析表格行时出错: {e}") + continue + + if found_city: + break + + except Exception as e: + logger.error(f"处理 {url} 时出错: {e}", exc_info=True) + continue + + logger.error(f"在所有天气页面中都未找到城市: {city}") return None -def get_access_token(): - """ - 获取微信公众号access_token - Returns: - str: access_token 或 None - """ - try: - app_id = Configure["APP_ID"] - app_secret = Configure["APP_SECRET"] - if not app_id or not app_secret: - logging.error("未配置APP_ID或APP_SECRET") +class WeChatAPI: + """微信API接口类""" + + BASE_URL = "https://api.weixin.qq.com/cgi-bin" + + @staticmethod + def get_access_token() -> Optional[str]: + """ + 获取微信公众号access_token + + Returns: + str: access_token 或 None + """ + try: + if not config.APP_ID or not config.APP_SECRET: + logger.error("APP_ID 或 APP_SECRET 未配置") + return None + + url = f"{WeChatAPI.BASE_URL}/token" + params = { + 'grant_type': 'client_credential', + 'appid': config.APP_ID, + 'secret': config.APP_SECRET + } + + response = requests.get(url, params=params, timeout=config.REQUEST_TIMEOUT) + response.raise_for_status() + result = response.json() + + if 'access_token' in result: + logger.info("成功获取access_token") + return result['access_token'] + else: + error_msg = result.get('errmsg', '未知错误') + logger.error(f"获取access_token失败: {error_msg}") + return None + + except requests.exceptions.RequestException as e: + logger.error(f"请求access_token失败: {e}", exc_info=True) return None - url = f'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={app_id}&secret={app_secret}' - response = requests.get(url, timeout=5) - response.raise_for_status() - result = response.json() - if 'errcode' in result and result['errcode'] != 0: - logging.error(f"获取access_token失败:{result['errmsg']}") + except json.JSONDecodeError as e: + logger.error(f"解析access_token响应失败: {e}", exc_info=True) return None - return result.get('access_token') - except requests.exceptions.RequestException as e: - logging.error(f"获取access_token失败:{e}", exc_info=True) - return None - except Exception as e: - logging.error(f"获取access_token逻辑错误:{e}", exc_info=True) - return None + except Exception as e: + logger.error(f"获取access_token时发生未知错误: {e}", exc_info=True) + return None + + @staticmethod + def send_template_message(access_token: str, template_data: Dict[str, Any]) -> bool: + """ + 发送模板消息 + + Args: + access_token (str): 微信access_token + template_data (Dict): 模板消息数据 + + Returns: + bool: 是否发送成功 + """ + try: + url = f"{WeChatAPI.BASE_URL}/message/template/send" + params = {'access_token': access_token} + + response = requests.post( + url, + params=params, + json=template_data, + timeout=config.REQUEST_TIMEOUT + ) + response.raise_for_status() + result = response.json() + + if result.get('errcode') == 0: + logger.info("模板消息发送成功") + return True + else: + error_msg = result.get('errmsg', '未知错误') + logger.error(f"发送模板消息失败: {error_msg}") + return False + + except requests.exceptions.RequestException as e: + logger.error(f"发送模板消息请求失败: {e}", exc_info=True) + return False + except Exception as e: + logger.error(f"发送模板消息时发生未知错误: {e}", exc_info=True) + return False -def get_daily_love(): - """ - 获取每日情话 - Returns: - str: 情话内容 - """ - try: - url = "https://api.lovelive.tools/api/SweetNothings/Serialization/Json" - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ', - 'Referer': 'https://api.lovelive.tools/' +class DailyInspiration: + """每日激励语获取类""" + + APIS = [ + { + 'name': 'lovelive', + 'url': 'https://api.lovelive.tools/api/SweetNothings/Serialization/Json', + 'headers': { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'application/json' + }, + 'parser': lambda data: data.get('returnObj', [])[0] if data.get('returnObj', []) else "" + }, + { + 'name': 'fallback', + 'url': None, + 'parser': lambda data: get_fallback_inspiration() } - response = requests.get(url, headers=headers, timeout=5) - response.raise_for_status() - data = response.json() - sentence = data.get('returnObj', [])[0] if data.get('returnObj', []) else "" - daily_love = sentence if sentence else "每天都要开心哦!" - return daily_love - except requests.exceptions.RequestException as e: - logging.error(f"获取情话失败:{e}", exc_info=True) - return "每天都要开心哦!" - except Exception as e: - logging.error(f"情话获取逻辑错误:{e}", exc_info=True) - return "每天都要开心哦!" + ] + + @staticmethod + def get_inspiration() -> str: + """ + 获取每日激励语 + + Returns: + str: 激励语内容 + """ + for api in DailyInspiration.APIS: + try: + if api['url'] is None: + return api['parser'](None) + + response = requests.get( + api['url'], + headers=api['headers'], + timeout=config.REQUEST_TIMEOUT + ) + response.raise_for_status() + data = response.json() + + sentence = api['parser'](data) + if sentence and len(sentence) > 0: + logger.debug(f"从{api['name']}获取激励语成功") + return sentence.strip() + + except Exception as e: + logger.debug(f"从{api['name']}获取激励语失败: {e}") + continue + + # 所有API都失败时使用备用 + return get_fallback_inspiration() + +def get_fallback_inspiration() -> str: + """获取备用激励语""" + inspirations = [ + "每一天都是新的开始,加油!", + "保持微笑,好运自然来!", + "今天也要元气满满哦!", + "愿你的一天充满阳光和欢笑!", + "好事总会发生在下个转弯!", + "保持热爱,奔赴山海!", + "今天是你余生中最年轻的一天!", + "坚持就是胜利!", + "心若向阳,无畏悲伤!", + "每天都要进步一点点!" + ] + + # 使用日期作为种子,确保每天的消息相同(可选) + today = datetime.date.today() + seed = today.year * 10000 + today.month * 100 + today.day + index = seed % len(inspirations) + + return inspirations[index] -def send_weather(access_token, weather_info): +def create_template_data(weather_info: Tuple[str, str, str, str]) -> Dict[str, Any]: """ - 发送天气预报 + 创建微信模板消息数据 + Args: - access_token (str): 微信公众号access_token - weather_info (tuple): 天气信息(城市名, 温度, 天气类型, 风力) + weather_info (Tuple): 天气信息 + Returns: - bool: 是否成功发送 + Dict: 模板消息数据 """ - try: - if not weather_info: - logging.error("天气信息为空") - return False - today = datetime.date.today() - today_str = today.strftime("%Y年%m月%d日") - body = { - "touser": Configure["OPEN_ID"], - "template_id": Configure["TEMPLATE_ID"], - "url": "https://weixin.qq.com", - "data": { - "date": { - "value": today_str - }, - "region": { - "value": weather_info[0] - }, - "weather": { - "value": weather_info[2] - }, - "temp": { - "value": weather_info[1] - }, - "wind_dir": { - "value": weather_info[3] - }, - "today_note": { - "value": get_daily_love() - } + today = datetime.date.today() + today_str = today.strftime("%Y年%m月%d日") + weekday = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"][today.weekday()] + + city, temperature, weather_type, wind = weather_info + + return { + "touser": config.OPEN_ID, + "template_id": config.TEMPLATE_ID, + "url": "https://mp.weixin.qq.com", + "data": { + "date": { + "value": f"{today_str} {weekday}", + "color": "#173177" + }, + "region": { + "value": city, + "color": "#173177" + }, + "weather": { + "value": weather_type, + "color": "#173177" + }, + "temp": { + "value": temperature, + "color": "#FF0000" # 温度用红色突出 + }, + "wind_dir": { + "value": wind if wind else "微风", + "color": "#173177" + }, + "today_note": { + "value": DailyInspiration.get_inspiration(), + "color": "#FF69B4" # 温馨的粉色 + }, + "update_time": { + "value": datetime.datetime.now().strftime("%H:%M:%S"), + "color": "#808080" } } - if not access_token: - logging.error("access_token为空") - return False - url = f'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={access_token}' - response = requests.post(url, json=body, timeout=10) - response.raise_for_status() - result = response.json() - if result.get('errcode') == 0: - logging.info("天气预报发送成功") - return True - else: - logging.error(f"发送失败:{result.get('errmsg', '未知错误')}") - return False - except requests.exceptions.RequestException as e: - logging.error(f"发送天气预报失败:{e}", exc_info=True) - return False - except Exception as e: - logging.error(f"发送天气预报逻辑错误:{e}", exc_info=True) - return False + } def weather_report(): """ 主函数,执行完整的天气报告流程 """ try: - logging.info("开始执行天气报告任务") - # 检查配置 - for key, value in Configure.items(): - if not value: - logging.error(f"配置项{key}未设置") - return - - # 1. 获取天气信息 - weather_info = get_weather(Configure["CITY"]) + logger.info("=" * 50) + logger.info("开始执行天气报告任务") + + # 1. 验证配置 + if not config.validate(): + logger.error("配置验证失败,任务终止") + return False + + logger.info(f"配置验证通过,目标城市: {config.CITY}") + + # 2. 获取天气信息 + weather_info = WeatherFetcher.get_weather(config.CITY) if not weather_info: - logging.error("无法获取天气信息") - return - logging.info(f"获取到天气信息:{weather_info}") - - # 2. 获取access_token - access_token = get_access_token() + logger.error("无法获取天气信息,任务终止") + return False + + logger.info(f"成功获取天气信息: {weather_info}") + + # 3. 获取access_token + access_token = WeChatAPI.get_access_token() if not access_token: - logging.error("无法获取access_token") - return - - # 3. 发送天气预报 - success = send_weather(access_token, weather_info) + logger.error("无法获取access_token,任务终止") + return False + + # 4. 准备并发送模板消息 + template_data = create_template_data(weather_info) + success = WeChatAPI.send_template_message(access_token, template_data) + if success: - logging.info("天气预报发送完成") + logger.info("天气报告任务执行成功!") else: - logging.error("天气预报发送失败") + logger.error("天气报告任务执行失败") + + return success + except Exception as e: - logging.error(f"主程序错误:{e}", exc_info=True) + logger.error(f"天气报告任务执行过程中发生错误: {e}", exc_info=True) + return False + finally: + logger.info("天气报告任务执行结束") + logger.info("=" * 50) if __name__ == '__main__': + # 可以添加命令行参数支持 + import argparse + + parser = argparse.ArgumentParser(description='微信天气报告机器人') + parser.add_argument('--city', type=str, help='指定城市名称', default=None) + parser.add_argument('--debug', action='store_true', help='启用调试模式') + + args = parser.parse_args() + + # 如果命令行指定了城市,则覆盖配置 + if args.city: + config.CITY = args.city + + # 设置调试模式 + if args.debug: + logger.setLevel(logging.DEBUG) + for handler in logger.handlers: + handler.setLevel(logging.DEBUG) + + # 执行天气报告 weather_report() From 77f62ef387d3262a7f93c96424c9a77128796097 Mon Sep 17 00:00:00 2001 From: tie4453 Date: Fri, 19 Dec 2025 22:56:31 +0800 Subject: [PATCH 10/21] Update weather_report.py --- weather_report.py | 1135 +++++++++++++++++++++++++++++---------------- 1 file changed, 739 insertions(+), 396 deletions(-) diff --git a/weather_report.py b/weather_report.py index 81a06ac7..f416724e 100644 --- a/weather_report.py +++ b/weather_report.py @@ -4,47 +4,21 @@ from bs4 import BeautifulSoup import logging import datetime -from typing import Optional, Tuple, Dict, Any +from typing import Optional, Tuple, Dict, Any, List from dataclasses import dataclass import time -from urllib.parse import quote +import re +from enum import Enum -# 配置日志 - 改进为更专业的配置 -def setup_logging(): - """设置日志配置""" - logger = logging.getLogger(__name__) - logger.setLevel(logging.INFO) - - # 避免重复添加handler - if not logger.handlers: - # 控制台处理器 - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.INFO) - - # 文件处理器(可选) - file_handler = logging.FileHandler( - filename=f'weather_report_{datetime.date.today().strftime("%Y%m%d")}.log', - encoding='utf-8' - ) - file_handler.setLevel(logging.INFO) - - # 格式化器 - formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - - console_handler.setFormatter(formatter) - file_handler.setFormatter(formatter) - - logger.addHandler(console_handler) - logger.addHandler(file_handler) - - return logger +# 设置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) -logger = setup_logging() - -# 使用数据类存储配置 +# 配置信息 @dataclass class Config: """配置信息类""" @@ -60,11 +34,7 @@ class Config: def validate(self) -> bool: """验证配置是否完整""" required_fields = ["APP_ID", "APP_SECRET", "OPEN_ID", "TEMPLATE_ID"] - missing_fields = [] - - for field in required_fields: - if not getattr(self, field): - missing_fields.append(field) + missing_fields = [field for field in required_fields if not getattr(self, field)] if missing_fields: logger.error(f"缺少必要的配置项: {', '.join(missing_fields)}") @@ -76,421 +46,794 @@ def validate(self) -> bool: return True -# 初始化配置 config = Config() -class WeatherFetcher: - """天气信息获取器""" - - # 天气网站URL列表 - WEATHER_URLS = [ - "http://www.weather.com.cn/textFC/hb.shtml", # 华北 - "http://www.weather.com.cn/textFC/db.shtml", # 东北 - "http://www.weather.com.cn/textFC/hd.shtml", # 华东 - "http://www.weather.com.cn/textFC/hz.shtml", # 华中 - "http://www.weather.com.cn/textFC/hn.shtml", # 华南 - "http://www.weather.com.cn/textFC/xb.shtml", # 西北 - "http://www.weather.com.cn/textFC/xn.shtml", # 西南 - ] +# 天气信息数据类 +@dataclass +class WeatherInfo: + """天气信息数据类""" + city: str + date: str + week: str + temperature: str # 温度范围 + current_temp: str # 当前温度 + weather: str # 天气状况 + weather_desc: str # 天气描述 + wind_direction: str # 风向 + wind_force: str # 风力等级 + wind_speed: str # 风速 + humidity: str # 湿度 + pressure: str # 气压 + visibility: str # 能见度 + uv_index: str # 紫外线指数 + uv_desc: str # 紫外线描述 + aqi: str # 空气质量指数 + aqi_desc: str # 空气质量描述 + comfort: str # 舒适度指数 + dressing: str # 穿衣指数 + car_washing: str # 洗车指数 + cold_risk: str # 感冒风险 + sunrise: str # 日出时间 + sunset: str # 日落时间 + update_time: str # 更新时间 - HEADERS = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', - 'Accept-Encoding': 'gzip, deflate', - 'Connection': 'keep-alive', - 'Upgrade-Insecure-Requests': '1', - } + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + "city": self.city, + "date": self.date, + "week": self.week, + "temperature": self.temperature, + "current_temp": self.current_temp, + "weather": self.weather, + "weather_desc": self.weather_desc, + "wind_direction": self.wind_direction, + "wind_force": self.wind_force, + "wind_speed": self.wind_speed, + "humidity": self.humidity, + "pressure": self.pressure, + "visibility": self.visibility, + "uv_index": self.uv_index, + "uv_desc": self.uv_desc, + "aqi": self.aqi, + "aqi_desc": self.aqi_desc, + "comfort": self.comfort, + "dressing": self.dressing, + "car_washing": self.car_washing, + "cold_risk": self.cold_risk, + "sunrise": self.sunrise, + "sunset": self.sunset, + "update_time": self.update_time + } + +class WeatherService: + """天气服务类""" - @staticmethod - def _make_request(url: str, max_retries: int = 3) -> Optional[str]: - """发送HTTP请求并返回响应内容""" - for attempt in range(max_retries): - try: - response = requests.get( - url, - headers=WeatherFetcher.HEADERS, - timeout=config.REQUEST_TIMEOUT - ) - response.raise_for_status() - response.encoding = 'utf-8' # 确保编码正确 - return response.text - except requests.exceptions.Timeout: - logger.warning(f"请求超时 ({attempt + 1}/{max_retries}): {url}") - if attempt < max_retries - 1: - time.sleep(config.RETRY_DELAY * (attempt + 1)) - except requests.exceptions.RequestException as e: - logger.error(f"请求失败: {url}, 错误: {e}") - if attempt < max_retries - 1: - time.sleep(config.RETRY_DELAY) - else: - return None - return None + def __init__(self): + self.headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate', + } + + # 省份简称映射(用于搜索) + self.province_short = { + '北京': 'bj', '上海': 'sh', '天津': 'tj', '重庆': 'cq', + '河北': 'hb', '山西': 'sx', '辽宁': 'ln', '吉林': 'jl', + '黑龙江': 'hlj', '江苏': 'js', '浙江': 'zj', '安徽': 'ah', + '福建': 'fj', '江西': 'jx', '山东': 'sd', '河南': 'ha', + '湖北': 'hb', '湖南': 'hn', '广东': 'gd', '海南': 'hn', + '四川': 'sc', '贵州': 'gz', '云南': 'yn', '陕西': 'sn', + '甘肃': 'gs', '青海': 'qh', '台湾': 'tw', '内蒙古': 'nm', + '广西': 'gx', '西藏': 'xz', '宁夏': 'nx', '新疆': 'xj', + '香港': 'hk', '澳门': 'mo' + } - @classmethod - def get_weather(cls, city: str) -> Optional[Tuple[str, str, str, str]]: + def get_weather_by_api(self, city: str) -> Optional[WeatherInfo]: """ - 从中国天气网获取指定城市的天气信息 - - Args: - city (str): 要查询的城市名称 - - Returns: - Optional[Tuple]: (城市名, 温度, 天气类型, 风力) 或 None + 通过天气API获取详细天气信息(使用心知天气API示例,需要自行申请key) + 注意:这里使用免费API,实际使用时需要注册获取API_KEY """ - logger.info(f"开始获取{city}的天气信息") - - for url in cls.WEATHER_URLS: - try: - logger.debug(f"尝试从 {url} 获取天气数据") - html_content = cls._make_request(url) - if not html_content: - continue + try: + # 这里使用公开的API,实际使用时建议使用正规天气API服务 + # 示例:心知天气API + api_key = os.environ.get("WEATHER_API_KEY", "your_api_key_here") + + # 获取城市ID(需要先调用城市搜索API) + city_search_url = f"https://api.seniverse.com/v3/location/search.json?key={api_key}&q={city}" + city_response = requests.get(city_search_url, timeout=config.REQUEST_TIMEOUT) + + if city_response.status_code == 200: + city_data = city_response.json() + if city_data and len(city_data) > 0: + city_id = city_data[0]['id'] - soup = BeautifulSoup(html_content, 'html.parser') - div_conMidtab = soup.find("div", class_="conMidtab") - - if not div_conMidtab: - logger.debug(f"{url} 中未找到天气数据") - continue + # 获取实时天气 + weather_url = f"https://api.seniverse.com/v3/weather/now.json?key={api_key}&location={city_id}&language=zh-Hans&unit=c" + weather_response = requests.get(weather_url, timeout=config.REQUEST_TIMEOUT) - tables = div_conMidtab.find_all("table") - found_city = False - - for table in tables: - trs = table.find_all("tr")[2:] # 跳过前两行标题行 - for tr in trs: - try: - tds = tr.find_all("td") - if len(tds) < 8: # 确保有足够的列 - continue - - city_td = tds[-8] - this_city = next(city_td.stripped_strings, "") - - # 支持简写匹配(比如"北京"匹配"北京市") - if city in this_city or this_city.startswith(city): - # 提取天气信息 - high_temp = next(tds[-5].stripped_strings, "-") - low_temp = next(tds[-2].stripped_strings, "-") - weather_day = next(tds[-7].stripped_strings, "-") - weather_night = next(tds[-4].stripped_strings, "-") - - # 提取风向风速 - wind_day_td = tds[-6] - wind_day_parts = list(wind_day_td.stripped_strings) - wind_day = "".join(wind_day_parts[:2]) if len(wind_day_parts) >= 2 else "" - - wind_night_td = tds[-3] - wind_night_parts = list(wind_night_td.stripped_strings) - wind_night = "".join(wind_night_parts[:2]) if len(wind_night_parts) >= 2 else "" - - # 格式化输出 - if high_temp != "-" and low_temp != "-": - temperature = f"{low_temp}~{high_temp}℃" - else: - temperature = f"{low_temp if low_temp != '-' else '未知'}℃" - - weather_type = weather_day if weather_day != "-" else weather_night - wind = wind_day if wind_day else wind_night - - logger.info(f"成功获取{city}天气: {temperature}, {weather_type}, {wind}") - return (this_city, temperature, weather_type, wind) - - except (IndexError, StopIteration, AttributeError) as e: - logger.debug(f"解析表格行时出错: {e}") - continue - - if found_city: - break - - except Exception as e: - logger.error(f"处理 {url} 时出错: {e}", exc_info=True) - continue - - logger.error(f"在所有天气页面中都未找到城市: {city}") - return None - -class WeChatAPI: - """微信API接口类""" - - BASE_URL = "https://api.weixin.qq.com/cgi-bin" + if weather_response.status_code == 200: + weather_data = weather_response.json() + # 处理天气数据... + pass + + return None + + except Exception as e: + logger.error(f"API获取天气失败: {e}") + return None - @staticmethod - def get_access_token() -> Optional[str]: + def get_weather_by_web(self, city: str) -> Optional[WeatherInfo]: """ - 获取微信公众号access_token - - Returns: - str: access_token 或 None + 从中国天气网获取详细天气信息 """ try: - if not config.APP_ID or not config.APP_SECRET: - logger.error("APP_ID 或 APP_SECRET 未配置") + # 构建城市页面URL(需要先找到城市代码) + city_code = self._get_city_code(city) + if not city_code: + logger.error(f"未找到城市代码: {city}") return None - url = f"{WeChatAPI.BASE_URL}/token" - params = { - 'grant_type': 'client_credential', - 'appid': config.APP_ID, - 'secret': config.APP_SECRET - } + # 获取城市详细天气页面 + url = f"http://www.weather.com.cn/weather/{city_code}.shtml" + logger.info(f"正在获取天气数据: {url}") - response = requests.get(url, params=params, timeout=config.REQUEST_TIMEOUT) - response.raise_for_status() - result = response.json() + response = requests.get(url, headers=self.headers, timeout=config.REQUEST_TIMEOUT) + response.encoding = 'utf-8' - if 'access_token' in result: - logger.info("成功获取access_token") - return result['access_token'] - else: - error_msg = result.get('errmsg', '未知错误') - logger.error(f"获取access_token失败: {error_msg}") + if response.status_code != 200: + logger.error(f"请求失败: {response.status_code}") return None - - except requests.exceptions.RequestException as e: - logger.error(f"请求access_token失败: {e}", exc_info=True) - return None - except json.JSONDecodeError as e: - logger.error(f"解析access_token响应失败: {e}", exc_info=True) - return None + + soup = BeautifulSoup(response.text, 'html.parser') + + # 获取今天日期和星期 + today = datetime.date.today() + today_str = today.strftime("%Y年%m月%d日") + weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] + week = weekdays[today.weekday()] + + # 解析天气信息 + weather_info = self._parse_weather_page(soup, city, today_str, week) + + # 获取生活指数 + life_index = self._get_life_index(city_code) + if life_index: + weather_info.comfort = life_index.get('comfort', '舒适') + weather_info.dressing = life_index.get('dressing', '舒适') + weather_info.car_washing = life_index.get('car_washing', '适宜') + weather_info.cold_risk = life_index.get('cold_risk', '少发') + + # 获取空气质量 + aqi_info = self._get_aqi_info(city) + if aqi_info: + weather_info.aqi = aqi_info.get('aqi', '--') + weather_info.aqi_desc = aqi_info.get('level', '未知') + + weather_info.update_time = datetime.datetime.now().strftime("%H:%M:%S") + + return weather_info + except Exception as e: - logger.error(f"获取access_token时发生未知错误: {e}", exc_info=True) + logger.error(f"网页获取天气失败: {e}", exc_info=True) return None - @staticmethod - def send_template_message(access_token: str, template_data: Dict[str, Any]) -> bool: - """ - 发送模板消息 + def _get_city_code(self, city: str) -> Optional[str]: + """获取城市代码""" + # 简化的城市代码映射表(实际应该从数据库或文件加载) + city_codes = { + "北京": "101010100", "上海": "101020100", "广州": "101280101", + "深圳": "101280601", "杭州": "101210101", "南京": "101190101", + "苏州": "101190401", "武汉": "101200101", "成都": "101270101", + "重庆": "101040100", "天津": "101030100", "西安": "101110101", + "郑州": "101180101", "长沙": "101250101", "沈阳": "101070101", + "青岛": "101120201", "大连": "101070201", "济南": "101120101", + "厦门": "101230201", "福州": "101230101", "合肥": "101220101", + "石家庄": "101090101", "太原": "101100101", "长春": "101060101", + "哈尔滨": "101050101", "南昌": "101240101", "南宁": "101300101", + "海口": "101310101", "贵阳": "101260101", "昆明": "101290101", + "兰州": "101160101", "西宁": "101150101", "银川": "101170101", + "乌鲁木齐": "101130101", "拉萨": "101140101", "呼和浩特": "101080101", + "香港": "101320101", "澳门": "101330101", "台北": "101340101", + "吉安": "101240601", # 吉安的代码 + } + + # 精确匹配 + if city in city_codes: + return city_codes[city] - Args: - access_token (str): 微信access_token - template_data (Dict): 模板消息数据 + # 尝试模糊匹配 + for city_name, code in city_codes.items(): + if city in city_name or city_name in city: + return code + + # 如果找不到,尝试搜索 + logger.warning(f"未在映射表中找到城市代码: {city}, 尝试搜索...") + return self._search_city_code(city) + + def _search_city_code(self, city: str) -> Optional[str]: + """搜索城市代码""" + try: + search_url = f"http://toy1.weather.com.cn/search?cityname={city}" + response = requests.get(search_url, headers=self.headers, timeout=5) + response.encoding = 'utf-8' - Returns: - bool: 是否发送成功 - """ + if response.status_code == 200: + # 返回格式通常是:~101240601~,需要解析 + content = response.text + pattern = r'~(\d+)~' + matches = re.findall(pattern, content) + if matches: + return matches[0] + except Exception as e: + logger.error(f"搜索城市代码失败: {e}") + + return None + + def _parse_weather_page(self, soup: BeautifulSoup, city: str, date: str, week: str) -> WeatherInfo: + """解析天气页面""" try: - url = f"{WeChatAPI.BASE_URL}/message/template/send" - params = {'access_token': access_token} - - response = requests.post( - url, - params=params, - json=template_data, - timeout=config.REQUEST_TIMEOUT - ) - response.raise_for_status() - result = response.json() - - if result.get('errcode') == 0: - logger.info("模板消息发送成功") - return True - else: - error_msg = result.get('errmsg', '未知错误') - logger.error(f"发送模板消息失败: {error_msg}") - return False + # 获取今天天气信息 + today_div = soup.find('div', id='today') + + # 温度信息 + temp_div = today_div.find('div', class_='tem') if today_div else None + temperature = "未知" + current_temp = "未知" + + if temp_div: + temp_span = temp_div.find('span') + temp_i = temp_div.find('i') + if temp_span and temp_i: + high_temp = temp_span.get_text(strip=True) # 最高温 + low_temp = temp_i.get_text(strip=True) # 最低温 + temperature = f"{low_temp}~{high_temp}℃" + + # 尝试获取当前温度(可能在em标签中) + temp_em = temp_div.find('em') + if temp_em: + current_temp = temp_em.get_text(strip=True) + "℃" + + # 天气状况 + weather_div = today_div.find('div', class_='wea') if today_div else None + weather = "未知" + weather_desc = "未知" + + if weather_div: + weather = weather_div.get_text(strip=True) + # 获取更详细的天气描述(可能在父元素中) + parent_div = weather_div.parent + if parent_div: + wea_text = parent_div.get_text(" ", strip=True) + parts = wea_text.split() + if len(parts) > 1: + weather_desc = parts[1] if len(parts) > 1 else weather + + # 风力风向 + win_div = today_div.find('div', class_='win') if today_div else None + wind_direction = "未知" + wind_force = "未知" + wind_speed = "未知" + + if win_div: + # 风向 + wind_direction_span = win_div.find('span') + if wind_direction_span: + wind_direction = wind_direction_span.get('title', '未知') - except requests.exceptions.RequestException as e: - logger.error(f"发送模板消息请求失败: {e}", exc_info=True) - return False + # 风力和风速 + win_text = win_div.get_text(" ", strip=True) + # 尝试提取风力等级(如:<3级) + force_match = re.search(r'[<≤]?(\d+)[\-~]?(\d+)?级', win_text) + if force_match: + if force_match.group(2): + wind_force = f"{force_match.group(1)}-{force_match.group(2)}级" + else: + wind_force = f"{force_match.group(1)}级" + + # 尝试提取风速 + speed_match = re.search(r'(\d+)(\.\d+)?米/秒', win_text) + if speed_match: + wind_speed = f"{speed_match.group()}" + + # 湿度、气压、能见度(可能在详细信息的div中) + details_div = soup.find('div', class_='livezs') + humidity = "未知" + pressure = "未知" + visibility = "未知" + sunrise = "未知" + sunset = "未知" + + if details_div: + # 查找湿度 + humidity_li = details_div.find('li', text=re.compile(r'湿度')) + if humidity_li: + humidity_text = humidity_li.get_text(strip=True) + humidity_match = re.search(r'(\d+)%', humidity_text) + if humidity_match: + humidity = f"{humidity_match.group(1)}%" + + # 查找气压 + pressure_li = details_div.find('li', text=re.compile(r'气压')) + if pressure_li: + pressure_text = pressure_li.get_text(strip=True) + pressure_match = re.search(r'(\d+)\s*hPa', pressure_text) + if pressure_match: + pressure = f"{pressure_match.group(1)}hPa" + + # 查找能见度 + visibility_li = details_div.find('li', text=re.compile(r'能见度')) + if visibility_li: + visibility_text = visibility_li.get_text(strip=True) + visibility_match = re.search(r'(\d+)\s*公里', visibility_text) + if visibility_match: + visibility = f"{visibility_match.group(1)}公里" + + # 查找日出日落 + sun_li = details_div.find('li', text=re.compile(r'日出')) + if sun_li: + sun_text = sun_li.get_text(strip=True) + sun_match = re.search(r'日出\s*(\d+:\d+).*日落\s*(\d+:\d+)', sun_text) + if sun_match: + sunrise = sun_match.group(1) + sunset = sun_match.group(2) + + # 紫外线指数 + uv_index = "未知" + uv_desc = "未知" + uv_div = details_div.find('li', text=re.compile(r'紫外线')) if details_div else None + if uv_div: + uv_text = uv_div.get_text(strip=True) + uv_match = re.search(r'紫外线\s*(\d+)\s*([弱低中高强]+)', uv_text) + if uv_match: + uv_index = uv_match.group(1) + uv_desc = uv_match.group(2) + + return WeatherInfo( + city=city, + date=date, + week=week, + temperature=temperature, + current_temp=current_temp, + weather=weather, + weather_desc=weather_desc, + wind_direction=wind_direction, + wind_force=wind_force, + wind_speed=wind_speed, + humidity=humidity, + pressure=pressure, + visibility=visibility, + uv_index=uv_index, + uv_desc=uv_desc, + aqi="--", + aqi_desc="未知", + comfort="舒适", + dressing="舒适", + car_washing="适宜", + cold_risk="少发", + sunrise=sunrise, + sunset=sunset, + update_time="" + ) + except Exception as e: - logger.error(f"发送模板消息时发生未知错误: {e}", exc_info=True) - return False - -class DailyInspiration: - """每日激励语获取类""" + logger.error(f"解析天气页面失败: {e}") + # 返回基础信息 + return WeatherInfo( + city=city, + date=date, + week=week, + temperature="未知", + current_temp="未知", + weather="未知", + weather_desc="未知", + wind_direction="未知", + wind_force="未知", + wind_speed="未知", + humidity="未知", + pressure="未知", + visibility="未知", + uv_index="未知", + uv_desc="未知", + aqi="--", + aqi_desc="未知", + comfort="舒适", + dressing="舒适", + car_washing="适宜", + cold_risk="少发", + sunrise="未知", + sunset="未知", + update_time="" + ) - APIS = [ - { - 'name': 'lovelive', - 'url': 'https://api.lovelive.tools/api/SweetNothings/Serialization/Json', - 'headers': { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Accept': 'application/json' - }, - 'parser': lambda data: data.get('returnObj', [])[0] if data.get('returnObj', []) else "" - }, - { - 'name': 'fallback', - 'url': None, - 'parser': lambda data: get_fallback_inspiration() - } - ] + def _get_life_index(self, city_code: str) -> Dict[str, str]: + """获取生活指数""" + try: + url = f"http://www.weather.com.cn/weather1d/{city_code}.shtml" + response = requests.get(url, headers=self.headers, timeout=5) + response.encoding = 'utf-8' + + soup = BeautifulSoup(response.text, 'html.parser') + + life_index_div = soup.find('div', class_='live_index') + if not life_index_div: + return {} + + indices = {} + index_items = life_index_div.find_all('li') + + for item in index_items: + text = item.get_text(strip=True) + if '舒适度' in text: + indices['comfort'] = self._extract_index_level(text) + elif '穿衣' in text: + indices['dressing'] = self._extract_index_level(text) + elif '洗车' in text: + indices['car_washing'] = self._extract_index_level(text) + elif '感冒' in text: + indices['cold_risk'] = self._extract_index_level(text) + + return indices + + except Exception as e: + logger.error(f"获取生活指数失败: {e}") + return {} - @staticmethod - def get_inspiration() -> str: - """ - 获取每日激励语 + def _extract_index_level(self, text: str) -> str: + """提取指数等级""" + levels = ["适宜", "较适宜", "适宜", "不太适宜", "不适宜", + "舒适", "较舒适", "不舒适", "极易发", "易发", "较易发", "少发"] - Returns: - str: 激励语内容 - """ - for api in DailyInspiration.APIS: - try: - if api['url'] is None: - return api['parser'](None) - - response = requests.get( - api['url'], - headers=api['headers'], - timeout=config.REQUEST_TIMEOUT - ) - response.raise_for_status() - data = response.json() - - sentence = api['parser'](data) - if sentence and len(sentence) > 0: - logger.debug(f"从{api['name']}获取激励语成功") - return sentence.strip() - - except Exception as e: - logger.debug(f"从{api['name']}获取激励语失败: {e}") - continue + for level in levels: + if level in text: + return level - # 所有API都失败时使用备用 - return get_fallback_inspiration() - -def get_fallback_inspiration() -> str: - """获取备用激励语""" - inspirations = [ - "每一天都是新的开始,加油!", - "保持微笑,好运自然来!", - "今天也要元气满满哦!", - "愿你的一天充满阳光和欢笑!", - "好事总会发生在下个转弯!", - "保持热爱,奔赴山海!", - "今天是你余生中最年轻的一天!", - "坚持就是胜利!", - "心若向阳,无畏悲伤!", - "每天都要进步一点点!" - ] + # 提取括号中的内容 + match = re.search(r'[((]([^))]+)[))]', text) + if match: + return match.group(1) + + return "未知" + + def _get_aqi_info(self, city: str) -> Dict[str, str]: + """获取空气质量信息""" + try: + # 尝试从中国天气网获取AQI + url = f"http://www.weather.com.cn/air/?city={city}" + response = requests.get(url, headers=self.headers, timeout=5) + response.encoding = 'utf-8' + + soup = BeautifulSoup(response.text, 'html.parser') + + aqi_div = soup.find('div', class_='level') + if not aqi_div: + return {} + + aqi_text = aqi_div.get_text(strip=True) + aqi_match = re.search(r'(\d+)', aqi_text) + + if aqi_match: + aqi_value = int(aqi_match.group(1)) + aqi_level = self._get_aqi_level(aqi_value) + + return { + 'aqi': str(aqi_value), + 'level': aqi_level + } + + return {} + + except Exception as e: + logger.error(f"获取空气质量信息失败: {e}") + return {} - # 使用日期作为种子,确保每天的消息相同(可选) - today = datetime.date.today() - seed = today.year * 10000 + today.month * 100 + today.day - index = seed % len(inspirations) + def _get_aqi_level(self, aqi: int) -> str: + """根据AQI值获取空气质量等级""" + if aqi <= 50: + return "优" + elif aqi <= 100: + return "良" + elif aqi <= 150: + return "轻度污染" + elif aqi <= 200: + return "中度污染" + elif aqi <= 300: + return "重度污染" + else: + return "严重污染" - return inspirations[index] + def get_weather(self, city: str) -> Optional[WeatherInfo]: + """获取天气信息(主入口)""" + logger.info(f"开始获取{city}的详细天气信息") + + # 优先使用网页方式获取 + weather_info = self.get_weather_by_web(city) + + if not weather_info: + logger.warning("网页获取失败,尝试API方式") + weather_info = self.get_weather_by_api(city) + + if weather_info: + logger.info(f"成功获取{city}天气信息") + logger.info(f"温度: {weather_info.temperature}, 天气: {weather_info.weather}") + logger.info(f"风力: {weather_info.wind_force} {weather_info.wind_direction}") + logger.info(f"湿度: {weather_info.humidity}, 气压: {weather_info.pressure}") + + return weather_info -def create_template_data(weather_info: Tuple[str, str, str, str]) -> Dict[str, Any]: - """ - 创建微信模板消息数据 +class MessageBuilder: + """消息构建器""" - Args: - weather_info (Tuple): 天气信息 + @staticmethod + def build_wechat_message(weather_info: WeatherInfo, inspiration: str) -> Dict[str, Any]: + """构建微信消息""" + # 准备详细天气描述 + weather_details = f""" +{weather_info.weather_desc} +温度:{weather_info.current_temp}({weather_info.temperature}) +湿度:{weather_info.humidity} +气压:{weather_info.pressure} +风向:{weather_info.wind_direction} +风力:{weather_info.wind_force} +风速:{weather_info.wind_speed} +能见度:{weather_info.visibility} +紫外线:{weather_info.uv_index}({weather_info.uv_desc}) +空气质量:{weather_info.aqi}({weather_info.aqi_desc}) +日出/日落:{weather_info.sunrise}/{weather_info.sunset} + """.strip() - Returns: - Dict: 模板消息数据 - """ - today = datetime.date.today() - today_str = today.strftime("%Y年%m月%d日") - weekday = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"][today.weekday()] - - city, temperature, weather_type, wind = weather_info - - return { - "touser": config.OPEN_ID, - "template_id": config.TEMPLATE_ID, - "url": "https://mp.weixin.qq.com", - "data": { - "date": { - "value": f"{today_str} {weekday}", - "color": "#173177" - }, - "region": { - "value": city, - "color": "#173177" - }, - "weather": { - "value": weather_type, - "color": "#173177" - }, - "temp": { - "value": temperature, - "color": "#FF0000" # 温度用红色突出 - }, - "wind_dir": { - "value": wind if wind else "微风", - "color": "#173177" - }, - "today_note": { - "value": DailyInspiration.get_inspiration(), - "color": "#FF69B4" # 温馨的粉色 - }, - "update_time": { - "value": datetime.datetime.now().strftime("%H:%M:%S"), - "color": "#808080" + # 生活指数提示 + life_tips = f""" +👕 穿衣指数:{weather_info.dressing} +🚗 洗车指数:{weather_info.car_washing} +😷 感冒风险:{weather_info.cold_risk} +😊 舒适度:{weather_info.comfort} + """.strip() + + # 今日寄语 + today_note = f"{inspiration}\n\n{life_tips}" + + return { + "touser": config.OPEN_ID, + "template_id": config.TEMPLATE_ID, + "url": "https://mp.weixin.qq.com", + "data": { + "date": { + "value": f"{weather_info.date} {weather_info.week}", + "color": "#173177" + }, + "region": { + "value": weather_info.city, + "color": "#173177" + }, + "weather": { + "value": weather_info.weather, + "color": "#173177" + }, + "temp": { + "value": weather_info.temperature, + "color": "#FF0000" + }, + "current_temp": { + "value": weather_info.current_temp, + "color": "#FF4500" + }, + "wind_info": { + "value": f"{weather_info.wind_direction} {weather_info.wind_force}", + "color": "#4169E1" + }, + "humidity": { + "value": weather_info.humidity, + "color": "#1E90FF" + }, + "pressure": { + "value": weather_info.pressure, + "color": "#4682B4" + }, + "uv_index": { + "value": f"{weather_info.uv_index} ({weather_info.uv_desc})", + "color": "#FF8C00" + }, + "aqi": { + "value": f"{weather_info.aqi} ({weather_info.aqi_desc})", + "color": self._get_aqi_color(weather_info.aqi_desc) + }, + "weather_details": { + "value": weather_details, + "color": "#2E8B57" + }, + "life_index": { + "value": life_tips, + "color": "#8B4513" + }, + "today_note": { + "value": today_note, + "color": "#FF69B4" + }, + "sun_info": { + "value": f"日出: {weather_info.sunrise} 日落: {weather_info.sunset}", + "color": "#FFD700" + }, + "update_time": { + "value": weather_info.update_time, + "color": "#808080" + } } } - } + + @staticmethod + def _get_aqi_color(aqi_desc: str) -> str: + """根据空气质量获取颜色""" + color_map = { + "优": "#00FF00", + "良": "#90EE90", + "轻度污染": "#FFFF00", + "中度污染": "#FFA500", + "重度污染": "#FF4500", + "严重污染": "#FF0000" + } + return color_map.get(aqi_desc, "#000000") + +class InspirationService: + """激励语服务""" + + @staticmethod + def get_inspiration() -> str: + """获取每日激励语""" + try: + # 多个API源 + apis = [ + { + 'url': 'https://api.lovelive.tools/api/SweetNothings/Serialization/Json', + 'parser': lambda data: data.get('returnObj', [])[0] if data.get('returnObj', []) else "" + }, + { + 'url': 'https://v1.hitokoto.cn/?c=a&c=b&c=c&c=d&c=e&c=f&c=g&c=h&c=i&c=j&c=k&c=l', + 'parser': lambda data: f"{data.get('hitokoto', '')} ——{data.get('from', '')}" + } + ] + + for api in apis: + try: + response = requests.get(api['url'], timeout=5) + response.raise_for_status() + data = response.json() + inspiration = api['parser'](data) + + if inspiration and len(inspiration.strip()) > 5: + return inspiration.strip() + + except Exception: + continue + + # 如果API都失败,使用本地库 + return InspirationService.get_local_inspiration() + + except Exception as e: + logger.error(f"获取激励语失败: {e}") + return InspirationService.get_local_inspiration() + + @staticmethod + def get_local_inspiration() -> str: + """获取本地激励语""" + inspirations = [ + "生活不是等待风暴过去,而是学会在雨中跳舞。", + "每一天都是新的开始,微笑面对,好运自然来。", + "保持热爱,奔赴山海,忠于自己,热爱生活。", + "心若向阳,无畏悲伤,眼中有光,心中有爱。", + "努力成为更好的自己,比仰望别人更有意义。", + "生活总会给你答案,但不会马上告诉你一切。", + "把平凡的日子过成诗,简单的生活过成画。", + "愿你眼中总有光芒,活成自己想要的模样。", + "不为模糊的未来担忧,只为清楚的现在努力。", + "生活就是一边失去,一边拥有,一边选择,一边放弃。" + ] + + # 使用日期作为索引,确保每天相同 + day_of_year = datetime.date.today().timetuple().tm_yday + return inspirations[day_of_year % len(inspirations)] + +def get_access_token(): + """获取微信access_token""" + try: + url = f'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={config.APP_ID}&secret={config.APP_SECRET}' + response = requests.get(url, timeout=config.REQUEST_TIMEOUT) + response.raise_for_status() + result = response.json() + + if 'access_token' in result: + logger.info("成功获取access_token") + return result['access_token'] + else: + logger.error(f"获取access_token失败: {result.get('errmsg', '未知错误')}") + return None + + except Exception as e: + logger.error(f"获取access_token失败: {e}") + return None -def weather_report(): - """ - 主函数,执行完整的天气报告流程 - """ +def send_wechat_message(access_token: str, message_data: Dict[str, Any]) -> bool: + """发送微信消息""" try: - logger.info("=" * 50) - logger.info("开始执行天气报告任务") + url = f'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={access_token}' + response = requests.post(url, json=message_data, timeout=config.REQUEST_TIMEOUT) + response.raise_for_status() + result = response.json() + if result.get('errcode') == 0: + logger.info("微信消息发送成功") + return True + else: + logger.error(f"微信消息发送失败: {result.get('errmsg', '未知错误')}") + return False + + except Exception as e: + logger.error(f"发送微信消息失败: {e}") + return False + +def main(): + """主函数""" + logger.info("=" * 60) + logger.info("开始执行详细天气报告任务") + + try: # 1. 验证配置 if not config.validate(): - logger.error("配置验证失败,任务终止") + logger.error("配置验证失败") return False - logger.info(f"配置验证通过,目标城市: {config.CITY}") + logger.info(f"目标城市: {config.CITY}") + + # 2. 获取详细天气信息 + weather_service = WeatherService() + weather_info = weather_service.get_weather(config.CITY) - # 2. 获取天气信息 - weather_info = WeatherFetcher.get_weather(config.CITY) if not weather_info: - logger.error("无法获取天气信息,任务终止") + logger.error("获取天气信息失败") return False - logger.info(f"成功获取天气信息: {weather_info}") + logger.info(f"获取到详细天气信息:") + logger.info(f"城市: {weather_info.city}") + logger.info(f"温度: {weather_info.current_temp} ({weather_info.temperature})") + logger.info(f"天气: {weather_info.weather}") + logger.info(f"风向风力: {weather_info.wind_direction} {weather_info.wind_force}") + logger.info(f"风速: {weather_info.wind_speed}") + logger.info(f"湿度: {weather_info.humidity}") + logger.info(f"气压: {weather_info.pressure}") + logger.info(f"紫外线: {weather_info.uv_index} ({weather_info.uv_desc})") + logger.info(f"空气质量: {weather_info.aqi} ({weather_info.aqi_desc})") + + # 3. 获取激励语 + inspiration = InspirationService.get_inspiration() + logger.info(f"今日寄语: {inspiration[:30]}...") + + # 4. 构建微信消息 + message_builder = MessageBuilder() + message_data = message_builder.build_wechat_message(weather_info, inspiration) - # 3. 获取access_token - access_token = WeChatAPI.get_access_token() + # 5. 获取access_token + access_token = get_access_token() if not access_token: - logger.error("无法获取access_token,任务终止") + logger.error("获取access_token失败") return False - # 4. 准备并发送模板消息 - template_data = create_template_data(weather_info) - success = WeChatAPI.send_template_message(access_token, template_data) + # 6. 发送消息 + success = send_wechat_message(access_token, message_data) if success: logger.info("天气报告任务执行成功!") else: logger.error("天气报告任务执行失败") - + return success except Exception as e: - logger.error(f"天气报告任务执行过程中发生错误: {e}", exc_info=True) + logger.error(f"任务执行失败: {e}", exc_info=True) return False finally: - logger.info("天气报告任务执行结束") - logger.info("=" * 50) + logger.info("任务执行结束") + logger.info("=" * 60) if __name__ == '__main__': - # 可以添加命令行参数支持 - import argparse - - parser = argparse.ArgumentParser(description='微信天气报告机器人') - parser.add_argument('--city', type=str, help='指定城市名称', default=None) - parser.add_argument('--debug', action='store_true', help='启用调试模式') - - args = parser.parse_args() - - # 如果命令行指定了城市,则覆盖配置 - if args.city: - config.CITY = args.city - - # 设置调试模式 - if args.debug: - logger.setLevel(logging.DEBUG) - for handler in logger.handlers: - handler.setLevel(logging.DEBUG) - - # 执行天气报告 - weather_report() + main() From a606037c7ac4662b3a41944e365c65f13651b8a8 Mon Sep 17 00:00:00 2001 From: tie4453 Date: Fri, 19 Dec 2025 23:00:36 +0800 Subject: [PATCH 11/21] Update weather_report.py From 3023b28dc3da872bad58781cb321146cc76d960a Mon Sep 17 00:00:00 2001 From: tie4453 Date: Fri, 19 Dec 2025 23:16:52 +0800 Subject: [PATCH 12/21] Update weather_report.py --- weather_report.py | 1196 +++++++++++++++++---------------------------- 1 file changed, 446 insertions(+), 750 deletions(-) diff --git a/weather_report.py b/weather_report.py index f416724e..2e4fd7c8 100644 --- a/weather_report.py +++ b/weather_report.py @@ -4,13 +4,11 @@ from bs4 import BeautifulSoup import logging import datetime -from typing import Optional, Tuple, Dict, Any, List -from dataclasses import dataclass import time -import re -from enum import Enum +from typing import Optional, Dict, Any, Tuple +from dataclasses import dataclass -# 设置日志 +# 配置日志 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', @@ -18,822 +16,520 @@ ) logger = logging.getLogger(__name__) -# 配置信息 +# 配置信息类 @dataclass class Config: - """配置信息类""" - APP_ID: str = os.environ.get("APP_ID", "") - APP_SECRET: str = os.environ.get("APP_SECRET", "") - OPEN_ID: str = os.environ.get("OPEN_ID", "") - TEMPLATE_ID: str = os.environ.get("TEMPLATE_ID", "") - CITY: str = os.environ.get("CITY", "吉安") - REQUEST_TIMEOUT: int = 10 - MAX_RETRIES: int = 3 - RETRY_DELAY: float = 1.0 + """配置信息类,用于类型提示和验证""" + APP_ID: str + APP_SECRET: str + OPEN_ID: str + TEMPLATE_ID: str + CITY: str = "吉安" - def validate(self) -> bool: - """验证配置是否完整""" - required_fields = ["APP_ID", "APP_SECRET", "OPEN_ID", "TEMPLATE_ID"] - missing_fields = [field for field in required_fields if not getattr(self, field)] - - if missing_fields: - logger.error(f"缺少必要的配置项: {', '.join(missing_fields)}") - return False - - if not self.CITY: - logger.error("未配置城市信息") - return False - - return True - -config = Config() + @classmethod + def from_env(cls) -> Optional['Config']: + """从环境变量加载配置""" + try: + app_id = os.environ.get("APP_ID") + app_secret = os.environ.get("APP_SECRET") + open_id = os.environ.get("OPEN_ID") + template_id = os.environ.get("TEMPLATE_ID") + city = os.environ.get("CITY", "吉安") + + # 验证必要配置 + missing = [] + if not app_id: + missing.append("APP_ID") + if not app_secret: + missing.append("APP_SECRET") + if not open_id: + missing.append("OPEN_ID") + if not template_id: + missing.append("TEMPLATE_ID") + + if missing: + logger.error(f"缺少必要的环境变量: {', '.join(missing)}") + return None + + return cls( + APP_ID=app_id, + APP_SECRET=app_secret, + OPEN_ID=open_id, + TEMPLATE_ID=template_id, + CITY=city + ) + except Exception as e: + logger.error(f"加载配置失败: {e}") + return None -# 天气信息数据类 +# 天气信息类 @dataclass class WeatherInfo: """天气信息数据类""" city: str - date: str - week: str - temperature: str # 温度范围 - current_temp: str # 当前温度 - weather: str # 天气状况 - weather_desc: str # 天气描述 - wind_direction: str # 风向 - wind_force: str # 风力等级 - wind_speed: str # 风速 - humidity: str # 湿度 - pressure: str # 气压 - visibility: str # 能见度 - uv_index: str # 紫外线指数 - uv_desc: str # 紫外线描述 - aqi: str # 空气质量指数 - aqi_desc: str # 空气质量描述 - comfort: str # 舒适度指数 - dressing: str # 穿衣指数 - car_washing: str # 洗车指数 - cold_risk: str # 感冒风险 - sunrise: str # 日出时间 - sunset: str # 日落时间 - update_time: str # 更新时间 + temperature: str + weather_type: str + wind: str + date: str = None + + def __post_init__(self): + if not self.date: + self.date = datetime.date.today().strftime("%Y年%m月%d日") def to_dict(self) -> Dict[str, Any]: - """转换为字典""" + """转换为字典格式""" return { "city": self.city, - "date": self.date, - "week": self.week, "temperature": self.temperature, - "current_temp": self.current_temp, - "weather": self.weather, - "weather_desc": self.weather_desc, - "wind_direction": self.wind_direction, - "wind_force": self.wind_force, - "wind_speed": self.wind_speed, - "humidity": self.humidity, - "pressure": self.pressure, - "visibility": self.visibility, - "uv_index": self.uv_index, - "uv_desc": self.uv_desc, - "aqi": self.aqi, - "aqi_desc": self.aqi_desc, - "comfort": self.comfort, - "dressing": self.dressing, - "car_washing": self.car_washing, - "cold_risk": self.cold_risk, - "sunrise": self.sunrise, - "sunset": self.sunset, - "update_time": self.update_time + "weather_type": self.weather_type, + "wind": self.wind, + "date": self.date } -class WeatherService: - """天气服务类""" +class WeatherFetcher: + """天气获取器,封装天气数据获取逻辑""" - def __init__(self): - self.headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', - 'Accept-Encoding': 'gzip, deflate', - } - - # 省份简称映射(用于搜索) - self.province_short = { - '北京': 'bj', '上海': 'sh', '天津': 'tj', '重庆': 'cq', - '河北': 'hb', '山西': 'sx', '辽宁': 'ln', '吉林': 'jl', - '黑龙江': 'hlj', '江苏': 'js', '浙江': 'zj', '安徽': 'ah', - '福建': 'fj', '江西': 'jx', '山东': 'sd', '河南': 'ha', - '湖北': 'hb', '湖南': 'hn', '广东': 'gd', '海南': 'hn', - '四川': 'sc', '贵州': 'gz', '云南': 'yn', '陕西': 'sn', - '甘肃': 'gs', '青海': 'qh', '台湾': 'tw', '内蒙古': 'nm', - '广西': 'gx', '西藏': 'xz', '宁夏': 'nx', '新疆': 'xj', - '香港': 'hk', '澳门': 'mo' - } + # 中国天气网各区域URL + WEATHER_URLS = [ + "http://www.weather.com.cn/textFC/hb.shtml", # 华北 + "http://www.weather.com.cn/textFC/db.shtml", # 东北 + "http://www.weather.com.cn/textFC/hd.shtml", # 华东 + "http://www.weather.com.cn/textFC/hz.shtml", # 华中 + "http://www.weather.com.cn/textFC/hn.shtml", # 华南 + "http://www.weather.com.cn/textFC/xb.shtml", # 西北 + "http://www.weather.com.cn/textFC/xn.shtml", # 西南 + "http://www.weather.com.cn/textFC/gat.shtml", # 港澳台 + ] - def get_weather_by_api(self, city: str) -> Optional[WeatherInfo]: - """ - 通过天气API获取详细天气信息(使用心知天气API示例,需要自行申请key) - 注意:这里使用免费API,实际使用时需要注册获取API_KEY + HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'http://www.weather.com.cn/', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + } + + @staticmethod + def retry_request(url: str, max_retries: int = 3, timeout: int = 10) -> Optional[requests.Response]: + """带重试的请求函数""" + for attempt in range(max_retries): + try: + response = requests.get(url, headers=WeatherFetcher.HEADERS, timeout=timeout) + response.raise_for_status() + # 检查编码 + if response.encoding.lower() in ('utf-8', 'gbk', 'gb2312'): + return response + else: + response.encoding = 'utf-8' + return response + except requests.exceptions.Timeout: + logger.warning(f"请求超时 ({attempt+1}/{max_retries}): {url}") + if attempt < max_retries - 1: + time.sleep(2 ** attempt) # 指数退避 + except requests.exceptions.RequestException as e: + logger.warning(f"请求失败 ({attempt+1}/{max_retries}): {e}") + if attempt < max_retries - 1: + time.sleep(1) + return None + + @classmethod + def get_weather(cls, city_name: str) -> Optional[WeatherInfo]: """ - try: - # 这里使用公开的API,实际使用时建议使用正规天气API服务 - # 示例:心知天气API - api_key = os.environ.get("WEATHER_API_KEY", "your_api_key_here") - - # 获取城市ID(需要先调用城市搜索API) - city_search_url = f"https://api.seniverse.com/v3/location/search.json?key={api_key}&q={city}" - city_response = requests.get(city_search_url, timeout=config.REQUEST_TIMEOUT) + 从中国天气网获取指定城市的天气信息 + + Args: + city_name (str): 要查询的城市名称 - if city_response.status_code == 200: - city_data = city_response.json() - if city_data and len(city_data) > 0: - city_id = city_data[0]['id'] + Returns: + WeatherInfo or None: 天气信息对象 + """ + logger.info(f"开始获取{city_name}的天气信息") + + for url in cls.WEATHER_URLS: + try: + logger.debug(f"尝试从 {url} 获取数据") + response = cls.retry_request(url) + if not response: + continue - # 获取实时天气 - weather_url = f"https://api.seniverse.com/v3/weather/now.json?key={api_key}&location={city_id}&language=zh-Hans&unit=c" - weather_response = requests.get(weather_url, timeout=config.REQUEST_TIMEOUT) + soup = BeautifulSoup(response.content, 'html.parser') + + # 尝试不同的选择器定位天气表格 + weather_tables = soup.find_all("div", class_="conMidtab") + if not weather_tables: + # 备用选择器 + weather_tables = soup.find_all("div", class_="conMidtab2") + + for table_div in weather_tables: + tables = table_div.find_all("table") - if weather_response.status_code == 200: - weather_data = weather_response.json() - # 处理天气数据... - pass - - return None - - except Exception as e: - logger.error(f"API获取天气失败: {e}") - return None + for table in tables: + # 跳过表头行 + rows = table.find_all("tr")[2:] if len(table.find_all("tr")) > 2 else table.find_all("tr") + + for row in rows: + cells = row.find_all("td") + if len(cells) < 8: # 确保有足够的单元格 + continue + + # 城市单元格通常是倒数第8个 + city_cell = cells[-8] if len(cells) >= 8 else cells[0] + current_city = ''.join(city_cell.stripped_strings) + + # 模糊匹配城市名(支持简写) + if city_name in current_city or current_city in city_name: + try: + # 提取天气数据 + high_temp = cls._extract_text(cells[-5]) if len(cells) >= 5 else '-' + low_temp = cls._extract_text(cells[-2]) if len(cells) >= 2 else '-' + weather_day = cls._extract_text(cells[-7]) if len(cells) >= 7 else '-' + weather_night = cls._extract_text(cells[-4]) if len(cells) >= 4 else '-' + + # 处理风力信息 + wind_day_cell = cells[-6] if len(cells) >= 6 else None + wind_night_cell = cells[-3] if len(cells) >= 3 else None + + wind_day = cls._extract_wind(wind_day_cell) + wind_night = cls._extract_wind(wind_night_cell) + + # 构建返回结果 + if high_temp != '-' and low_temp != '-': + temperature = f"{low_temp}~{high_temp}℃" + elif low_temp != '-': + temperature = f"{low_temp}℃" + elif high_temp != '-': + temperature = f"{high_temp}℃" + else: + temperature = "暂无数据" + + # 选择白天天气或夜间天气 + weather_type = weather_day if weather_day != '-' else weather_night + if weather_type == '-': + weather_type = "暂无数据" + + # 选择风力信息 + wind = wind_day if wind_day and wind_day != '--' else wind_night + if not wind or wind == '--': + wind = "暂无数据" + + logger.info(f"成功获取{city_name}天气: {weather_type}, {temperature}") + return WeatherInfo( + city=current_city, + temperature=temperature, + weather_type=weather_type, + wind=wind + ) + + except (IndexError, AttributeError, ValueError) as e: + logger.warning(f"解析{city_name}天气数据时出错: {e}") + continue + + except Exception as e: + logger.error(f"处理URL {url} 时出错: {e}") + continue + + logger.error(f"在所有天气页面中未找到城市: {city_name}") + return None - def get_weather_by_web(self, city: str) -> Optional[WeatherInfo]: - """ - 从中国天气网获取详细天气信息 - """ - try: - # 构建城市页面URL(需要先找到城市代码) - city_code = self._get_city_code(city) - if not city_code: - logger.error(f"未找到城市代码: {city}") - return None - - # 获取城市详细天气页面 - url = f"http://www.weather.com.cn/weather/{city_code}.shtml" - logger.info(f"正在获取天气数据: {url}") - - response = requests.get(url, headers=self.headers, timeout=config.REQUEST_TIMEOUT) - response.encoding = 'utf-8' - - if response.status_code != 200: - logger.error(f"请求失败: {response.status_code}") - return None - - soup = BeautifulSoup(response.text, 'html.parser') - - # 获取今天日期和星期 - today = datetime.date.today() - today_str = today.strftime("%Y年%m月%d日") - weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] - week = weekdays[today.weekday()] - - # 解析天气信息 - weather_info = self._parse_weather_page(soup, city, today_str, week) - - # 获取生活指数 - life_index = self._get_life_index(city_code) - if life_index: - weather_info.comfort = life_index.get('comfort', '舒适') - weather_info.dressing = life_index.get('dressing', '舒适') - weather_info.car_washing = life_index.get('car_washing', '适宜') - weather_info.cold_risk = life_index.get('cold_risk', '少发') - - # 获取空气质量 - aqi_info = self._get_aqi_info(city) - if aqi_info: - weather_info.aqi = aqi_info.get('aqi', '--') - weather_info.aqi_desc = aqi_info.get('level', '未知') - - weather_info.update_time = datetime.datetime.now().strftime("%H:%M:%S") - - return weather_info - - except Exception as e: - logger.error(f"网页获取天气失败: {e}", exc_info=True) - return None + @staticmethod + def _extract_text(element) -> str: + """从BeautifulSoup元素中提取文本""" + if not element: + return '-' + text = ''.join(element.stripped_strings) + return text if text else '-' - def _get_city_code(self, city: str) -> Optional[str]: - """获取城市代码""" - # 简化的城市代码映射表(实际应该从数据库或文件加载) - city_codes = { - "北京": "101010100", "上海": "101020100", "广州": "101280101", - "深圳": "101280601", "杭州": "101210101", "南京": "101190101", - "苏州": "101190401", "武汉": "101200101", "成都": "101270101", - "重庆": "101040100", "天津": "101030100", "西安": "101110101", - "郑州": "101180101", "长沙": "101250101", "沈阳": "101070101", - "青岛": "101120201", "大连": "101070201", "济南": "101120101", - "厦门": "101230201", "福州": "101230101", "合肥": "101220101", - "石家庄": "101090101", "太原": "101100101", "长春": "101060101", - "哈尔滨": "101050101", "南昌": "101240101", "南宁": "101300101", - "海口": "101310101", "贵阳": "101260101", "昆明": "101290101", - "兰州": "101160101", "西宁": "101150101", "银川": "101170101", - "乌鲁木齐": "101130101", "拉萨": "101140101", "呼和浩特": "101080101", - "香港": "101320101", "澳门": "101330101", "台北": "101340101", - "吉安": "101240601", # 吉安的代码 - } - - # 精确匹配 - if city in city_codes: - return city_codes[city] + @staticmethod + def _extract_wind(element) -> str: + """提取风力信息""" + if not element: + return '--' + wind_parts = list(element.stripped_strings) + if len(wind_parts) >= 2: + return f"{wind_parts[0]}{wind_parts[1]}" + elif len(wind_parts) == 1: + return wind_parts[0] + else: + return '--' + + +class WeChatAPI: + """微信API封装类""" + + BASE_URL = "https://api.weixin.qq.com/cgi-bin" + + def __init__(self, app_id: str, app_secret: str): + self.app_id = app_id + self.app_secret = app_secret + self._access_token = None + self._token_expire_time = 0 - # 尝试模糊匹配 - for city_name, code in city_codes.items(): - if city in city_name or city_name in city: - return code + def get_access_token(self, force_refresh: bool = False) -> Optional[str]: + """ + 获取微信公众号access_token(带缓存) - # 如果找不到,尝试搜索 - logger.warning(f"未在映射表中找到城市代码: {city}, 尝试搜索...") - return self._search_city_code(city) - - def _search_city_code(self, city: str) -> Optional[str]: - """搜索城市代码""" - try: - search_url = f"http://toy1.weather.com.cn/search?cityname={city}" - response = requests.get(search_url, headers=self.headers, timeout=5) - response.encoding = 'utf-8' + Args: + force_refresh (bool): 是否强制刷新token - if response.status_code == 200: - # 返回格式通常是:~101240601~,需要解析 - content = response.text - pattern = r'~(\d+)~' - matches = re.findall(pattern, content) - if matches: - return matches[0] - except Exception as e: - logger.error(f"搜索城市代码失败: {e}") + Returns: + str or None: access_token + """ + # 检查缓存,token有效期通常为7200秒,这里设置为7000秒 + if not force_refresh and self._access_token and time.time() < self._token_expire_time: + logger.debug("使用缓存的access_token") + return self._access_token - return None - - def _parse_weather_page(self, soup: BeautifulSoup, city: str, date: str, week: str) -> WeatherInfo: - """解析天气页面""" try: - # 获取今天天气信息 - today_div = soup.find('div', id='today') - - # 温度信息 - temp_div = today_div.find('div', class_='tem') if today_div else None - temperature = "未知" - current_temp = "未知" - - if temp_div: - temp_span = temp_div.find('span') - temp_i = temp_div.find('i') - if temp_span and temp_i: - high_temp = temp_span.get_text(strip=True) # 最高温 - low_temp = temp_i.get_text(strip=True) # 最低温 - temperature = f"{low_temp}~{high_temp}℃" - - # 尝试获取当前温度(可能在em标签中) - temp_em = temp_div.find('em') - if temp_em: - current_temp = temp_em.get_text(strip=True) + "℃" - - # 天气状况 - weather_div = today_div.find('div', class_='wea') if today_div else None - weather = "未知" - weather_desc = "未知" - - if weather_div: - weather = weather_div.get_text(strip=True) - # 获取更详细的天气描述(可能在父元素中) - parent_div = weather_div.parent - if parent_div: - wea_text = parent_div.get_text(" ", strip=True) - parts = wea_text.split() - if len(parts) > 1: - weather_desc = parts[1] if len(parts) > 1 else weather - - # 风力风向 - win_div = today_div.find('div', class_='win') if today_div else None - wind_direction = "未知" - wind_force = "未知" - wind_speed = "未知" - - if win_div: - # 风向 - wind_direction_span = win_div.find('span') - if wind_direction_span: - wind_direction = wind_direction_span.get('title', '未知') - - # 风力和风速 - win_text = win_div.get_text(" ", strip=True) - # 尝试提取风力等级(如:<3级) - force_match = re.search(r'[<≤]?(\d+)[\-~]?(\d+)?级', win_text) - if force_match: - if force_match.group(2): - wind_force = f"{force_match.group(1)}-{force_match.group(2)}级" - else: - wind_force = f"{force_match.group(1)}级" - - # 尝试提取风速 - speed_match = re.search(r'(\d+)(\.\d+)?米/秒', win_text) - if speed_match: - wind_speed = f"{speed_match.group()}" - - # 湿度、气压、能见度(可能在详细信息的div中) - details_div = soup.find('div', class_='livezs') - humidity = "未知" - pressure = "未知" - visibility = "未知" - sunrise = "未知" - sunset = "未知" + url = f"{self.BASE_URL}/token" + params = { + "grant_type": "client_credential", + "appid": self.app_id, + "secret": self.app_secret + } - if details_div: - # 查找湿度 - humidity_li = details_div.find('li', text=re.compile(r'湿度')) - if humidity_li: - humidity_text = humidity_li.get_text(strip=True) - humidity_match = re.search(r'(\d+)%', humidity_text) - if humidity_match: - humidity = f"{humidity_match.group(1)}%" - - # 查找气压 - pressure_li = details_div.find('li', text=re.compile(r'气压')) - if pressure_li: - pressure_text = pressure_li.get_text(strip=True) - pressure_match = re.search(r'(\d+)\s*hPa', pressure_text) - if pressure_match: - pressure = f"{pressure_match.group(1)}hPa" - - # 查找能见度 - visibility_li = details_div.find('li', text=re.compile(r'能见度')) - if visibility_li: - visibility_text = visibility_li.get_text(strip=True) - visibility_match = re.search(r'(\d+)\s*公里', visibility_text) - if visibility_match: - visibility = f"{visibility_match.group(1)}公里" + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + result = response.json() + + if 'access_token' in result: + self._access_token = result['access_token'] + # 设置过期时间(提前5分钟过期) + self._token_expire_time = time.time() + result.get('expires_in', 7200) - 300 + logger.info("获取access_token成功") + return self._access_token + else: + error_msg = result.get('errmsg', '未知错误') + logger.error(f"获取access_token失败: {error_msg} (错误码: {result.get('errcode')})") + return None - # 查找日出日落 - sun_li = details_div.find('li', text=re.compile(r'日出')) - if sun_li: - sun_text = sun_li.get_text(strip=True) - sun_match = re.search(r'日出\s*(\d+:\d+).*日落\s*(\d+:\d+)', sun_text) - if sun_match: - sunrise = sun_match.group(1) - sunset = sun_match.group(2) - - # 紫外线指数 - uv_index = "未知" - uv_desc = "未知" - uv_div = details_div.find('li', text=re.compile(r'紫外线')) if details_div else None - if uv_div: - uv_text = uv_div.get_text(strip=True) - uv_match = re.search(r'紫外线\s*(\d+)\s*([弱低中高强]+)', uv_text) - if uv_match: - uv_index = uv_match.group(1) - uv_desc = uv_match.group(2) - - return WeatherInfo( - city=city, - date=date, - week=week, - temperature=temperature, - current_temp=current_temp, - weather=weather, - weather_desc=weather_desc, - wind_direction=wind_direction, - wind_force=wind_force, - wind_speed=wind_speed, - humidity=humidity, - pressure=pressure, - visibility=visibility, - uv_index=uv_index, - uv_desc=uv_desc, - aqi="--", - aqi_desc="未知", - comfort="舒适", - dressing="舒适", - car_washing="适宜", - cold_risk="少发", - sunrise=sunrise, - sunset=sunset, - update_time="" - ) - - except Exception as e: - logger.error(f"解析天气页面失败: {e}") - # 返回基础信息 - return WeatherInfo( - city=city, - date=date, - week=week, - temperature="未知", - current_temp="未知", - weather="未知", - weather_desc="未知", - wind_direction="未知", - wind_force="未知", - wind_speed="未知", - humidity="未知", - pressure="未知", - visibility="未知", - uv_index="未知", - uv_desc="未知", - aqi="--", - aqi_desc="未知", - comfort="舒适", - dressing="舒适", - car_washing="适宜", - cold_risk="少发", - sunrise="未知", - sunset="未知", - update_time="" - ) - - def _get_life_index(self, city_code: str) -> Dict[str, str]: - """获取生活指数""" - try: - url = f"http://www.weather.com.cn/weather1d/{city_code}.shtml" - response = requests.get(url, headers=self.headers, timeout=5) - response.encoding = 'utf-8' - - soup = BeautifulSoup(response.text, 'html.parser') - - life_index_div = soup.find('div', class_='live_index') - if not life_index_div: - return {} - - indices = {} - index_items = life_index_div.find_all('li') - - for item in index_items: - text = item.get_text(strip=True) - if '舒适度' in text: - indices['comfort'] = self._extract_index_level(text) - elif '穿衣' in text: - indices['dressing'] = self._extract_index_level(text) - elif '洗车' in text: - indices['car_washing'] = self._extract_index_level(text) - elif '感冒' in text: - indices['cold_risk'] = self._extract_index_level(text) - - return indices - + except requests.exceptions.RequestException as e: + logger.error(f"请求access_token失败: {e}") + return None + except json.JSONDecodeError as e: + logger.error(f"解析access_token响应失败: {e}") + return None except Exception as e: - logger.error(f"获取生活指数失败: {e}") - return {} + logger.error(f"获取access_token时发生未知错误: {e}") + return None - def _extract_index_level(self, text: str) -> str: - """提取指数等级""" - levels = ["适宜", "较适宜", "适宜", "不太适宜", "不适宜", - "舒适", "较舒适", "不舒适", "极易发", "易发", "较易发", "少发"] - - for level in levels: - if level in text: - return level - - # 提取括号中的内容 - match = re.search(r'[((]([^))]+)[))]', text) - if match: - return match.group(1) + def send_template_message(self, open_id: str, template_id: str, + weather_info: WeatherInfo, daily_note: str) -> bool: + """ + 发送模板消息 - return "未知" - - def _get_aqi_info(self, city: str) -> Dict[str, str]: - """获取空气质量信息""" + Args: + open_id (str): 用户OpenID + template_id (str): 模板ID + weather_info (WeatherInfo): 天气信息 + daily_note (str): 每日情话 + + Returns: + bool: 是否发送成功 + """ try: - # 尝试从中国天气网获取AQI - url = f"http://www.weather.com.cn/air/?city={city}" - response = requests.get(url, headers=self.headers, timeout=5) - response.encoding = 'utf-8' - - soup = BeautifulSoup(response.text, 'html.parser') + access_token = self.get_access_token() + if not access_token: + logger.error("无法获取有效的access_token") + return False + + # 构建消息数据 + message_data = { + "touser": open_id, + "template_id": template_id, + "url": "https://mp.weixin.qq.com", + "data": { + "date": { + "value": weather_info.date, + "color": "#173177" + }, + "region": { + "value": weather_info.city, + "color": "#173177" + }, + "weather": { + "value": weather_info.weather_type, + "color": "#173177" + }, + "temp": { + "value": weather_info.temperature, + "color": "#FF0000" # 温度用红色突出 + }, + "wind_dir": { + "value": weather_info.wind, + "color": "#173177" + }, + "today_note": { + "value": daily_note, + "color": "#FF69B4" # 情话用粉色 + } + } + } - aqi_div = soup.find('div', class_='level') - if not aqi_div: - return {} + url = f"{self.BASE_URL}/message/template/send" + params = {"access_token": access_token} - aqi_text = aqi_div.get_text(strip=True) - aqi_match = re.search(r'(\d+)', aqi_text) + response = requests.post(url, params=params, json=message_data, timeout=15) + response.raise_for_status() + result = response.json() - if aqi_match: - aqi_value = int(aqi_match.group(1)) - aqi_level = self._get_aqi_level(aqi_value) + if result.get('errcode') == 0: + logger.info(f"模板消息发送成功 (消息ID: {result.get('msgid')})") + return True + else: + error_msg = result.get('errmsg', '未知错误') + error_code = result.get('errcode') + logger.error(f"发送模板消息失败: {error_msg} (错误码: {error_code})") - return { - 'aqi': str(aqi_value), - 'level': aqi_level - } - - return {} - + # 如果token过期,强制刷新后重试一次 + if error_code in [40001, 40014, 42001]: + logger.warning("access_token可能已过期,尝试刷新后重试") + access_token = self.get_access_token(force_refresh=True) + if access_token: + return self.send_template_message(open_id, template_id, weather_info, daily_note) + + return False + + except requests.exceptions.RequestException as e: + logger.error(f"发送模板消息请求失败: {e}") + return False except Exception as e: - logger.error(f"获取空气质量信息失败: {e}") - return {} - - def _get_aqi_level(self, aqi: int) -> str: - """根据AQI值获取空气质量等级""" - if aqi <= 50: - return "优" - elif aqi <= 100: - return "良" - elif aqi <= 150: - return "轻度污染" - elif aqi <= 200: - return "中度污染" - elif aqi <= 300: - return "重度污染" - else: - return "严重污染" - - def get_weather(self, city: str) -> Optional[WeatherInfo]: - """获取天气信息(主入口)""" - logger.info(f"开始获取{city}的详细天气信息") - - # 优先使用网页方式获取 - weather_info = self.get_weather_by_web(city) - - if not weather_info: - logger.warning("网页获取失败,尝试API方式") - weather_info = self.get_weather_by_api(city) - - if weather_info: - logger.info(f"成功获取{city}天气信息") - logger.info(f"温度: {weather_info.temperature}, 天气: {weather_info.weather}") - logger.info(f"风力: {weather_info.wind_force} {weather_info.wind_direction}") - logger.info(f"湿度: {weather_info.humidity}, 气压: {weather_info.pressure}") - - return weather_info + logger.error(f"发送模板消息时发生未知错误: {e}") + return False + -class MessageBuilder: - """消息构建器""" +class DailyService: + """每日服务类,整合各种服务""" @staticmethod - def build_wechat_message(weather_info: WeatherInfo, inspiration: str) -> Dict[str, Any]: - """构建微信消息""" - # 准备详细天气描述 - weather_details = f""" -{weather_info.weather_desc} -温度:{weather_info.current_temp}({weather_info.temperature}) -湿度:{weather_info.humidity} -气压:{weather_info.pressure} -风向:{weather_info.wind_direction} -风力:{weather_info.wind_force} -风速:{weather_info.wind_speed} -能见度:{weather_info.visibility} -紫外线:{weather_info.uv_index}({weather_info.uv_desc}) -空气质量:{weather_info.aqi}({weather_info.aqi_desc}) -日出/日落:{weather_info.sunrise}/{weather_info.sunset} - """.strip() - - # 生活指数提示 - life_tips = f""" -👕 穿衣指数:{weather_info.dressing} -🚗 洗车指数:{weather_info.car_washing} -😷 感冒风险:{weather_info.cold_risk} -😊 舒适度:{weather_info.comfort} - """.strip() - - # 今日寄语 - today_note = f"{inspiration}\n\n{life_tips}" + def get_daily_love(backup_message: str = "每天都要开心哦!") -> str: + """ + 获取每日情话,支持多个备用源 - return { - "touser": config.OPEN_ID, - "template_id": config.TEMPLATE_ID, - "url": "https://mp.weixin.qq.com", - "data": { - "date": { - "value": f"{weather_info.date} {weather_info.week}", - "color": "#173177" - }, - "region": { - "value": weather_info.city, - "color": "#173177" - }, - "weather": { - "value": weather_info.weather, - "color": "#173177" - }, - "temp": { - "value": weather_info.temperature, - "color": "#FF0000" - }, - "current_temp": { - "value": weather_info.current_temp, - "color": "#FF4500" - }, - "wind_info": { - "value": f"{weather_info.wind_direction} {weather_info.wind_force}", - "color": "#4169E1" - }, - "humidity": { - "value": weather_info.humidity, - "color": "#1E90FF" - }, - "pressure": { - "value": weather_info.pressure, - "color": "#4682B4" - }, - "uv_index": { - "value": f"{weather_info.uv_index} ({weather_info.uv_desc})", - "color": "#FF8C00" - }, - "aqi": { - "value": f"{weather_info.aqi} ({weather_info.aqi_desc})", - "color": self._get_aqi_color(weather_info.aqi_desc) - }, - "weather_details": { - "value": weather_details, - "color": "#2E8B57" - }, - "life_index": { - "value": life_tips, - "color": "#8B4513" - }, - "today_note": { - "value": today_note, - "color": "#FF69B4" - }, - "sun_info": { - "value": f"日出: {weather_info.sunrise} 日落: {weather_info.sunset}", - "color": "#FFD700" - }, - "update_time": { - "value": weather_info.update_time, - "color": "#808080" - } + Args: + backup_message (str): 备用情话 + + Returns: + str: 情话内容 + """ + # 多个情话API源 + love_apis = [ + { + 'url': "https://api.lovelive.tools/api/SweetNothings/Serialization/Json", + 'parser': lambda data: data.get('returnObj', [])[0] if data.get('returnObj', []) else None + }, + { + 'url': "https://v1.hitokoto.cn/", + 'parser': lambda data: data.get('hitokoto', '') + f" ——{data.get('from', '')}" + }, + { + 'url': "https://api.shadiao.pro/chp", + 'parser': lambda data: data.get('data', {}).get('text', '') if data.get('data') else None } - } - - @staticmethod - def _get_aqi_color(aqi_desc: str) -> str: - """根据空气质量获取颜色""" - color_map = { - "优": "#00FF00", - "良": "#90EE90", - "轻度污染": "#FFFF00", - "中度污染": "#FFA500", - "重度污染": "#FF4500", - "严重污染": "#FF0000" - } - return color_map.get(aqi_desc, "#000000") - -class InspirationService: - """激励语服务""" - - @staticmethod - def get_inspiration() -> str: - """获取每日激励语""" - try: - # 多个API源 - apis = [ - { - 'url': 'https://api.lovelive.tools/api/SweetNothings/Serialization/Json', - 'parser': lambda data: data.get('returnObj', [])[0] if data.get('returnObj', []) else "" - }, - { - 'url': 'https://v1.hitokoto.cn/?c=a&c=b&c=c&c=d&c=e&c=f&c=g&c=h&c=i&c=j&c=k&c=l', - 'parser': lambda data: f"{data.get('hitokoto', '')} ——{data.get('from', '')}" + ] + + for api in love_apis: + try: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'application/json' } - ] - - for api in apis: - try: - response = requests.get(api['url'], timeout=5) - response.raise_for_status() - data = response.json() - inspiration = api['parser'](data) + + response = requests.get(api['url'], headers=headers, timeout=5) + response.raise_for_status() + data = response.json() + + sentence = api['parser'](data) + if sentence and len(sentence.strip()) > 0: + # 清理和截断过长的句子 + sentence = sentence.strip() + if len(sentence) > 50: + sentence = sentence[:47] + "..." + logger.info(f"成功获取情话: {sentence}") + return sentence - if inspiration and len(inspiration.strip()) > 5: - return inspiration.strip() - - except Exception: - continue - - # 如果API都失败,使用本地库 - return InspirationService.get_local_inspiration() - - except Exception as e: - logger.error(f"获取激励语失败: {e}") - return InspirationService.get_local_inspiration() + except Exception as e: + logger.debug(f"情话API {api['url']} 失败: {e}") + continue + + logger.warning("所有情话API都失败,使用备用情话") + return backup_message @staticmethod - def get_local_inspiration() -> str: - """获取本地激励语""" - inspirations = [ - "生活不是等待风暴过去,而是学会在雨中跳舞。", - "每一天都是新的开始,微笑面对,好运自然来。", - "保持热爱,奔赴山海,忠于自己,热爱生活。", - "心若向阳,无畏悲伤,眼中有光,心中有爱。", - "努力成为更好的自己,比仰望别人更有意义。", - "生活总会给你答案,但不会马上告诉你一切。", - "把平凡的日子过成诗,简单的生活过成画。", - "愿你眼中总有光芒,活成自己想要的模样。", - "不为模糊的未来担忧,只为清楚的现在努力。", - "生活就是一边失去,一边拥有,一边选择,一边放弃。" + def get_random_tip() -> str: + """获取随机小贴士""" + tips = [ + "记得多喝水,保持身体水分~", + "出门记得带伞,有备无患哦!", + "天气变化大,注意增减衣物~", + "今天也要保持好心情呀!", + "记得按时吃饭,身体最重要~", + "工作学习之余,记得休息眼睛~" ] - - # 使用日期作为索引,确保每天相同 - day_of_year = datetime.date.today().timetuple().tm_yday - return inspirations[day_of_year % len(inspirations)] + import random + return random.choice(tips) -def get_access_token(): - """获取微信access_token""" - try: - url = f'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={config.APP_ID}&secret={config.APP_SECRET}' - response = requests.get(url, timeout=config.REQUEST_TIMEOUT) - response.raise_for_status() - result = response.json() - - if 'access_token' in result: - logger.info("成功获取access_token") - return result['access_token'] - else: - logger.error(f"获取access_token失败: {result.get('errmsg', '未知错误')}") - return None - - except Exception as e: - logger.error(f"获取access_token失败: {e}") - return None - -def send_wechat_message(access_token: str, message_data: Dict[str, Any]) -> bool: - """发送微信消息""" - try: - url = f'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={access_token}' - response = requests.post(url, json=message_data, timeout=config.REQUEST_TIMEOUT) - response.raise_for_status() - result = response.json() - - if result.get('errcode') == 0: - logger.info("微信消息发送成功") - return True - else: - logger.error(f"微信消息发送失败: {result.get('errmsg', '未知错误')}") - return False - - except Exception as e: - logger.error(f"发送微信消息失败: {e}") - return False def main(): """主函数""" - logger.info("=" * 60) - logger.info("开始执行详细天气报告任务") - try: - # 1. 验证配置 - if not config.validate(): - logger.error("配置验证失败") - return False + logger.info("=" * 50) + logger.info("开始执行天气预报推送任务") + logger.info(f"执行时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + logger.info("=" * 50) - logger.info(f"目标城市: {config.CITY}") + # 1. 加载配置 + config = Config.from_env() + if not config: + logger.error("配置加载失败,程序退出") + return False - # 2. 获取详细天气信息 - weather_service = WeatherService() - weather_info = weather_service.get_weather(config.CITY) + logger.info(f"配置加载成功,城市: {config.CITY}") + # 2. 获取天气信息 + weather_info = WeatherFetcher.get_weather(config.CITY) if not weather_info: - logger.error("获取天气信息失败") + logger.error("获取天气信息失败,程序退出") return False - logger.info(f"获取到详细天气信息:") - logger.info(f"城市: {weather_info.city}") - logger.info(f"温度: {weather_info.current_temp} ({weather_info.temperature})") - logger.info(f"天气: {weather_info.weather}") - logger.info(f"风向风力: {weather_info.wind_direction} {weather_info.wind_force}") - logger.info(f"风速: {weather_info.wind_speed}") - logger.info(f"湿度: {weather_info.humidity}") - logger.info(f"气压: {weather_info.pressure}") - logger.info(f"紫外线: {weather_info.uv_index} ({weather_info.uv_desc})") - logger.info(f"空气质量: {weather_info.aqi} ({weather_info.aqi_desc})") + logger.info(f"天气信息获取成功: {weather_info}") - # 3. 获取激励语 - inspiration = InspirationService.get_inspiration() - logger.info(f"今日寄语: {inspiration[:30]}...") + # 3. 获取每日情话 + daily_love = DailyService.get_daily_love() + logger.info(f"每日情话: {daily_love}") - # 4. 构建微信消息 - message_builder = MessageBuilder() - message_data = message_builder.build_wechat_message(weather_info, inspiration) + # 4. 初始化微信API并发送消息 + wechat = WeChatAPI(config.APP_ID, config.APP_SECRET) - # 5. 获取access_token - access_token = get_access_token() - if not access_token: - logger.error("获取access_token失败") - return False - - # 6. 发送消息 - success = send_wechat_message(access_token, message_data) + success = wechat.send_template_message( + open_id=config.OPEN_ID, + template_id=config.TEMPLATE_ID, + weather_info=weather_info, + daily_note=daily_love + ) if success: - logger.info("天气报告任务执行成功!") + logger.info("天气预报推送任务执行成功!") else: - logger.error("天气报告任务执行失败") + logger.error("天气预报推送任务执行失败!") return success + except KeyboardInterrupt: + logger.info("用户中断执行") + return False except Exception as e: - logger.error(f"任务执行失败: {e}", exc_info=True) + logger.error(f"主程序发生未预期的错误: {e}", exc_info=True) return False finally: - logger.info("任务执行结束") - logger.info("=" * 60) + logger.info("=" * 50) + logger.info("天气预报推送任务执行结束") + logger.info("=" * 50) + if __name__ == '__main__': - main() + # 设置详细日志级别(生产环境可以调整为INFO) + if os.environ.get('DEBUG', '').lower() in ('true', '1', 'yes'): + logger.setLevel(logging.DEBUG) + + success = main() + exit_code = 0 if success else 1 + exit(exit_code) From ca08fb5dafca41f7c9f7f48f64b537b2ccf1fbdc Mon Sep 17 00:00:00 2001 From: tie4453 Date: Fri, 19 Dec 2025 23:17:41 +0800 Subject: [PATCH 13/21] Update weather_report.py From 7c5018a51004b028dae0809705a08e33102488ba Mon Sep 17 00:00:00 2001 From: tie4453 Date: Sat, 20 Dec 2025 21:50:43 +0800 Subject: [PATCH 14/21] Update weather_report.py --- weather_report.py | 1039 ++++++++++++++++++++++++++------------------- 1 file changed, 594 insertions(+), 445 deletions(-) diff --git a/weather_report.py b/weather_report.py index 2e4fd7c8..22c06d0c 100644 --- a/weather_report.py +++ b/weather_report.py @@ -5,8 +5,10 @@ import logging import datetime import time -from typing import Optional, Dict, Any, Tuple -from dataclasses import dataclass +from typing import Optional, Dict, Any, Tuple, List +from enum import Enum +import random +import hashlib # 配置日志 logging.basicConfig( @@ -16,520 +18,667 @@ ) logger = logging.getLogger(__name__) -# 配置信息类 -@dataclass +# 配置信息 class Config: - """配置信息类,用于类型提示和验证""" - APP_ID: str - APP_SECRET: str - OPEN_ID: str - TEMPLATE_ID: str - CITY: str = "吉安" + """配置管理类""" + _instance = None - @classmethod - def from_env(cls) -> Optional['Config']: - """从环境变量加载配置""" - try: - app_id = os.environ.get("APP_ID") - app_secret = os.environ.get("APP_SECRET") - open_id = os.environ.get("OPEN_ID") - template_id = os.environ.get("TEMPLATE_ID") - city = os.environ.get("CITY", "吉安") - - # 验证必要配置 - missing = [] - if not app_id: - missing.append("APP_ID") - if not app_secret: - missing.append("APP_SECRET") - if not open_id: - missing.append("OPEN_ID") - if not template_id: - missing.append("TEMPLATE_ID") - - if missing: - logger.error(f"缺少必要的环境变量: {', '.join(missing)}") - return None - - return cls( - APP_ID=app_id, - APP_SECRET=app_secret, - OPEN_ID=open_id, - TEMPLATE_ID=template_id, - CITY=city - ) - except Exception as e: - logger.error(f"加载配置失败: {e}") - return None - -# 天气信息类 -@dataclass -class WeatherInfo: - """天气信息数据类""" - city: str - temperature: str - weather_type: str - wind: str - date: str = None + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._load_config() + return cls._instance - def __post_init__(self): - if not self.date: - self.date = datetime.date.today().strftime("%Y年%m月%d日") + def _load_config(self): + """从环境变量加载配置""" + self.APP_ID = os.environ.get("APP_ID", "") + self.APP_SECRET = os.environ.get("APP_SECRET", "") + self.OPEN_ID = os.environ.get("OPEN_ID", "") + self.TEMPLATE_ID = os.environ.get("TEMPLATE_ID", "") + + # 城市配置,支持多个城市 + city_str = os.environ.get("CITIES", "吉安") + self.CITIES = [city.strip() for city in city_str.split(",")] + + # 其他配置 + self.ENABLE_AQI = os.environ.get("ENABLE_AQI", "true").lower() == "true" + self.ENABLE_LIFE_INDEX = os.environ.get("ENABLE_LIFE_INDEX", "true").lower() == "true" + self.ENABLE_HOURLY_FORECAST = os.environ.get("ENABLE_HOURLY_FORECAST", "false").lower() == "true" + + # 检查必要配置 + self._validate_config() - def to_dict(self) -> Dict[str, Any]: - """转换为字典格式""" - return { - "city": self.city, - "temperature": self.temperature, - "weather_type": self.weather_type, - "wind": self.wind, - "date": self.date - } + def _validate_config(self): + """验证配置是否完整""" + required_configs = ["APP_ID", "APP_SECRET", "OPEN_ID", "TEMPLATE_ID"] + missing = [] + for key in required_configs: + if not getattr(self, key, ""): + missing.append(key) + + if missing: + logger.warning(f"缺少必要的环境变量: {', '.join(missing)}") + logger.warning("请设置以下环境变量:") + for key in missing: + logger.warning(f" {key}") + + if not self.CITIES: + logger.warning("未配置城市,使用默认城市: 吉安") + self.CITIES = ["吉安"] + +# 星期几枚举 +class Weekday(Enum): + MONDAY = "星期一" + TUESDAY = "星期二" + WEDNESDAY = "星期三" + THURSDAY = "星期四" + FRIDAY = "星期五" + SATURDAY = "星期六" + SUNDAY = "星期日" -class WeatherFetcher: - """天气获取器,封装天气数据获取逻辑""" +def get_current_weekday() -> str: + """获取当前星期几""" + today = datetime.date.today() + weekday_num = today.weekday() # 0=周一, 6=周日 + return list(Weekday)[weekday_num].value + +def get_date_info() -> Dict[str, str]: + """获取日期相关信息""" + today = datetime.date.today() + yesterday = today - datetime.timedelta(days=1) + tomorrow = today + datetime.timedelta(days=1) - # 中国天气网各区域URL - WEATHER_URLS = [ - "http://www.weather.com.cn/textFC/hb.shtml", # 华北 - "http://www.weather.com.cn/textFC/db.shtml", # 东北 - "http://www.weather.com.cn/textFC/hd.shtml", # 华东 - "http://www.weather.com.cn/textFC/hz.shtml", # 华中 - "http://www.weather.com.cn/textFC/hn.shtml", # 华南 - "http://www.weather.com.cn/textFC/xb.shtml", # 西北 - "http://www.weather.com.cn/textFC/xn.shtml", # 西南 - "http://www.weather.com.cn/textFC/gat.shtml", # 港澳台 - ] + # 农历转换(简化版,实际使用需要农历库) + lunar_info = get_simple_lunar_date(today) - HEADERS = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Referer': 'http://www.weather.com.cn/', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + return { + "date": today.strftime("%Y年%m月%d日"), + "weekday": get_current_weekday(), + "yesterday": yesterday.strftime("%Y-%m-%d"), + "tomorrow": tomorrow.strftime("%Y-%m-%d"), + "lunar_date": lunar_info, + "day_of_year": today.timetuple().tm_yday, + "week_number": today.isocalendar()[1] } + +def get_simple_lunar_date(date_obj: datetime.date) -> str: + """获取简化的农历日期(实际使用时可接入农历库)""" + # 这里返回一个占位符,实际可以使用lunarcalendar等库 + return "农历信息" + +def get_extended_weather(city_name: str) -> Dict[str, Any]: + """ + 获取扩展的天气信息,包括更多实用数据 - @staticmethod - def retry_request(url: str, max_retries: int = 3, timeout: int = 10) -> Optional[requests.Response]: - """带重试的请求函数""" - for attempt in range(max_retries): + Args: + city_name: 城市名称 + + Returns: + 包含扩展天气信息的字典 + """ + try: + # 这里可以接入更多天气API,如和风天气、OpenWeather等 + # 当前仍使用中国天气网,但可以添加更多信息 + + weather_data = get_weather_from_website(city_name) + if not weather_data: + return None + + # 添加更多计算的信息 + temp_range = weather_data.get("temperature", "0-0℃").replace("℃", "").split("-") + if len(temp_range) == 2: try: - response = requests.get(url, headers=WeatherFetcher.HEADERS, timeout=timeout) - response.raise_for_status() - # 检查编码 - if response.encoding.lower() in ('utf-8', 'gbk', 'gb2312'): - return response - else: - response.encoding = 'utf-8' - return response - except requests.exceptions.Timeout: - logger.warning(f"请求超时 ({attempt+1}/{max_retries}): {url}") - if attempt < max_retries - 1: - time.sleep(2 ** attempt) # 指数退避 - except requests.exceptions.RequestException as e: - logger.warning(f"请求失败 ({attempt+1}/{max_retries}): {e}") - if attempt < max_retries - 1: - time.sleep(1) + low_temp = int(temp_range[0]) + high_temp = int(temp_range[1]) + + # 计算温差 + temp_diff = high_temp - low_temp + + # 根据温度给出穿衣建议 + dressing_advice = get_dressing_advice(high_temp, low_temp) + + # 获取紫外线指数(模拟) + uv_index = get_uv_index(weather_data.get("weather_type", "")) + + # 获取空气质量(模拟,实际可接入API) + aqi_info = get_aqi_info(city_name) + + # 生活指数 + life_indices = get_life_indices(weather_data.get("weather_type", ""), + high_temp, low_temp) + + # 小时预报(简化版) + hourly_forecast = get_hourly_forecast(weather_data.get("weather_type", "")) + + weather_data.update({ + "temp_diff": f"{temp_diff}℃", + "dressing_advice": dressing_advice, + "uv_index": uv_index, + "aqi": aqi_info, + "life_indices": life_indices, + "hourly_forecast": hourly_forecast, + "update_time": datetime.datetime.now().strftime("%H:%M") + }) + + except ValueError: + logger.warning(f"温度解析失败: {weather_data.get('temperature')}") + + return weather_data + + except Exception as e: + logger.error(f"获取扩展天气失败: {e}") return None + +def get_weather_from_website(city_name: str) -> Dict[str, Any]: + """ + 从中国天气网获取天气信息 - @classmethod - def get_weather(cls, city_name: str) -> Optional[WeatherInfo]: - """ - 从中国天气网获取指定城市的天气信息 + Returns: + 天气信息字典 + """ + try: + urls = [ + "http://www.weather.com.cn/textFC/hb.shtml", + "http://www.weather.com.cn/textFC/db.shtml", + "http://www.weather.com.cn/textFC/hd.shtml", + "http://www.weather.com.cn/textFC/hz.shtml", + "http://www.weather.com.cn/textFC/hn.shtml", + "http://www.weather.com.cn/textFC/xb.shtml", + "http://www.weather.com.cn/textFC/xn.shtml" + ] - Args: - city_name (str): 要查询的城市名称 - - Returns: - WeatherInfo or None: 天气信息对象 - """ - logger.info(f"开始获取{city_name}的天气信息") + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'http://www.weather.com.cn/', + 'Accept-Language': 'zh-CN,zh;q=0.9' + } - for url in cls.WEATHER_URLS: + for url in urls: try: - logger.debug(f"尝试从 {url} 获取数据") - response = cls.retry_request(url) - if not response: - continue - - soup = BeautifulSoup(response.content, 'html.parser') + resp = requests.get(url, headers=headers, timeout=8) + resp.encoding = 'utf-8' + soup = BeautifulSoup(resp.text, 'html.parser') - # 尝试不同的选择器定位天气表格 - weather_tables = soup.find_all("div", class_="conMidtab") - if not weather_tables: - # 备用选择器 - weather_tables = soup.find_all("div", class_="conMidtab2") + # 查找所有天气表格 + tables = soup.find_all('table') - for table_div in weather_tables: - tables = table_div.find_all("table") - - for table in tables: - # 跳过表头行 - rows = table.find_all("tr")[2:] if len(table.find_all("tr")) > 2 else table.find_all("tr") - - for row in rows: - cells = row.find_all("td") - if len(cells) < 8: # 确保有足够的单元格 - continue - - # 城市单元格通常是倒数第8个 - city_cell = cells[-8] if len(cells) >= 8 else cells[0] - current_city = ''.join(city_cell.stripped_strings) + for table in tables: + rows = table.find_all('tr') + for row in rows: + cols = row.find_all('td') + if len(cols) >= 8: + city_cell = cols[-8] + city_text = city_cell.get_text(strip=True) - # 模糊匹配城市名(支持简写) - if city_name in current_city or current_city in city_name: - try: - # 提取天气数据 - high_temp = cls._extract_text(cells[-5]) if len(cells) >= 5 else '-' - low_temp = cls._extract_text(cells[-2]) if len(cells) >= 2 else '-' - weather_day = cls._extract_text(cells[-7]) if len(cells) >= 7 else '-' - weather_night = cls._extract_text(cells[-4]) if len(cells) >= 4 else '-' - - # 处理风力信息 - wind_day_cell = cells[-6] if len(cells) >= 6 else None - wind_night_cell = cells[-3] if len(cells) >= 3 else None - - wind_day = cls._extract_wind(wind_day_cell) - wind_night = cls._extract_wind(wind_night_cell) - - # 构建返回结果 - if high_temp != '-' and low_temp != '-': - temperature = f"{low_temp}~{high_temp}℃" - elif low_temp != '-': - temperature = f"{low_temp}℃" - elif high_temp != '-': - temperature = f"{high_temp}℃" - else: - temperature = "暂无数据" - - # 选择白天天气或夜间天气 - weather_type = weather_day if weather_day != '-' else weather_night - if weather_type == '-': - weather_type = "暂无数据" - - # 选择风力信息 - wind = wind_day if wind_day and wind_day != '--' else wind_night - if not wind or wind == '--': - wind = "暂无数据" - - logger.info(f"成功获取{city_name}天气: {weather_type}, {temperature}") - return WeatherInfo( - city=current_city, - temperature=temperature, - weather_type=weather_type, - wind=wind - ) - - except (IndexError, AttributeError, ValueError) as e: - logger.warning(f"解析{city_name}天气数据时出错: {e}") - continue - + # 城市名称匹配(支持模糊匹配) + if city_name in city_text or city_text in city_name: + # 提取天气信息 + high_temp = cols[-5].get_text(strip=True) if len(cols) > 5 else '-' + low_temp = cols[-2].get_text(strip=True) if len(cols) > 2 else '-' + weather_day = cols[-7].get_text(strip=True) if len(cols) > 7 else '-' + weather_night = cols[-4].get_text(strip=True) if len(cols) > 4 else '-' + + # 风力信息 + wind_day = cols[-6].get_text(strip=True) if len(cols) > 6 else '-' + wind_night = cols[-3].get_text(strip=True) if len(cols) > 3 else '-' + + # 湿度信息(某些版本可能提供) + humidity = '-' + if len(cols) > 9: + humidity = cols[-9].get_text(strip=True) + + # 构建天气数据 + temperature = f"{low_temp}~{high_temp}℃" + weather_type = weather_day if weather_day != '-' else weather_night + wind = wind_day if wind_day != '-' else wind_night + + return { + "city": city_text, + "temperature": temperature, + "weather_type": weather_type, + "wind": wind, + "humidity": humidity, + "high_temp": high_temp.replace('℃', ''), + "low_temp": low_temp.replace('℃', '') + } + except Exception as e: - logger.error(f"处理URL {url} 时出错: {e}") + logger.debug(f"尝试URL {url} 失败: {e}") continue + + return None - logger.error(f"在所有天气页面中未找到城市: {city_name}") + except Exception as e: + logger.error(f"获取天气失败: {e}") return None + +def get_dressing_advice(high_temp: int, low_temp: int) -> str: + """根据温度给出穿衣建议""" + avg_temp = (high_temp + low_temp) / 2 - @staticmethod - def _extract_text(element) -> str: - """从BeautifulSoup元素中提取文本""" - if not element: - return '-' - text = ''.join(element.stripped_strings) - return text if text else '-' + if avg_temp >= 28: + return "天气炎热,建议穿短袖、短裤、薄裙" + elif 23 <= avg_temp < 28: + return "天气较热,建议穿短袖、薄外套" + elif 18 <= avg_temp < 23: + return "温度适宜,建议穿单层棉麻面料的短套装、T恤衫" + elif 10 <= avg_temp < 18: + return "天气微凉,建议穿套装、夹衣、风衣、休闲装" + elif 0 <= avg_temp < 10: + return "天气较冷,建议穿厚外套、毛衣、毛呢大衣" + else: + return "天气寒冷,建议穿羽绒服、棉衣、厚毛衣" + +def get_uv_index(weather_type: str) -> str: + """获取紫外线指数(简化版)""" + weather_type_lower = weather_type.lower() - @staticmethod - def _extract_wind(element) -> str: - """提取风力信息""" - if not element: - return '--' - wind_parts = list(element.stripped_strings) - if len(wind_parts) >= 2: - return f"{wind_parts[0]}{wind_parts[1]}" - elif len(wind_parts) == 1: - return wind_parts[0] - else: - return '--' + if any(word in weather_type_lower for word in ['晴', '多云', '少云']): + uv_levels = ["中等", "强", "很强"] + return f"紫外线{random.choice(uv_levels)}" + elif any(word in weather_type_lower for word in ['阴', '雨', '雪']): + return "紫外线弱" + else: + return "紫外线中等" +def get_aqi_info(city_name: str) -> Dict[str, str]: + """获取空气质量信息(简化版,实际可接入API)""" + # 模拟空气质量数据 + aqi_levels = ["优", "良", "轻度污染", "中度污染", "重度污染"] + aqi_values = [random.randint(10, 50), random.randint(51, 100), + random.randint(101, 150), random.randint(151, 200), + random.randint(201, 300)] + + level = random.choice(aqi_levels) + idx = aqi_levels.index(level) + value = aqi_values[idx] + + return { + "level": level, + "value": str(value), + "primary_pollutant": random.choice(["PM2.5", "PM10", "O3", "NO2"]) + } -class WeChatAPI: - """微信API封装类""" +def get_life_indices(weather_type: str, high_temp: int, low_temp: int) -> Dict[str, str]: + """获取生活指数""" + indices = {} - BASE_URL = "https://api.weixin.qq.com/cgi-bin" + # 洗车指数 + if "雨" in weather_type or "雪" in weather_type: + indices["car_wash"] = "不适宜" + else: + indices["car_wash"] = "适宜" - def __init__(self, app_id: str, app_secret: str): - self.app_id = app_id - self.app_secret = app_secret - self._access_token = None - self._token_expire_time = 0 - - def get_access_token(self, force_refresh: bool = False) -> Optional[str]: - """ - 获取微信公众号access_token(带缓存) - - Args: - force_refresh (bool): 是否强制刷新token - - Returns: - str or None: access_token - """ - # 检查缓存,token有效期通常为7200秒,这里设置为7000秒 - if not force_refresh and self._access_token and time.time() < self._token_expire_time: - logger.debug("使用缓存的access_token") - return self._access_token - - try: - url = f"{self.BASE_URL}/token" - params = { - "grant_type": "client_credential", - "appid": self.app_id, - "secret": self.app_secret - } - - response = requests.get(url, params=params, timeout=10) - response.raise_for_status() - result = response.json() - - if 'access_token' in result: - self._access_token = result['access_token'] - # 设置过期时间(提前5分钟过期) - self._token_expire_time = time.time() + result.get('expires_in', 7200) - 300 - logger.info("获取access_token成功") - return self._access_token - else: - error_msg = result.get('errmsg', '未知错误') - logger.error(f"获取access_token失败: {error_msg} (错误码: {result.get('errcode')})") - return None - - except requests.exceptions.RequestException as e: - logger.error(f"请求access_token失败: {e}") - return None - except json.JSONDecodeError as e: - logger.error(f"解析access_token响应失败: {e}") - return None - except Exception as e: - logger.error(f"获取access_token时发生未知错误: {e}") - return None + # 运动指数 + if "雨" in weather_type or "雪" in weather_type or high_temp > 35 or low_temp < 0: + indices["sport"] = "较不宜" + else: + indices["sport"] = "适宜" - def send_template_message(self, open_id: str, template_id: str, - weather_info: WeatherInfo, daily_note: str) -> bool: - """ - 发送模板消息 - - Args: - open_id (str): 用户OpenID - template_id (str): 模板ID - weather_info (WeatherInfo): 天气信息 - daily_note (str): 每日情话 - - Returns: - bool: 是否发送成功 - """ - try: - access_token = self.get_access_token() - if not access_token: - logger.error("无法获取有效的access_token") - return False - - # 构建消息数据 - message_data = { - "touser": open_id, - "template_id": template_id, - "url": "https://mp.weixin.qq.com", - "data": { - "date": { - "value": weather_info.date, - "color": "#173177" - }, - "region": { - "value": weather_info.city, - "color": "#173177" - }, - "weather": { - "value": weather_info.weather_type, - "color": "#173177" - }, - "temp": { - "value": weather_info.temperature, - "color": "#FF0000" # 温度用红色突出 - }, - "wind_dir": { - "value": weather_info.wind, - "color": "#173177" - }, - "today_note": { - "value": daily_note, - "color": "#FF69B4" # 情话用粉色 - } - } - } - - url = f"{self.BASE_URL}/message/template/send" - params = {"access_token": access_token} - - response = requests.post(url, params=params, json=message_data, timeout=15) - response.raise_for_status() - result = response.json() - - if result.get('errcode') == 0: - logger.info(f"模板消息发送成功 (消息ID: {result.get('msgid')})") - return True - else: - error_msg = result.get('errmsg', '未知错误') - error_code = result.get('errcode') - logger.error(f"发送模板消息失败: {error_msg} (错误码: {error_code})") - - # 如果token过期,强制刷新后重试一次 - if error_code in [40001, 40014, 42001]: - logger.warning("access_token可能已过期,尝试刷新后重试") - access_token = self.get_access_token(force_refresh=True) - if access_token: - return self.send_template_message(open_id, template_id, weather_info, daily_note) - - return False - - except requests.exceptions.RequestException as e: - logger.error(f"发送模板消息请求失败: {e}") - return False - except Exception as e: - logger.error(f"发送模板消息时发生未知错误: {e}") - return False + # 感冒指数 + if high_temp - low_temp > 10: + indices["cold"] = "易发" + else: + indices["cold"] = "少发" + + # 舒适度指数 + if 18 <= (high_temp + low_temp) / 2 <= 26: + indices["comfort"] = "舒适" + else: + indices["comfort"] = "不舒适" + + # 钓鱼指数(随机) + fishing = ["适宜", "较适宜", "不宜"] + indices["fishing"] = random.choice(fishing) + + return indices +def get_hourly_forecast(weather_type: str) -> List[Dict[str, str]]: + """获取小时预报(简化版)""" + forecast = [] + current_hour = datetime.datetime.now().hour + + for i in range(4): # 未来4小时的简化预报 + hour = (current_hour + i) % 24 + temp_change = random.randint(-2, 2) + + forecast.append({ + "time": f"{hour:02d}:00", + "temp": f"{20 + temp_change}℃", # 基准20度 + "weather": weather_type, + "wind": f"{random.randint(1, 3)}级" + }) + + return forecast -class DailyService: - """每日服务类,整合各种服务""" +def get_daily_tips(date_info: Dict[str, str]) -> List[str]: + """获取每日小贴士""" + tips = [] + + # 根据星期几的贴士 + weekday_tips = { + "星期一": ["新的一周开始啦,加油!", "周一综合症?来杯咖啡提提神"], + "星期二": ["工作渐入佳境,保持节奏", "记得起身活动,保护颈椎"], + "星期三": ["一周过半,坚持就是胜利", "适当放松,劳逸结合"], + "星期四": ["黎明前的黑暗,加油", "可以开始规划周末活动了"], + "星期五": ["明天就周末啦,坚持一下", "晚上可以适当放松一下"], + "星期六": ["周末愉快!好好休息", "适合户外活动的好时机"], + "星期日": ["周末最后一天,好好享受", "明天要上班,记得早睡哦"] + } + + if date_info["weekday"] in weekday_tips: + tips.append(random.choice(weekday_tips[date_info["weekday"]])) + + # 通用健康贴士 + health_tips = [ + "每天八杯水,健康永相随", + "早睡早起身体好", + "多吃蔬菜水果,补充维生素", + "适当运动,增强免疫力", + "保持好心情,健康最重要" + ] + tips.append(random.choice(health_tips)) + + # 天气相关贴士 + weather_tips = [ + "出门记得看天气,有备无患", + "天气变化大,注意增减衣物", + "空气质量不佳时,减少户外活动" + ] + tips.append(random.choice(weather_tips)) - @staticmethod - def get_daily_love(backup_message: str = "每天都要开心哦!") -> str: - """ - 获取每日情话,支持多个备用源 + return tips + +def get_access_token(): + """ + 获取微信公众号access_token + Returns: + str: access_token 或 None + """ + try: + config = Config() + app_id = config.APP_ID + app_secret = config.APP_SECRET - Args: - backup_message (str): 备用情话 + if not app_id or not app_secret: + logger.error("未配置APP_ID或APP_SECRET") + return None - Returns: - str: 情话内容 - """ + url = f'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={app_id}&secret={app_secret}' + response = requests.get(url, timeout=8) + response.raise_for_status() + result = response.json() + + if 'access_token' in result: + logger.info("获取access_token成功") + return result.get('access_token') + else: + error_msg = result.get('errmsg', '未知错误') + logger.error(f"获取access_token失败:{error_msg}") + return None + + except Exception as e: + logger.error(f"获取access_token失败:{e}") + return None + +def get_daily_love(date_info: Dict[str, str]) -> str: + """ + 获取每日情话,增加更多来源 + + Returns: + str: 情话内容 + """ + try: # 多个情话API源 - love_apis = [ + api_sources = [ { - 'url': "https://api.lovelive.tools/api/SweetNothings/Serialization/Json", - 'parser': lambda data: data.get('returnObj', [])[0] if data.get('returnObj', []) else None + "url": "https://api.lovelive.tools/api/SweetNothings", + "parser": lambda data: data.get('returnObj', '') if isinstance(data, dict) else data }, { - 'url': "https://v1.hitokoto.cn/", - 'parser': lambda data: data.get('hitokoto', '') + f" ——{data.get('from', '')}" + "url": "https://v1.hitokoto.cn/", + "parser": lambda data: f"{data.get('hitokoto', '')} ——{data.get('from', '')}" }, { - 'url': "https://api.shadiao.pro/chp", - 'parser': lambda data: data.get('data', {}).get('text', '') if data.get('data') else None + "url": "https://api.uomg.com/api/rand.qinghua", + "parser": lambda data: data.get('content', '') if isinstance(data, dict) else '' } ] - for api in love_apis: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'application/json' + } + + for api in api_sources: try: - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Accept': 'application/json' - } - - response = requests.get(api['url'], headers=headers, timeout=5) + response = requests.get(api["url"], headers=headers, timeout=5) response.raise_for_status() data = response.json() - sentence = api['parser'](data) + sentence = api["parser"](data) if sentence and len(sentence.strip()) > 0: - # 清理和截断过长的句子 - sentence = sentence.strip() - if len(sentence) > 50: - sentence = sentence[:47] + "..." - logger.info(f"成功获取情话: {sentence}") + # 添加日期相关的个性化 + if "星期" in date_info["weekday"]: + sentence = f"{date_info['weekday']}的问候:{sentence}" + + # 限制长度 + if len(sentence) > 100: + sentence = sentence[:97] + "..." + + logger.info(f"获取情话成功:{sentence[:30]}...") return sentence except Exception as e: - logger.debug(f"情话API {api['url']} 失败: {e}") + logger.debug(f"情话API {api['url']} 失败:{e}") continue - logger.warning("所有情话API都失败,使用备用情话") - return backup_message - - @staticmethod - def get_random_tip() -> str: - """获取随机小贴士""" - tips = [ - "记得多喝水,保持身体水分~", - "出门记得带伞,有备无患哦!", - "天气变化大,注意增减衣物~", - "今天也要保持好心情呀!", - "记得按时吃饭,身体最重要~", - "工作学习之余,记得休息眼睛~" + # 如果所有API都失败,使用备用情话 + backup_sentences = [ + f"{date_info['weekday']}也要保持好心情呀!", + "每天都要开心哦!", + "记得微笑,今天又是美好的一天!", + "照顾好自己,比什么都重要~", + "愿你今天有阳光般的好心情!" ] - import random - return random.choice(tips) + + return random.choice(backup_sentences) + + except Exception as e: + logger.error(f"获取情话失败:{e}") + return "每天都要开心哦!" +def send_weather_message(access_token: str, weather_data: Dict[str, Any], + date_info: Dict[str, str], city_name: str) -> bool: + """ + 发送天气预报消息 + + Returns: + bool: 是否成功发送 + """ + try: + config = Config() + + if not weather_data: + logger.error("天气数据为空") + return False + + # 获取情话 + daily_love = get_daily_love(date_info) + + # 获取小贴士 + daily_tips = get_daily_tips(date_info) + tips_text = ";".join(daily_tips[:3]) # 取前3条 + + # 构建消息数据 + message_data = { + "touser": config.OPEN_ID, + "template_id": config.TEMPLATE_ID, + "url": "https://mp.weixin.qq.com", + "data": { + # 基础信息 + "date": { + "value": f"{date_info['date']} {date_info['weekday']}", + "color": "#173177" + }, + "city": { + "value": city_name, + "color": "#173177" + }, + # 天气信息 + "weather": { + "value": weather_data.get("weather_type", "未知"), + "color": "#FF0000" + }, + "temperature": { + "value": weather_data.get("temperature", "未知"), + "color": "#FF4500" + }, + "wind": { + "value": weather_data.get("wind", "未知"), + "color": "#4682B4" + }, + "humidity": { + "value": weather_data.get("humidity", "未知"), + "color": "#4682B4" + }, + # 扩展信息 + "temp_diff": { + "value": weather_data.get("temp_diff", "未知"), + "color": "#FF6347" + }, + "uv_index": { + "value": weather_data.get("uv_index", "未知"), + "color": "#FF8C00" + }, + # 空气质量 + "aqi": { + "value": f"{weather_data.get('aqi', {}).get('level', '未知')} ({weather_data.get('aqi', {}).get('value', '')})", + "color": "#32CD32" if weather_data.get('aqi', {}).get('level') == '优' else "#FFD700" + }, + # 生活指数 + "dressing": { + "value": weather_data.get("dressing_advice", "请根据温度穿衣"), + "color": "#8A2BE2" + }, + "car_wash": { + "value": weather_data.get("life_indices", {}).get("car_wash", "未知"), + "color": "#1E90FF" + }, + "sport": { + "value": weather_data.get("life_indices", {}).get("sport", "未知"), + "color": "#1E90FF" + }, + # 温馨提示 + "love_note": { + "value": daily_love, + "color": "#FF69B4" + }, + "daily_tips": { + "value": tips_text, + "color": "#2E8B57" + }, + "update_time": { + "value": f"更新时间:{weather_data.get('update_time', '')}", + "color": "#808080" + } + } + } + + # 发送请求 + url = f'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={access_token}' + response = requests.post(url, json=message_data, timeout=10) + response.raise_for_status() + result = response.json() + + if result.get('errcode') == 0: + logger.info(f"天气预报发送成功 (消息ID: {result.get('msgid')})") + return True + else: + error_msg = result.get('errmsg', '未知错误') + logger.error(f"发送失败:{error_msg}") + return False + + except Exception as e: + logger.error(f"发送天气预报失败:{e}") + return False def main(): """主函数""" try: - logger.info("=" * 50) - logger.info("开始执行天气预报推送任务") - logger.info(f"执行时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - logger.info("=" * 50) - - # 1. 加载配置 - config = Config.from_env() - if not config: - logger.error("配置加载失败,程序退出") - return False + logger.info("=" * 60) + logger.info("天气预报推送服务启动") + logger.info(f"启动时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + logger.info("=" * 60) - logger.info(f"配置加载成功,城市: {config.CITY}") + # 初始化配置 + config = Config() - # 2. 获取天气信息 - weather_info = WeatherFetcher.get_weather(config.CITY) - if not weather_info: - logger.error("获取天气信息失败,程序退出") - return False + # 检查必要配置 + required_configs = ["APP_ID", "APP_SECRET", "OPEN_ID", "TEMPLATE_ID"] + for key in required_configs: + if not getattr(config, key, ""): + logger.error(f"缺少必要配置: {key}") + return False - logger.info(f"天气信息获取成功: {weather_info}") + logger.info(f"服务配置: 城市={config.CITIES}, AQI={config.ENABLE_AQI}, 生活指数={config.ENABLE_LIFE_INDEX}") - # 3. 获取每日情话 - daily_love = DailyService.get_daily_love() - logger.info(f"每日情话: {daily_love}") + # 获取日期信息 + date_info = get_date_info() + logger.info(f"当前日期: {date_info['date']} {date_info['weekday']}") - # 4. 初始化微信API并发送消息 - wechat = WeChatAPI(config.APP_ID, config.APP_SECRET) + # 获取access_token + access_token = get_access_token() + if not access_token: + logger.error("获取access_token失败") + return False - success = wechat.send_template_message( - open_id=config.OPEN_ID, - template_id=config.TEMPLATE_ID, - weather_info=weather_info, - daily_note=daily_love - ) + # 遍历所有城市 + success_count = 0 + for city in config.CITIES: + logger.info(f"开始处理城市: {city}") + + # 获取天气信息 + weather_data = get_extended_weather(city) + if not weather_data: + logger.error(f"获取{city}天气信息失败") + continue + + logger.info(f"获取到{city}天气: {weather_data.get('weather_type', '未知')} {weather_data.get('temperature', '未知')}") + + # 发送消息 + success = send_weather_message(access_token, weather_data, date_info, city) + if success: + success_count += 1 + logger.info(f"{city}天气预报发送成功") + else: + logger.error(f"{city}天气预报发送失败") + + # 城市间延迟,避免请求过快 + if len(config.CITIES) > 1 and city != config.CITIES[-1]: + time.sleep(2) - if success: - logger.info("天气预报推送任务执行成功!") - else: - logger.error("天气预报推送任务执行失败!") + # 汇总结果 + logger.info("=" * 60) + logger.info(f"任务完成: 成功{success_count}/{len(config.CITIES)}个城市") + logger.info(f"完成时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + logger.info("=" * 60) - return success + return success_count > 0 - except KeyboardInterrupt: - logger.info("用户中断执行") - return False except Exception as e: - logger.error(f"主程序发生未预期的错误: {e}", exc_info=True) + logger.error(f"主程序错误: {e}", exc_info=True) return False - finally: - logger.info("=" * 50) - logger.info("天气预报推送任务执行结束") - logger.info("=" * 50) - if __name__ == '__main__': - # 设置详细日志级别(生产环境可以调整为INFO) - if os.environ.get('DEBUG', '').lower() in ('true', '1', 'yes'): - logger.setLevel(logging.DEBUG) + # 设置环境变量示例(实际使用时请设置真实值): + # export APP_ID="your_app_id" + # export APP_SECRET="your_app_secret" + # export OPEN_ID="your_open_id" + # export TEMPLATE_ID="your_template_id" + # export CITIES="吉安,南昌" # 支持多个城市 + # export ENABLE_AQI="true" + # export ENABLE_LIFE_INDEX="true" success = main() - exit_code = 0 if success else 1 - exit(exit_code) + exit(0 if success else 1) From 6ae21829bfebba6a6daa2cffdb47dda76da173c3 Mon Sep 17 00:00:00 2001 From: tie4453 Date: Sat, 20 Dec 2025 21:51:42 +0800 Subject: [PATCH 15/21] Update weather_report.py From 05e1298749e7d520bf6b750963455d049e38e22d Mon Sep 17 00:00:00 2001 From: tie4453 Date: Sat, 20 Dec 2025 22:01:58 +0800 Subject: [PATCH 16/21] Update weather_report.py --- weather_report.py | 902 ++++++++++++++-------------------------------- 1 file changed, 268 insertions(+), 634 deletions(-) diff --git a/weather_report.py b/weather_report.py index 22c06d0c..8e2db4a8 100644 --- a/weather_report.py +++ b/weather_report.py @@ -1,684 +1,318 @@ +""" +微信天气推送服务 +功能:从天气网站抓取数据,通过微信模板消息推送给指定用户 +""" + import os -import requests import json -from bs4 import BeautifulSoup -import logging import datetime -import time -from typing import Optional, Dict, Any, Tuple, List -from enum import Enum -import random -import hashlib - -# 配置日志 -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) -logger = logging.getLogger(__name__) - -# 配置信息 -class Config: - """配置管理类""" - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance._load_config() - return cls._instance - - def _load_config(self): - """从环境变量加载配置""" - self.APP_ID = os.environ.get("APP_ID", "") - self.APP_SECRET = os.environ.get("APP_SECRET", "") - self.OPEN_ID = os.environ.get("OPEN_ID", "") - self.TEMPLATE_ID = os.environ.get("TEMPLATE_ID", "") - - # 城市配置,支持多个城市 - city_str = os.environ.get("CITIES", "吉安") - self.CITIES = [city.strip() for city in city_str.split(",")] - - # 其他配置 - self.ENABLE_AQI = os.environ.get("ENABLE_AQI", "true").lower() == "true" - self.ENABLE_LIFE_INDEX = os.environ.get("ENABLE_LIFE_INDEX", "true").lower() == "true" - self.ENABLE_HOURLY_FORECAST = os.environ.get("ENABLE_HOURLY_FORECAST", "false").lower() == "true" - - # 检查必要配置 - self._validate_config() - - def _validate_config(self): - """验证配置是否完整""" - required_configs = ["APP_ID", "APP_SECRET", "OPEN_ID", "TEMPLATE_ID"] - missing = [] - for key in required_configs: - if not getattr(self, key, ""): - missing.append(key) - - if missing: - logger.warning(f"缺少必要的环境变量: {', '.join(missing)}") - logger.warning("请设置以下环境变量:") - for key in missing: - logger.warning(f" {key}") - - if not self.CITIES: - logger.warning("未配置城市,使用默认城市: 吉安") - self.CITIES = ["吉安"] - -# 星期几枚举 -class Weekday(Enum): - MONDAY = "星期一" - TUESDAY = "星期二" - WEDNESDAY = "星期三" - THURSDAY = "星期四" - FRIDAY = "星期五" - SATURDAY = "星期六" - SUNDAY = "星期日" +import requests +from typing import Optional, Tuple, Dict, Any +from bs4 import BeautifulSoup -def get_current_weekday() -> str: - """获取当前星期几""" - today = datetime.date.today() - weekday_num = today.weekday() # 0=周一, 6=周日 - return list(Weekday)[weekday_num].value +# 常量定义(使用大写命名) +WEATHER_URLS = [ + "http://www.weather.com.cn/textFC/hb.shtml", # 华北 + "http://www.weather.com.cn/textFC/db.shtml", # 东北 + "http://www.weather.com.cn/textFC/hd.shtml", # 华东 + "http://www.weather.com.cn/textFC/hz.shtml", # 华中 + "http://www.weather.com.cn/textFC/hn.shtml", # 华南 + "http://www.weather.com.cn/textFC/xb.shtml", # 西北 + "http://www.weather.com.cn/textFC/xn.shtml", # 西南 +] -def get_date_info() -> Dict[str, str]: - """获取日期相关信息""" - today = datetime.date.today() - yesterday = today - datetime.timedelta(days=1) - tomorrow = today + datetime.timedelta(days=1) - - # 农历转换(简化版,实际使用需要农历库) - lunar_info = get_simple_lunar_date(today) - - return { - "date": today.strftime("%Y年%m月%d日"), - "weekday": get_current_weekday(), - "yesterday": yesterday.strftime("%Y-%m-%d"), - "tomorrow": tomorrow.strftime("%Y-%m-%d"), - "lunar_date": lunar_info, - "day_of_year": today.timetuple().tm_yday, - "week_number": today.isocalendar()[1] - } +# 从环境变量获取配置(增加默认值) +APP_ID = os.environ.get("APP_ID", "") +APP_SECRET = os.environ.get("APP_SECRET", "") +OPEN_ID = os.environ.get("OPEN_ID", "") +WEATHER_TEMPLATE_ID = os.environ.get("TEMPLATE_ID", "") -def get_simple_lunar_date(date_obj: datetime.date) -> str: - """获取简化的农历日期(实际使用时可接入农历库)""" - # 这里返回一个占位符,实际可以使用lunarcalendar等库 - return "农历信息" -def get_extended_weather(city_name: str) -> Dict[str, Any]: - """ - 获取扩展的天气信息,包括更多实用数据 +class WeatherFetcher: + """天气数据获取器""" - Args: - city_name: 城市名称 + @staticmethod + def fetch_weather_data(city: str) -> Optional[Tuple[str, str, str, str]]: + """ + 获取指定城市的天气信息 - Returns: - 包含扩展天气信息的字典 - """ - try: - # 这里可以接入更多天气API,如和风天气、OpenWeather等 - # 当前仍使用中国天气网,但可以添加更多信息 - - weather_data = get_weather_from_website(city_name) - if not weather_data: - return None - - # 添加更多计算的信息 - temp_range = weather_data.get("temperature", "0-0℃").replace("℃", "").split("-") - if len(temp_range) == 2: + Args: + city: 城市名称 + + Returns: + 元组 (城市名, 温度范围, 天气类型, 风向风力) + 如果未找到则返回None + """ + for url in WEATHER_URLS: try: - low_temp = int(temp_range[0]) - high_temp = int(temp_range[1]) - - # 计算温差 - temp_diff = high_temp - low_temp - - # 根据温度给出穿衣建议 - dressing_advice = get_dressing_advice(high_temp, low_temp) - - # 获取紫外线指数(模拟) - uv_index = get_uv_index(weather_data.get("weather_type", "")) - - # 获取空气质量(模拟,实际可接入API) - aqi_info = get_aqi_info(city_name) - - # 生活指数 - life_indices = get_life_indices(weather_data.get("weather_type", ""), - high_temp, low_temp) + response = requests.get(url, timeout=10) + response.raise_for_status() + response.encoding = 'utf-8' - # 小时预报(简化版) - hourly_forecast = get_hourly_forecast(weather_data.get("weather_type", "")) + soup = BeautifulSoup(response.text, 'html.parser') + div_con_midtab = soup.find("div", class_="conMidtab") - weather_data.update({ - "temp_diff": f"{temp_diff}℃", - "dressing_advice": dressing_advice, - "uv_index": uv_index, - "aqi": aqi_info, - "life_indices": life_indices, - "hourly_forecast": hourly_forecast, - "update_time": datetime.datetime.now().strftime("%H:%M") - }) + if not div_con_midtab: + continue - except ValueError: - logger.warning(f"温度解析失败: {weather_data.get('temperature')}") - - return weather_data + # 在找到的区域内搜索城市 + result = WeatherFetcher._search_city_in_tables(div_con_midtab, city) + if result: + return result + + except requests.RequestException as e: + print(f"请求天气数据失败 {url}: {e}") + continue - except Exception as e: - logger.error(f"获取扩展天气失败: {e}") + print(f"未找到城市 '{city}' 的天气信息") return None - -def get_weather_from_website(city_name: str) -> Dict[str, Any]: - """ - 从中国天气网获取天气信息 - Returns: - 天气信息字典 - """ - try: - urls = [ - "http://www.weather.com.cn/textFC/hb.shtml", - "http://www.weather.com.cn/textFC/db.shtml", - "http://www.weather.com.cn/textFC/hd.shtml", - "http://www.weather.com.cn/textFC/hz.shtml", - "http://www.weather.com.cn/textFC/hn.shtml", - "http://www.weather.com.cn/textFC/xb.shtml", - "http://www.weather.com.cn/textFC/xn.shtml" - ] - - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Referer': 'http://www.weather.com.cn/', - 'Accept-Language': 'zh-CN,zh;q=0.9' - } - - for url in urls: - try: - resp = requests.get(url, headers=headers, timeout=8) - resp.encoding = 'utf-8' - soup = BeautifulSoup(resp.text, 'html.parser') - - # 查找所有天气表格 - tables = soup.find_all('table') + @staticmethod + def _search_city_in_tables(div_con_midtab, target_city: str) -> Optional[Tuple[str, str, str, str]]: + """在表格中搜索特定城市的天气信息""" + tables = div_con_midtab.find_all("table") + + for table in tables: + # 跳过表头 + rows = table.find_all("tr")[2:] + + for row in rows: + cells = row.find_all("td") + if len(cells) < 8: + continue - for table in tables: - rows = table.find_all('tr') - for row in rows: - cols = row.find_all('td') - if len(cols) >= 8: - city_cell = cols[-8] - city_text = city_cell.get_text(strip=True) - - # 城市名称匹配(支持模糊匹配) - if city_name in city_text or city_text in city_name: - # 提取天气信息 - high_temp = cols[-5].get_text(strip=True) if len(cols) > 5 else '-' - low_temp = cols[-2].get_text(strip=True) if len(cols) > 2 else '-' - weather_day = cols[-7].get_text(strip=True) if len(cols) > 7 else '-' - weather_night = cols[-4].get_text(strip=True) if len(cols) > 4 else '-' - - # 风力信息 - wind_day = cols[-6].get_text(strip=True) if len(cols) > 6 else '-' - wind_night = cols[-3].get_text(strip=True) if len(cols) > 3 else '-' - - # 湿度信息(某些版本可能提供) - humidity = '-' - if len(cols) > 9: - humidity = cols[-9].get_text(strip=True) - - # 构建天气数据 - temperature = f"{low_temp}~{high_temp}℃" - weather_type = weather_day if weather_day != '-' else weather_night - wind = wind_day if wind_day != '-' else wind_night - - return { - "city": city_text, - "temperature": temperature, - "weather_type": weather_type, - "wind": wind, - "humidity": humidity, - "high_temp": high_temp.replace('℃', ''), - "low_temp": low_temp.replace('℃', '') - } - - except Exception as e: - logger.debug(f"尝试URL {url} 失败: {e}") - continue + # 获取城市名(从倒数第8个单元格) + city_cell = cells[-8] + city_name = city_cell.get_text(strip=True) - return None + if city_name == target_city: + return WeatherFetcher._extract_weather_data(cells, city_name) - except Exception as e: - logger.error(f"获取天气失败: {e}") return None - -def get_dressing_advice(high_temp: int, low_temp: int) -> str: - """根据温度给出穿衣建议""" - avg_temp = (high_temp + low_temp) / 2 - - if avg_temp >= 28: - return "天气炎热,建议穿短袖、短裤、薄裙" - elif 23 <= avg_temp < 28: - return "天气较热,建议穿短袖、薄外套" - elif 18 <= avg_temp < 23: - return "温度适宜,建议穿单层棉麻面料的短套装、T恤衫" - elif 10 <= avg_temp < 18: - return "天气微凉,建议穿套装、夹衣、风衣、休闲装" - elif 0 <= avg_temp < 10: - return "天气较冷,建议穿厚外套、毛衣、毛呢大衣" - else: - return "天气寒冷,建议穿羽绒服、棉衣、厚毛衣" - -def get_uv_index(weather_type: str) -> str: - """获取紫外线指数(简化版)""" - weather_type_lower = weather_type.lower() - - if any(word in weather_type_lower for word in ['晴', '多云', '少云']): - uv_levels = ["中等", "强", "很强"] - return f"紫外线{random.choice(uv_levels)}" - elif any(word in weather_type_lower for word in ['阴', '雨', '雪']): - return "紫外线弱" - else: - return "紫外线中等" - -def get_aqi_info(city_name: str) -> Dict[str, str]: - """获取空气质量信息(简化版,实际可接入API)""" - # 模拟空气质量数据 - aqi_levels = ["优", "良", "轻度污染", "中度污染", "重度污染"] - aqi_values = [random.randint(10, 50), random.randint(51, 100), - random.randint(101, 150), random.randint(151, 200), - random.randint(201, 300)] - - level = random.choice(aqi_levels) - idx = aqi_levels.index(level) - value = aqi_values[idx] - - return { - "level": level, - "value": str(value), - "primary_pollutant": random.choice(["PM2.5", "PM10", "O3", "NO2"]) - } - -def get_life_indices(weather_type: str, high_temp: int, low_temp: int) -> Dict[str, str]: - """获取生活指数""" - indices = {} - - # 洗车指数 - if "雨" in weather_type or "雪" in weather_type: - indices["car_wash"] = "不适宜" - else: - indices["car_wash"] = "适宜" - # 运动指数 - if "雨" in weather_type or "雪" in weather_type or high_temp > 35 or low_temp < 0: - indices["sport"] = "较不宜" - else: - indices["sport"] = "适宜" - - # 感冒指数 - if high_temp - low_temp > 10: - indices["cold"] = "易发" - else: - indices["cold"] = "少发" - - # 舒适度指数 - if 18 <= (high_temp + low_temp) / 2 <= 26: - indices["comfort"] = "舒适" - else: - indices["comfort"] = "不舒适" - - # 钓鱼指数(随机) - fishing = ["适宜", "较适宜", "不宜"] - indices["fishing"] = random.choice(fishing) - - return indices - -def get_hourly_forecast(weather_type: str) -> List[Dict[str, str]]: - """获取小时预报(简化版)""" - forecast = [] - current_hour = datetime.datetime.now().hour - - for i in range(4): # 未来4小时的简化预报 - hour = (current_hour + i) % 24 - temp_change = random.randint(-2, 2) + @staticmethod + def _extract_weather_data(cells, city_name: str) -> Tuple[str, str, str, str]: + """从表格单元格中提取天气数据""" + # 提取各个数据字段 + high_temp = cells[-5].get_text(strip=True) + low_temp = cells[-2].get_text(strip=True) + weather_day = cells[-7].get_text(strip=True) + weather_night = cells[-4].get_text(strip=True) + wind_day = cells[-6].get_text(strip=True) + wind_night = cells[-3].get_text(strip=True) + + # 处理温度显示 + if high_temp != "-" and low_temp != "-": + temperature = f"{low_temp}~{high_temp}°C" + else: + temperature = f"{low_temp}°C" if low_temp != "-" else "温度数据缺失" + + # 处理天气类型(优先使用白天数据) + weather_type = weather_day if weather_day != "-" else weather_night + if weather_type == "-": + weather_type = "天气数据缺失" + + # 处理风向风力 + if wind_day and wind_day != "--": + wind = wind_day + elif wind_night and wind_night != "--": + wind = wind_night + else: + wind = "风力数据缺失" - forecast.append({ - "time": f"{hour:02d}:00", - "temp": f"{20 + temp_change}℃", # 基准20度 - "weather": weather_type, - "wind": f"{random.randint(1, 3)}级" - }) - - return forecast + return city_name, temperature, weather_type, wind -def get_daily_tips(date_info: Dict[str, str]) -> List[str]: - """获取每日小贴士""" - tips = [] - - # 根据星期几的贴士 - weekday_tips = { - "星期一": ["新的一周开始啦,加油!", "周一综合症?来杯咖啡提提神"], - "星期二": ["工作渐入佳境,保持节奏", "记得起身活动,保护颈椎"], - "星期三": ["一周过半,坚持就是胜利", "适当放松,劳逸结合"], - "星期四": ["黎明前的黑暗,加油", "可以开始规划周末活动了"], - "星期五": ["明天就周末啦,坚持一下", "晚上可以适当放松一下"], - "星期六": ["周末愉快!好好休息", "适合户外活动的好时机"], - "星期日": ["周末最后一天,好好享受", "明天要上班,记得早睡哦"] - } - - if date_info["weekday"] in weekday_tips: - tips.append(random.choice(weekday_tips[date_info["weekday"]])) - - # 通用健康贴士 - health_tips = [ - "每天八杯水,健康永相随", - "早睡早起身体好", - "多吃蔬菜水果,补充维生素", - "适当运动,增强免疫力", - "保持好心情,健康最重要" - ] - tips.append(random.choice(health_tips)) - - # 天气相关贴士 - weather_tips = [ - "出门记得看天气,有备无患", - "天气变化大,注意增减衣物", - "空气质量不佳时,减少户外活动" - ] - tips.append(random.choice(weather_tips)) - - return tips -def get_access_token(): - """ - 获取微信公众号access_token - Returns: - str: access_token 或 None - """ - try: - config = Config() - app_id = config.APP_ID - app_secret = config.APP_SECRET - - if not app_id or not app_secret: - logger.error("未配置APP_ID或APP_SECRET") - return None - - url = f'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={app_id}&secret={app_secret}' - response = requests.get(url, timeout=8) - response.raise_for_status() - result = response.json() - - if 'access_token' in result: - logger.info("获取access_token成功") - return result.get('access_token') - else: - error_msg = result.get('errmsg', '未知错误') - logger.error(f"获取access_token失败:{error_msg}") - return None - - except Exception as e: - logger.error(f"获取access_token失败:{e}") - return None - -def get_daily_love(date_info: Dict[str, str]) -> str: - """ - 获取每日情话,增加更多来源 +class WeChatAPI: + """微信API接口封装""" - Returns: - str: 情话内容 - """ - try: - # 多个情话API源 - api_sources = [ - { - "url": "https://api.lovelive.tools/api/SweetNothings", - "parser": lambda data: data.get('returnObj', '') if isinstance(data, dict) else data - }, - { - "url": "https://v1.hitokoto.cn/", - "parser": lambda data: f"{data.get('hitokoto', '')} ——{data.get('from', '')}" - }, - { - "url": "https://api.uomg.com/api/rand.qinghua", - "parser": lambda data: data.get('content', '') if isinstance(data, dict) else '' - } - ] + @staticmethod + def get_access_token() -> Optional[str]: + """ + 获取微信access_token + + Returns: + access_token字符串,失败返回None + """ + if not APP_ID or not APP_SECRET: + print("错误:APP_ID或APP_SECRET未配置") + return None - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Accept': 'application/json' + url = f"https://api.weixin.qq.com/cgi-bin/token" + params = { + "grant_type": "client_credential", + "appid": APP_ID.strip(), + "secret": APP_SECRET.strip() } - for api in api_sources: - try: - response = requests.get(api["url"], headers=headers, timeout=5) - response.raise_for_status() - data = response.json() + try: + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + if 'access_token' in data: + return data['access_token'] + else: + print(f"获取access_token失败: {data}") + return None - sentence = api["parser"](data) - if sentence and len(sentence.strip()) > 0: - # 添加日期相关的个性化 - if "星期" in date_info["weekday"]: - sentence = f"{date_info['weekday']}的问候:{sentence}" - - # 限制长度 - if len(sentence) > 100: - sentence = sentence[:97] + "..." - - logger.info(f"获取情话成功:{sentence[:30]}...") - return sentence - - except Exception as e: - logger.debug(f"情话API {api['url']} 失败:{e}") - continue - - # 如果所有API都失败,使用备用情话 - backup_sentences = [ - f"{date_info['weekday']}也要保持好心情呀!", - "每天都要开心哦!", - "记得微笑,今天又是美好的一天!", - "照顾好自己,比什么都重要~", - "愿你今天有阳光般的好心情!" - ] - - return random.choice(backup_sentences) - - except Exception as e: - logger.error(f"获取情话失败:{e}") - return "每天都要开心哦!" - -def send_weather_message(access_token: str, weather_data: Dict[str, Any], - date_info: Dict[str, str], city_name: str) -> bool: - """ - 发送天气预报消息 + except requests.RequestException as e: + print(f"请求access_token失败: {e}") + return None - Returns: - bool: 是否成功发送 - """ - try: - config = Config() - - if not weather_data: - logger.error("天气数据为空") + @staticmethod + def send_weather_message(access_token: str, weather_data: Tuple, daily_note: str) -> bool: + """ + 发送天气模板消息 + + Args: + access_token: 微信访问令牌 + weather_data: 天气数据元组 (城市, 温度, 天气, 风力) + daily_note: 每日寄语 + + Returns: + 发送是否成功 + """ + if not access_token or not OPEN_ID or not WEATHER_TEMPLATE_ID: + print("错误:必要的配置参数缺失") return False - # 获取情话 - daily_love = get_daily_love(date_info) + # 准备消息数据 + message_data = WeChatAPI._build_message_data(weather_data, daily_note) - # 获取小贴士 - daily_tips = get_daily_tips(date_info) - tips_text = ";".join(daily_tips[:3]) # 取前3条 + url = f"https://api.weixin.qq.com/cgi-bin/message/template/send" + params = {"access_token": access_token} - # 构建消息数据 - message_data = { - "touser": config.OPEN_ID, - "template_id": config.TEMPLATE_ID, - "url": "https://mp.weixin.qq.com", + try: + response = requests.post(url, params=params, json=message_data, timeout=10) + response.raise_for_status() + result = response.json() + + if result.get('errcode') == 0: + print("消息发送成功") + return True + else: + print(f"消息发送失败: {result}") + return False + + except requests.RequestException as e: + print(f"发送消息失败: {e}") + return False + + @staticmethod + def _build_message_data(weather_data: Tuple, daily_note: str) -> Dict[str, Any]: + """构建微信模板消息数据""" + today_str = datetime.date.today().strftime("%Y年%m月%d日") + + return { + "touser": OPEN_ID.strip(), + "template_id": WEATHER_TEMPLATE_ID.strip(), + "url": "https://mp.weixin.qq.com", # 更合适的跳转链接 "data": { - # 基础信息 - "date": { - "value": f"{date_info['date']} {date_info['weekday']}", - "color": "#173177" - }, - "city": { - "value": city_name, - "color": "#173177" - }, - # 天气信息 - "weather": { - "value": weather_data.get("weather_type", "未知"), - "color": "#FF0000" - }, - "temperature": { - "value": weather_data.get("temperature", "未知"), - "color": "#FF4500" - }, - "wind": { - "value": weather_data.get("wind", "未知"), - "color": "#4682B4" - }, - "humidity": { - "value": weather_data.get("humidity", "未知"), - "color": "#4682B4" - }, - # 扩展信息 - "temp_diff": { - "value": weather_data.get("temp_diff", "未知"), - "color": "#FF6347" - }, - "uv_index": { - "value": weather_data.get("uv_index", "未知"), - "color": "#FF8C00" - }, - # 空气质量 - "aqi": { - "value": f"{weather_data.get('aqi', {}).get('level', '未知')} ({weather_data.get('aqi', {}).get('value', '')})", - "color": "#32CD32" if weather_data.get('aqi', {}).get('level') == '优' else "#FFD700" - }, - # 生活指数 - "dressing": { - "value": weather_data.get("dressing_advice", "请根据温度穿衣"), - "color": "#8A2BE2" - }, - "car_wash": { - "value": weather_data.get("life_indices", {}).get("car_wash", "未知"), - "color": "#1E90FF" - }, - "sport": { - "value": weather_data.get("life_indices", {}).get("sport", "未知"), - "color": "#1E90FF" - }, - # 温馨提示 - "love_note": { - "value": daily_love, - "color": "#FF69B4" - }, - "daily_tips": { - "value": tips_text, - "color": "#2E8B57" - }, - "update_time": { - "value": f"更新时间:{weather_data.get('update_time', '')}", - "color": "#808080" - } + "date": {"value": today_str, "color": "#173177"}, + "region": {"value": weather_data[0], "color": "#173177"}, + "weather": {"value": weather_data[2], "color": "#173177"}, + "temp": {"value": weather_data[1], "color": "#FF0000"}, + "wind_dir": {"value": weather_data[3], "color": "#173177"}, + "today_note": {"value": daily_note, "color": "#FF00FF"} } } - - # 发送请求 - url = f'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={access_token}' - response = requests.post(url, json=message_data, timeout=10) - response.raise_for_status() - result = response.json() - - if result.get('errcode') == 0: - logger.info(f"天气预报发送成功 (消息ID: {result.get('msgid')})") - return True - else: - error_msg = result.get('errmsg', '未知错误') - logger.error(f"发送失败:{error_msg}") - return False - - except Exception as e: - logger.error(f"发送天气预报失败:{e}") - return False -def main(): - """主函数""" - try: - logger.info("=" * 60) - logger.info("天气预报推送服务启动") - logger.info(f"启动时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - logger.info("=" * 60) + +class DailyInspiration: + """每日寄语获取""" + + @staticmethod + def get_daily_inspiration() -> str: + """ + 获取每日一句情话/寄语 + + Returns: + 寄语字符串,失败时返回默认寄语 + """ + url = "https://api.lovelive.tools/api/SweetNothings/Serialization/Json" + + try: + response = requests.get(url, timeout=5) + response.raise_for_status() + data = response.json() + + if data and 'returnObj' in data and data['returnObj']: + return data['returnObj'][0] + except Exception as e: + print(f"获取每日寄语失败: {e}") - # 初始化配置 - config = Config() + # 备用寄语 + return "愿你的一天充满阳光和微笑!" + + +class WeatherReporter: + """天气报告主控制器""" + + def __init__(self): + self.weather_fetcher = WeatherFetcher() + self.wechat_api = WeChatAPI() + self.daily_inspiration = DailyInspiration() + + def report_weather(self, city: str) -> bool: + """ + 执行完整的天气报告流程 - # 检查必要配置 - required_configs = ["APP_ID", "APP_SECRET", "OPEN_ID", "TEMPLATE_ID"] - for key in required_configs: - if not getattr(config, key, ""): - logger.error(f"缺少必要配置: {key}") - return False + Args: + city: 城市名称 + + Returns: + 是否成功执行 + """ + print(f"开始获取 {city} 的天气信息...") - logger.info(f"服务配置: 城市={config.CITIES}, AQI={config.ENABLE_AQI}, 生活指数={config.ENABLE_LIFE_INDEX}") + # 1. 获取天气数据 + weather_data = self.weather_fetcher.fetch_weather_data(city) + if not weather_data: + return False - # 获取日期信息 - date_info = get_date_info() - logger.info(f"当前日期: {date_info['date']} {date_info['weekday']}") + print(f"天气信息获取成功: {weather_data}") - # 获取access_token - access_token = get_access_token() + # 2. 获取access_token + access_token = self.wechat_api.get_access_token() if not access_token: - logger.error("获取access_token失败") return False - # 遍历所有城市 - success_count = 0 - for city in config.CITIES: - logger.info(f"开始处理城市: {city}") - - # 获取天气信息 - weather_data = get_extended_weather(city) - if not weather_data: - logger.error(f"获取{city}天气信息失败") - continue - - logger.info(f"获取到{city}天气: {weather_data.get('weather_type', '未知')} {weather_data.get('temperature', '未知')}") - - # 发送消息 - success = send_weather_message(access_token, weather_data, date_info, city) - if success: - success_count += 1 - logger.info(f"{city}天气预报发送成功") - else: - logger.error(f"{city}天气预报发送失败") - - # 城市间延迟,避免请求过快 - if len(config.CITIES) > 1 and city != config.CITIES[-1]: - time.sleep(2) - - # 汇总结果 - logger.info("=" * 60) - logger.info(f"任务完成: 成功{success_count}/{len(config.CITIES)}个城市") - logger.info(f"完成时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - logger.info("=" * 60) + # 3. 获取每日寄语 + daily_note = self.daily_inspiration.get_daily_inspiration() + print(f"每日寄语: {daily_note}") - return success_count > 0 + # 4. 发送微信消息 + success = self.wechat_api.send_weather_message(access_token, weather_data, daily_note) - except Exception as e: - logger.error(f"主程序错误: {e}", exc_info=True) - return False + return success -if __name__ == '__main__': - # 设置环境变量示例(实际使用时请设置真实值): - # export APP_ID="your_app_id" - # export APP_SECRET="your_app_secret" - # export OPEN_ID="your_open_id" - # export TEMPLATE_ID="your_template_id" - # export CITIES="吉安,南昌" # 支持多个城市 - # export ENABLE_AQI="true" - # export ENABLE_LIFE_INDEX="true" + +def main(): + """主函数""" + # 可以改为从命令行参数或配置文件读取城市 + city = "吉安" + + # 检查必要的环境变量 + required_env_vars = ["APP_ID", "APP_SECRET", "OPEN_ID", "TEMPLATE_ID"] + missing_vars = [var for var in required_env_vars if not os.environ.get(var)] + + if missing_vars: + print(f"错误:缺少必要的环境变量: {', '.join(missing_vars)}") + print("请设置以下环境变量:") + for var in missing_vars: + print(f" export {var}='your_value'") + return + + # 创建并运行天气报告器 + reporter = WeatherReporter() + success = reporter.report_weather(city) - success = main() - exit(0 if success else 1) + if success: + print("天气报告发送完成!") + else: + print("天气报告发送失败!") + + +if __name__ == '__main__': + main() From ac7bbddeabc004d8349a4279f49b1785f70c9bdc Mon Sep 17 00:00:00 2001 From: tie4453 Date: Sun, 30 Aug 2026 05:38:13 +0800 Subject: [PATCH 17/21] Update weather_report.py --- weather_report.py | 1378 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 1122 insertions(+), 256 deletions(-) diff --git a/weather_report.py b/weather_report.py index 8e2db4a8..63ec80b5 100644 --- a/weather_report.py +++ b/weather_report.py @@ -1,318 +1,1184 @@ +```python """ 微信天气推送服务 -功能:从天气网站抓取数据,通过微信模板消息推送给指定用户 +================ + +功能: +1. 从中国天气网获取指定城市当天的天气预报 +2. 获取微信公众号 access_token +3. 获取每日寄语 +4. 通过微信公众号模板消息发送天气信息 + +运行: + python weather_push.py + python weather_push.py --city 吉安 + +环境变量: + APP_ID 微信公众号 AppID + APP_SECRET 微信公众号 AppSecret + OPEN_ID 接收消息的用户 OpenID + TEMPLATE_ID 微信模板 ID + +可选环境变量: + CITY 默认城市,默认:吉安 + REQUEST_TIMEOUT 请求超时时间,默认:10 + NOTE_TIMEOUT 每日寄语请求超时时间,默认:5 + TZ 时区,默认:Asia/Shanghai """ +from __future__ import annotations + +import argparse +import datetime as dt +import logging import os -import json -import datetime +import re +import threading +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple +from zoneinfo import ZoneInfo + import requests -from typing import Optional, Tuple, Dict, Any from bs4 import BeautifulSoup +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + + +# ============================================================ +# 日志配置 +# ============================================================ + +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s | %(levelname)s | %(message)s", +) + +logger = logging.getLogger("weather_push") + + +# ============================================================ +# 常量 +# ============================================================ -# 常量定义(使用大写命名) WEATHER_URLS = [ - "http://www.weather.com.cn/textFC/hb.shtml", # 华北 - "http://www.weather.com.cn/textFC/db.shtml", # 东北 - "http://www.weather.com.cn/textFC/hd.shtml", # 华东 - "http://www.weather.com.cn/textFC/hz.shtml", # 华中 - "http://www.weather.com.cn/textFC/hn.shtml", # 华南 - "http://www.weather.com.cn/textFC/xb.shtml", # 西北 - "http://www.weather.com.cn/textFC/xn.shtml", # 西南 + "https://www.weather.com.cn/textFC/hb.shtml", # 华北 + "https://www.weather.com.cn/textFC/db.shtml", # 东北 + "https://www.weather.com.cn/textFC/hd.shtml", # 华东 + "https://www.weather.com.cn/textFC/hz.shtml", # 华中 + "https://www.weather.com.cn/textFC/hn.shtml", # 华南 + "https://www.weather.com.cn/textFC/xb.shtml", # 西北 + "https://www.weather.com.cn/textFC/xn.shtml", # 西南 ] -# 从环境变量获取配置(增加默认值) -APP_ID = os.environ.get("APP_ID", "") -APP_SECRET = os.environ.get("APP_SECRET", "") -OPEN_ID = os.environ.get("OPEN_ID", "") -WEATHER_TEMPLATE_ID = os.environ.get("TEMPLATE_ID", "") +WECHAT_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token" + +WECHAT_TEMPLATE_SEND_URL = ( + "https://api.weixin.qq.com/cgi-bin/message/template/send" +) + +INSPIRATION_URL = ( + "https://api.lovelive.tools/api/SweetNothings/Serialization/Json" +) + +DEFAULT_WEATHER_URL = "https://www.weather.com.cn/" + +DEFAULT_CITY = "吉安" +DEFAULT_TIMEZONE = "Asia/Shanghai" + +DEFAULT_REQUEST_TIMEOUT = 10 +DEFAULT_NOTE_TIMEOUT = 5 + +DEFAULT_NOTE = "愿你的一天充满阳光和微笑!" + +USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0 Safari/537.36" +) + + +# ============================================================ +# 配置 +# ============================================================ + +@dataclass(frozen=True) +class Config: + """应用配置。""" + + app_id: str + app_secret: str + open_id: str + template_id: str + + city: str = DEFAULT_CITY + timezone: str = DEFAULT_TIMEZONE + + request_timeout: int = DEFAULT_REQUEST_TIMEOUT + note_timeout: int = DEFAULT_NOTE_TIMEOUT + + @classmethod + def from_env(cls) -> "Config": + """从环境变量读取配置。""" + + return cls( + app_id=os.getenv("APP_ID", "").strip(), + app_secret=os.getenv("APP_SECRET", "").strip(), + open_id=os.getenv("OPEN_ID", "").strip(), + template_id=os.getenv("TEMPLATE_ID", "").strip(), + city=os.getenv("CITY", DEFAULT_CITY).strip() or DEFAULT_CITY, + timezone=os.getenv("TZ", DEFAULT_TIMEZONE).strip() + or DEFAULT_TIMEZONE, + request_timeout=_read_positive_int( + "REQUEST_TIMEOUT", + DEFAULT_REQUEST_TIMEOUT, + ), + note_timeout=_read_positive_int( + "NOTE_TIMEOUT", + DEFAULT_NOTE_TIMEOUT, + ), + ) + + def validate(self) -> None: + """检查必要配置。""" + + missing = [] + + if not self.app_id: + missing.append("APP_ID") + + if not self.app_secret: + missing.append("APP_SECRET") + + if not self.open_id: + missing.append("OPEN_ID") + + if not self.template_id: + missing.append("TEMPLATE_ID") + + if missing: + raise RuntimeError( + "缺少必要的环境变量:" + + ", ".join(missing) + ) + + try: + ZoneInfo(self.timezone) + except Exception as exc: + raise RuntimeError( + f"无效时区配置:{self.timezone}" + ) from exc + + +def _read_positive_int(name: str, default: int) -> int: + """读取正整数环境变量。""" + + value = os.getenv(name) + + if not value: + return default + + try: + result = int(value) + + if result <= 0: + raise ValueError + + return result + + except ValueError: + logger.warning( + "%s=%r 不是有效正整数,使用默认值 %s", + name, + value, + default, + ) + return default + + +# ============================================================ +# HTTP Session +# ============================================================ + +def create_session() -> requests.Session: + """创建带连接池和重试机制的 HTTP Session。""" + + session = requests.Session() + + retry = Retry( + total=3, + connect=3, + read=3, + backoff_factor=0.5, + status_forcelist=(429, 500, 502, 503, 504), + allowed_methods=frozenset({"GET", "POST"}), + raise_on_status=False, + ) + + adapter = HTTPAdapter( + max_retries=retry, + pool_connections=10, + pool_maxsize=10, + ) + + session.mount("http://", adapter) + session.mount("https://", adapter) + + session.headers.update( + { + "User-Agent": USER_AGENT, + "Accept": ( + "text/html,application/xhtml+xml," + "application/json;q=0.9,*/*;q=0.8" + ), + } + ) + + return session + + +# ============================================================ +# 数据模型 +# ============================================================ + +@dataclass(frozen=True) +class WeatherData: + """天气数据。""" + + city: str + temperature: str + weather: str + wind: str + date: dt.date + + def as_tuple(self) -> Tuple[str, str, str, str]: + """兼容原来的元组结构。""" + + return ( + self.city, + self.temperature, + self.weather, + self.wind, + ) + + +# ============================================================ +# 工具函数 +# ============================================================ + +def get_today(timezone_name: str) -> dt.date: + """按照指定时区获取今天的日期。""" + + timezone = ZoneInfo(timezone_name) + + return dt.datetime.now(timezone).date() + + +def normalize_city_name(city: str) -> str: + """ + 标准化城市名称。 + + 例如: + 吉安 -> 吉安 + 吉安市 -> 吉安 + 北京市 -> 北京 + """ + + city = re.sub(r"\s+", "", city.strip()) + + suffixes = ( + "特别行政区", + "自治州", + "地区", + "盟", + "市", + ) + + for suffix in suffixes: + if city.endswith(suffix): + city = city[: -len(suffix)] + break + + return city + + +def clean_text(value: str) -> str: + """清洗 HTML 文本。""" + + value = value.replace("\xa0", " ") + value = value.replace("\u3000", " ") + + return re.sub(r"\s+", " ", value).strip() + + +def is_missing(value: str) -> bool: + """判断天气字段是否为空。""" + + value = clean_text(value) + + return value in { + "", + "-", + "--", + "---", + "暂无", + "无", + } + +# ============================================================ +# 天气抓取 +# ============================================================ class WeatherFetcher: - """天气数据获取器""" - - @staticmethod - def fetch_weather_data(city: str) -> Optional[Tuple[str, str, str, str]]: + """中国天气网天气获取器。""" + + def __init__( + self, + session: requests.Session, + timeout: int = DEFAULT_REQUEST_TIMEOUT, + ) -> None: + self.session = session + self.timeout = timeout + + def fetch_weather_data( + self, + city: str, + target_date: Optional[dt.date] = None, + ) -> Optional[WeatherData]: """ - 获取指定城市的天气信息 - - Args: - city: 城市名称 - - Returns: - 元组 (城市名, 温度范围, 天气类型, 风向风力) - 如果未找到则返回None + 获取指定城市指定日期的天气。 + + 如果 target_date 未提供,则使用当天日期。 """ + + city = city.strip() + + if not city: + logger.error("城市名称不能为空") + return None + + if target_date is None: + target_date = dt.date.today() + + normalized_target = normalize_city_name(city) + + logger.info( + "开始获取天气:城市=%s,目标日期=%s", + city, + target_date.isoformat(), + ) + for url in WEATHER_URLS: try: - response = requests.get(url, timeout=10) + logger.debug("请求天气页面:%s", url) + + response = self.session.get( + url, + timeout=self.timeout, + ) + response.raise_for_status() - response.encoding = 'utf-8' - - soup = BeautifulSoup(response.text, 'html.parser') - div_con_midtab = soup.find("div", class_="conMidtab") - - if not div_con_midtab: - continue - - # 在找到的区域内搜索城市 - result = WeatherFetcher._search_city_in_tables(div_con_midtab, city) + + # 中国天气网当前页面是 UTF-8。 + response.encoding = response.apparent_encoding or "utf-8" + + soup = BeautifulSoup( + response.text, + "html.parser", + ) + + result = self._search_city( + soup=soup, + target_city=normalized_target, + target_date=target_date, + ) + if result: + logger.info( + "成功获取天气:%s", + result, + ) return result - - except requests.RequestException as e: - print(f"请求天气数据失败 {url}: {e}") + + except requests.RequestException as exc: + logger.warning( + "请求天气页面失败:%s | %s", + url, + exc, + ) + + except Exception: + logger.exception( + "解析天气页面时发生异常:%s", + url, + ) + + logger.error( + "所有天气页面都没有找到城市:%s,日期:%s", + city, + target_date, + ) + + return None + + def _search_city( + self, + soup: BeautifulSoup, + target_city: str, + target_date: dt.date, + ) -> Optional[WeatherData]: + """ + 在页面中查找目标日期对应的城市。 + + 中国天气网页面按日期组织多组 table。 + 不再简单使用 table.find_all("tr")[2:]。 + """ + + tables = soup.find_all("table") + + if not tables: + logger.warning("页面中没有找到 table") + return None + + # 当前页面通常以“周X(月日)”作为日期标题。 + target_date_text = ( + f"({target_date.month}月{target_date.day}日)" + ) + + for table in tables: + rows = table.find_all("tr") + + if len(rows) < 2: + continue + + header_text = clean_text( + table.get_text(" ", strip=True) + ) + + # 判断这个 table 是否属于目标日期。 + # + # 页面日期可能出现在表头附近,例如: + # 周日(8月30日)白天 + # 周日(8月30日)夜间 + if target_date_text not in header_text: + continue + + result = self._search_city_in_table( + table=table, + target_city=target_city, + target_date=target_date, + ) + + if result: + return result + + # 如果页面结构变化导致无法识别日期, + # 再使用第二套兼容解析方式。 + logger.warning( + "没有通过日期表头找到数据,尝试兼容解析:%s", + target_city, + ) + + return self._fallback_search( + soup=soup, + target_city=target_city, + target_date=target_date, + ) + + def _search_city_in_table( + self, + table, + target_city: str, + target_date: dt.date, + ) -> Optional[WeatherData]: + """在一个日期对应的表格中搜索城市。""" + + rows = table.find_all("tr") + + for row in rows: + cells = row.find_all("td") + + # 当前中国天气网文字版表格: + # + # 省/城市 + # 城市 + # 白天天气 + # 白天风力 + # 最高气温 + # 夜间天气 + # 夜间风力 + # 最低气温 + # + # 最后还可能存在“详情”单元格。 + if len(cells) < 8: + continue + + values = [ + clean_text(cell.get_text(" ", strip=True)) + for cell in cells + ] + + # 去掉最后的“详情”字段。 + if values and values[-1] == "详情": + values = values[:-1] + + if len(values) < 7: continue - - print(f"未找到城市 '{city}' 的天气信息") + + # 正常情况下: + # [省, 城市, 白天天气, 白天风力, + # 最高温, 夜间天气, 夜间风力, 最低温] + city_candidates = values[:2] + + if not any( + self._city_matches( + candidate, + target_city, + ) + for candidate in city_candidates + ): + continue + + return self._build_weather_data( + values=values, + target_date=target_date, + ) + return None - + + @staticmethod + def _city_matches( + actual: str, + target: str, + ) -> bool: + """判断城市名称是否匹配。""" + + actual_normalized = normalize_city_name(actual) + target_normalized = normalize_city_name(target) + + return ( + actual_normalized == target_normalized + or actual_normalized.endswith(target_normalized) + or target_normalized.endswith(actual_normalized) + ) + + def _build_weather_data( + self, + values, + target_date: dt.date, + ) -> WeatherData: + """根据天气表格字段构建 WeatherData。""" + + # 通常情况下 values 为: + # + # 0 省 + # 1 城市 + # 2 白天天气 + # 3 白天风力 + # 4 最高温 + # 5 夜间天气 + # 6 夜间风力 + # 7 最低温 + + city_name = values[1] + + day_weather = values[2] if len(values) > 2 else "-" + day_wind = values[3] if len(values) > 3 else "-" + high_temp = values[4] if len(values) > 4 else "-" + + night_weather = values[5] if len(values) > 5 else "-" + night_wind = values[6] if len(values) > 6 else "-" + low_temp = values[7] if len(values) > 7 else "-" + + # 天气: + # 优先白天,白天无数据则使用夜间。 + if not is_missing(day_weather): + weather = day_weather + elif not is_missing(night_weather): + weather = night_weather + else: + weather = "天气数据缺失" + + # 温度。 + high_temp = self._clean_temperature(high_temp) + low_temp = self._clean_temperature(low_temp) + + if ( + not is_missing(high_temp) + and not is_missing(low_temp) + ): + temperature = f"{low_temp}~{high_temp}℃" + + elif not is_missing(low_temp): + temperature = f"{low_temp}℃" + + elif not is_missing(high_temp): + temperature = f"{high_temp}℃" + + else: + temperature = "温度数据缺失" + + # 风力。 + if not is_missing(day_wind): + wind = day_wind + elif not is_missing(night_wind): + wind = night_wind + else: + wind = "风力数据缺失" + + return WeatherData( + city=city_name, + temperature=temperature, + weather=weather, + wind=wind, + date=target_date, + ) + @staticmethod - def _search_city_in_tables(div_con_midtab, target_city: str) -> Optional[Tuple[str, str, str, str]]: - """在表格中搜索特定城市的天气信息""" - tables = div_con_midtab.find_all("table") - + def _clean_temperature(value: str) -> str: + """清洗温度数据。""" + + value = clean_text(value) + + value = value.replace("℃", "") + value = value.replace("°C", "") + value = value.replace("°", "") + + return value + + def _fallback_search( + self, + soup: BeautifulSoup, + target_city: str, + target_date: dt.date, + ) -> Optional[WeatherData]: + """ + 兼容解析。 + + 如果页面结构变化导致无法识别日期, + 尝试从所有表格中寻找城市。 + + 注意: + 这只是兜底方案,优先使用日期匹配。 + """ + + tables = soup.find_all("table") + for table in tables: - # 跳过表头 - rows = table.find_all("tr")[2:] - + rows = table.find_all("tr") + for row in rows: cells = row.find_all("td") + if len(cells) < 8: continue - - # 获取城市名(从倒数第8个单元格) - city_cell = cells[-8] - city_name = city_cell.get_text(strip=True) - - if city_name == target_city: - return WeatherFetcher._extract_weather_data(cells, city_name) - + + values = [ + clean_text(cell.get_text(" ", strip=True)) + for cell in cells + ] + + if values and values[-1] == "详情": + values = values[:-1] + + if len(values) < 7: + continue + + if not any( + self._city_matches( + candidate, + target_city, + ) + for candidate in values[:2] + ): + continue + + logger.warning( + "使用兼容解析获取 %s," + "请注意日期结构可能发生变化。", + target_city, + ) + + return self._build_weather_data( + values=values, + target_date=target_date, + ) + return None - - @staticmethod - def _extract_weather_data(cells, city_name: str) -> Tuple[str, str, str, str]: - """从表格单元格中提取天气数据""" - # 提取各个数据字段 - high_temp = cells[-5].get_text(strip=True) - low_temp = cells[-2].get_text(strip=True) - weather_day = cells[-7].get_text(strip=True) - weather_night = cells[-4].get_text(strip=True) - wind_day = cells[-6].get_text(strip=True) - wind_night = cells[-3].get_text(strip=True) - - # 处理温度显示 - if high_temp != "-" and low_temp != "-": - temperature = f"{low_temp}~{high_temp}°C" - else: - temperature = f"{low_temp}°C" if low_temp != "-" else "温度数据缺失" - - # 处理天气类型(优先使用白天数据) - weather_type = weather_day if weather_day != "-" else weather_night - if weather_type == "-": - weather_type = "天气数据缺失" - - # 处理风向风力 - if wind_day and wind_day != "--": - wind = wind_day - elif wind_night and wind_night != "--": - wind = wind_night - else: - wind = "风力数据缺失" - - return city_name, temperature, weather_type, wind +# ============================================================ +# 微信 API +# ============================================================ + class WeChatAPI: - """微信API接口封装""" - - @staticmethod - def get_access_token() -> Optional[str]: + """微信公众号 API 封装。""" + + _token_cache: Optional[str] = None + _token_expires_at: Optional[dt.datetime] = None + _token_lock = threading.Lock() + + def __init__( + self, + config: Config, + session: requests.Session, + ) -> None: + self.config = config + self.session = session + + def get_access_token(self) -> Optional[str]: """ - 获取微信access_token - - Returns: - access_token字符串,失败返回None + 获取 access_token。 + + 自动缓存 access_token,避免每次运行都重复请求。 """ - if not APP_ID or not APP_SECRET: - print("错误:APP_ID或APP_SECRET未配置") - return None - - url = f"https://api.weixin.qq.com/cgi-bin/token" - params = { - "grant_type": "client_credential", - "appid": APP_ID.strip(), - "secret": APP_SECRET.strip() - } - - try: - response = requests.get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - if 'access_token' in data: - return data['access_token'] - else: - print(f"获取access_token失败: {data}") + + now = dt.datetime.now(dt.timezone.utc) + + with self._token_lock: + if ( + self._token_cache + and self._token_expires_at + and now < self._token_expires_at + ): + logger.debug("使用缓存中的 access_token") + return self._token_cache + + logger.info("正在获取新的微信 access_token...") + + params = { + "grant_type": "client_credential", + "appid": self.config.app_id, + "secret": self.config.app_secret, + } + + try: + response = self.session.get( + WECHAT_TOKEN_URL, + params=params, + timeout=self.config.request_timeout, + ) + + response.raise_for_status() + + data = response.json() + + except requests.RequestException as exc: + logger.error( + "请求微信 access_token 接口失败:%s", + exc, + ) return None - - except requests.RequestException as e: - print(f"请求access_token失败: {e}") - return None - - @staticmethod - def send_weather_message(access_token: str, weather_data: Tuple, daily_note: str) -> bool: - """ - 发送天气模板消息 - - Args: - access_token: 微信访问令牌 - weather_data: 天气数据元组 (城市, 温度, 天气, 风力) - daily_note: 每日寄语 - - Returns: - 发送是否成功 - """ - if not access_token or not OPEN_ID or not WEATHER_TEMPLATE_ID: - print("错误:必要的配置参数缺失") + + except ValueError as exc: + logger.error( + "微信 access_token 返回的不是有效 JSON:%s", + exc, + ) + return None + + access_token = data.get("access_token") + + if not access_token: + logger.error( + "获取 access_token 失败:errcode=%s, errmsg=%s", + data.get("errcode"), + data.get("errmsg"), + ) + return None + + expires_in = int(data.get("expires_in", 7200)) + + # 提前 5 分钟过期,避免临界时间请求失败。 + cache_seconds = max(expires_in - 300, 60) + + self._token_cache = access_token + self._token_expires_at = ( + now + dt.timedelta(seconds=cache_seconds) + ) + + logger.info("access_token 获取成功") + + return access_token + + def send_weather_message( + self, + access_token: str, + weather_data: WeatherData, + daily_note: str, + ) -> bool: + """发送天气模板消息。""" + + if not access_token: + logger.error("access_token 为空") return False - - # 准备消息数据 - message_data = WeChatAPI._build_message_data(weather_data, daily_note) - - url = f"https://api.weixin.qq.com/cgi-bin/message/template/send" - params = {"access_token": access_token} - + + message_data = self._build_message_data( + weather_data=weather_data, + daily_note=daily_note, + ) + + params = { + "access_token": access_token, + } + try: - response = requests.post(url, params=params, json=message_data, timeout=10) + response = self.session.post( + WECHAT_TEMPLATE_SEND_URL, + params=params, + json=message_data, + timeout=self.config.request_timeout, + ) + response.raise_for_status() + result = response.json() - - if result.get('errcode') == 0: - print("消息发送成功") - return True - else: - print(f"消息发送失败: {result}") - return False - - except requests.RequestException as e: - print(f"发送消息失败: {e}") + + except requests.RequestException as exc: + logger.error( + "发送微信模板消息请求失败:%s", + exc, + ) return False - - @staticmethod - def _build_message_data(weather_data: Tuple, daily_note: str) -> Dict[str, Any]: - """构建微信模板消息数据""" - today_str = datetime.date.today().strftime("%Y年%m月%d日") - + + except ValueError as exc: + logger.error( + "微信发送接口返回的不是有效 JSON:%s", + exc, + ) + return False + + errcode = result.get("errcode") + + if errcode == 0: + logger.info("微信天气消息发送成功") + return True + + logger.error( + "微信天气消息发送失败:errcode=%s, errmsg=%s, result=%s", + errcode, + result.get("errmsg"), + result, + ) + + # access_token 失效。 + # 清除缓存,下次重新获取。 + if errcode in {40001, 40014, 42001}: + logger.warning( + "检测到 access_token 可能失效,清除 Token 缓存" + ) + + self.clear_token_cache() + + return False + + def _build_message_data( + self, + weather_data: WeatherData, + daily_note: str, + ) -> Dict[str, Any]: + """构建微信模板消息 JSON。""" + + today_str = weather_data.date.strftime( + "%Y年%m月%d日" + ) + return { - "touser": OPEN_ID.strip(), - "template_id": WEATHER_TEMPLATE_ID.strip(), - "url": "https://mp.weixin.qq.com", # 更合适的跳转链接 + "touser": self.config.open_id, + "template_id": self.config.template_id, + + # 用户点击模板消息后的跳转地址。 + "url": DEFAULT_WEATHER_URL, + "data": { - "date": {"value": today_str, "color": "#173177"}, - "region": {"value": weather_data[0], "color": "#173177"}, - "weather": {"value": weather_data[2], "color": "#173177"}, - "temp": {"value": weather_data[1], "color": "#FF0000"}, - "wind_dir": {"value": weather_data[3], "color": "#173177"}, - "today_note": {"value": daily_note, "color": "#FF00FF"} - } + "date": { + "value": today_str, + "color": "#173177", + }, + "region": { + "value": weather_data.city, + "color": "#173177", + }, + "weather": { + "value": weather_data.weather, + "color": "#173177", + }, + "temp": { + "value": weather_data.temperature, + "color": "#FF0000", + }, + "wind_dir": { + "value": weather_data.wind, + "color": "#173177", + }, + "today_note": { + "value": daily_note, + "color": "#FF00FF", + }, + }, } + @classmethod + def clear_token_cache(cls) -> None: + """清除 Token 缓存。""" + + with cls._token_lock: + cls._token_cache = None + cls._token_expires_at = None + + +# ============================================================ +# 每日寄语 +# ============================================================ class DailyInspiration: - """每日寄语获取""" - - @staticmethod - def get_daily_inspiration() -> str: - """ - 获取每日一句情话/寄语 - - Returns: - 寄语字符串,失败时返回默认寄语 - """ - url = "https://api.lovelive.tools/api/SweetNothings/Serialization/Json" - + """每日寄语获取器。""" + + def __init__( + self, + session: requests.Session, + timeout: int = DEFAULT_NOTE_TIMEOUT, + ) -> None: + self.session = session + self.timeout = timeout + + def get_daily_inspiration(self) -> str: + """获取每日寄语,失败时使用备用内容。""" + try: - response = requests.get(url, timeout=5) + response = self.session.get( + INSPIRATION_URL, + timeout=self.timeout, + ) + response.raise_for_status() + data = response.json() - - if data and 'returnObj' in data and data['returnObj']: - return data['returnObj'][0] - except Exception as e: - print(f"获取每日寄语失败: {e}") - - # 备用寄语 - return "愿你的一天充满阳光和微笑!" + except requests.RequestException as exc: + logger.warning( + "获取每日寄语请求失败:%s", + exc, + ) + return DEFAULT_NOTE + + except ValueError as exc: + logger.warning( + "每日寄语接口返回无效 JSON:%s", + exc, + ) + return DEFAULT_NOTE + + try: + return self._extract_note(data) + + except (KeyError, IndexError, TypeError, AttributeError): + logger.warning( + "每日寄语接口数据结构异常:%s", + data, + ) + return DEFAULT_NOTE + + @staticmethod + def _extract_note(data: Any) -> str: + """从 API 返回值中提取寄语。""" + + if not isinstance(data, dict): + return DEFAULT_NOTE + + return_obj = data.get("returnObj") + + if isinstance(return_obj, list) and return_obj: + note = return_obj[0] + + if isinstance(note, str) and note.strip(): + return clean_text(note) + + return DEFAULT_NOTE + + +# ============================================================ +# 主控制器 +# ============================================================ class WeatherReporter: - """天气报告主控制器""" - - def __init__(self): - self.weather_fetcher = WeatherFetcher() - self.wechat_api = WeChatAPI() - self.daily_inspiration = DailyInspiration() - + """天气推送主控制器。""" + + def __init__(self, config: Config) -> None: + self.config = config + + self.session = create_session() + + self.weather_fetcher = WeatherFetcher( + session=self.session, + timeout=config.request_timeout, + ) + + self.wechat_api = WeChatAPI( + config=config, + session=self.session, + ) + + self.daily_inspiration = DailyInspiration( + session=self.session, + timeout=config.note_timeout, + ) + def report_weather(self, city: str) -> bool: - """ - 执行完整的天气报告流程 - - Args: - city: 城市名称 - - Returns: - 是否成功执行 - """ - print(f"开始获取 {city} 的天气信息...") - - # 1. 获取天气数据 - weather_data = self.weather_fetcher.fetch_weather_data(city) - if not weather_data: + """执行完整天气推送流程。""" + + city = city.strip() + + if not city: + logger.error("城市不能为空") + return False + + target_date = get_today(self.config.timezone) + + logger.info("=" * 60) + logger.info("开始天气推送") + logger.info("城市:%s", city) + logger.info("日期:%s", target_date) + logger.info("时区:%s", self.config.timezone) + logger.info("=" * 60) + + # ---------------------------------------------------- + # 1. 获取天气 + # ---------------------------------------------------- + + weather_data = self.weather_fetcher.fetch_weather_data( + city=city, + target_date=target_date, + ) + + if weather_data is None: + logger.error("天气数据获取失败") return False - - print(f"天气信息获取成功: {weather_data}") - - # 2. 获取access_token + + logger.info( + "天气:城市=%s,日期=%s,天气=%s,温度=%s,风力=%s", + weather_data.city, + weather_data.date, + weather_data.weather, + weather_data.temperature, + weather_data.wind, + ) + + # ---------------------------------------------------- + # 2. 获取微信 Token + # ---------------------------------------------------- + access_token = self.wechat_api.get_access_token() + if not access_token: + logger.error("获取微信 access_token 失败") return False - + + # ---------------------------------------------------- # 3. 获取每日寄语 - daily_note = self.daily_inspiration.get_daily_inspiration() - print(f"每日寄语: {daily_note}") - - # 4. 发送微信消息 - success = self.wechat_api.send_weather_message(access_token, weather_data, daily_note) - + # ---------------------------------------------------- + + daily_note = ( + self.daily_inspiration.get_daily_inspiration() + ) + + logger.info("每日寄语:%s", daily_note) + + # ---------------------------------------------------- + # 4. 发送微信 + # ---------------------------------------------------- + + success = self.wechat_api.send_weather_message( + access_token=access_token, + weather_data=weather_data, + daily_note=daily_note, + ) + + if success: + logger.info("=" * 60) + logger.info("天气推送完成") + logger.info("=" * 60) + else: + logger.error("=" * 60) + logger.error("天气推送失败") + logger.error("=" * 60) + return success + def close(self) -> None: + """关闭 HTTP Session。""" + + self.session.close() + + +# ============================================================ +# 命令行 +# ============================================================ + +def parse_args() -> argparse.Namespace: + """解析命令行参数。""" + + parser = argparse.ArgumentParser( + description="微信公众号天气推送服务" + ) + + parser.add_argument( + "--city", + default=None, + help="城市名称,例如:吉安、北京、上海", + ) + + parser.add_argument( + "--debug", + action="store_true", + help="开启 DEBUG 日志", + ) + + return parser.parse_args() + + +# ============================================================ +# main +# ============================================================ + +def main() -> int: + """程序入口。""" + + args = parse_args() + + if args.debug: + logger.setLevel(logging.DEBUG) + + try: + config = Config.from_env() + + config.validate() + + except RuntimeError as exc: + logger.error("%s", exc) + return 1 + + city = args.city or config.city + + reporter = WeatherReporter(config) + + try: + success = reporter.report_weather(city) + + return 0 if success else 1 + + except KeyboardInterrupt: + logger.warning("程序被用户中断") + return 130 + + except Exception: + logger.exception("程序发生未处理异常") + return 1 + + finally: + reporter.close() + -def main(): - """主函数""" - # 可以改为从命令行参数或配置文件读取城市 - city = "吉安" - - # 检查必要的环境变量 - required_env_vars = ["APP_ID", "APP_SECRET", "OPEN_ID", "TEMPLATE_ID"] - missing_vars = [var for var in required_env_vars if not os.environ.get(var)] - - if missing_vars: - print(f"错误:缺少必要的环境变量: {', '.join(missing_vars)}") - print("请设置以下环境变量:") - for var in missing_vars: - print(f" export {var}='your_value'") - return - - # 创建并运行天气报告器 - reporter = WeatherReporter() - success = reporter.report_weather(city) - - if success: - print("天气报告发送完成!") - else: - print("天气报告发送失败!") - - -if __name__ == '__main__': - main() +if __name__ == "__main__": + raise SystemExit(main()) +``` From 41c4b3165e488feacfb86263d03f04c85bfc86df Mon Sep 17 00:00:00 2001 From: tie4453 Date: Sun, 30 Aug 2026 05:44:11 +0800 Subject: [PATCH 18/21] Update weather_report.py --- weather_report.py | 1697 +++++++++++++++++++++++++-------------------- 1 file changed, 941 insertions(+), 756 deletions(-) diff --git a/weather_report.py b/weather_report.py index 63ec80b5..075ea8c3 100644 --- a/weather_report.py +++ b/weather_report.py @@ -1,123 +1,173 @@ ```python +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + """ -微信天气推送服务 -================ +微信公众号天气推送 +================================================== 功能: -1. 从中国天气网获取指定城市当天的天气预报 -2. 获取微信公众号 access_token -3. 获取每日寄语 -4. 通过微信公众号模板消息发送天气信息 +1. 获取指定城市当天的天气 +2. 获取每日寄语 +3. 获取微信公众号 access_token +4. 发送微信公众号模板消息 + +适用于: + GitHub Actions -运行: - python weather_push.py - python weather_push.py --city 吉安 +默认城市: + 吉安 -环境变量: - APP_ID 微信公众号 AppID - APP_SECRET 微信公众号 AppSecret - OPEN_ID 接收消息的用户 OpenID - TEMPLATE_ID 微信模板 ID +默认时区: + Asia/Shanghai + +必需环境变量: + APP_ID + APP_SECRET + OPEN_ID + TEMPLATE_ID 可选环境变量: - CITY 默认城市,默认:吉安 - REQUEST_TIMEOUT 请求超时时间,默认:10 - NOTE_TIMEOUT 每日寄语请求超时时间,默认:5 - TZ 时区,默认:Asia/Shanghai + CITY + +模板字段默认: + date + region + weather + temp + wind_dir + today_note """ + from __future__ import annotations import argparse -import datetime as dt +import datetime as datetime_module import logging import os import re -import threading +import sys from dataclasses import dataclass -from typing import Any, Dict, Optional, Tuple -from zoneinfo import ZoneInfo +from typing import Any, Dict, Optional import requests from bs4 import BeautifulSoup from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry +from zoneinfo import ZoneInfo # ============================================================ -# 日志配置 +# 基础配置 # ============================================================ -logging.basicConfig( - level=os.getenv("LOG_LEVEL", "INFO").upper(), - format="%(asctime)s | %(levelname)s | %(message)s", -) +APP_NAME = "微信天气推送" + +DEFAULT_CITY = "吉安" +DEFAULT_TIMEZONE = "Asia/Shanghai" + +REQUEST_TIMEOUT = 15 +INSPIRATION_TIMEOUT = 8 -logger = logging.getLogger("weather_push") +USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0 Safari/537.36" +) # ============================================================ -# 常量 +# 微信接口 # ============================================================ -WEATHER_URLS = [ - "https://www.weather.com.cn/textFC/hb.shtml", # 华北 - "https://www.weather.com.cn/textFC/db.shtml", # 东北 - "https://www.weather.com.cn/textFC/hd.shtml", # 华东 - "https://www.weather.com.cn/textFC/hz.shtml", # 华中 - "https://www.weather.com.cn/textFC/hn.shtml", # 华南 - "https://www.weather.com.cn/textFC/xb.shtml", # 西北 - "https://www.weather.com.cn/textFC/xn.shtml", # 西南 -] - -WECHAT_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token" +WECHAT_TOKEN_URL = ( + "https://api.weixin.qq.com/cgi-bin/token" +) WECHAT_TEMPLATE_SEND_URL = ( "https://api.weixin.qq.com/cgi-bin/message/template/send" ) + +# ============================================================ +# 每日寄语接口 +# ============================================================ + INSPIRATION_URL = ( "https://api.lovelive.tools/api/SweetNothings/Serialization/Json" ) -DEFAULT_WEATHER_URL = "https://www.weather.com.cn/" +DEFAULT_INSPIRATION = ( + "愿你的一天充满阳光和微笑!" +) -DEFAULT_CITY = "吉安" -DEFAULT_TIMEZONE = "Asia/Shanghai" -DEFAULT_REQUEST_TIMEOUT = 10 -DEFAULT_NOTE_TIMEOUT = 5 +# ============================================================ +# 中国天气网 +# ============================================================ -DEFAULT_NOTE = "愿你的一天充满阳光和微笑!" +# 吉安目前有独立城市页面。 +# 中国天气网当前页面可以确认吉安属于江西, +# 并提供吉安及下属地区的城市预报列表。 +WEATHER_CITY_URLS = { + "吉安": "https://jx.weather.com.cn/jian/index.shtml", + "南昌": "https://jx.weather.com.cn/nanchang/index.shtml", + "九江": "https://jx.weather.com.cn/jiujiang/index.shtml", + "上饶": "https://jx.weather.com.cn/shangrao/index.shtml", + "抚州": "https://jx.weather.com.cn/fuzhou/index.shtml", + "宜春": "https://jx.weather.com.cn/yichun/index.shtml", + "赣州": "https://jx.weather.com.cn/ganzhou/index.shtml", + "景德镇": "https://jx.weather.com.cn/jingdezhen/index.shtml", + "萍乡": "https://jx.weather.com.cn/pingxiang/index.shtml", + "新余": "https://jx.weather.com.cn/xinyu/index.shtml", + "鹰潭": "https://jx.weather.com.cn/yingtan/index.shtml", +} + +# 如果以后增加城市,可以继续在这里添加。 +# +# 例如: +# +# "北京": "https://www.weather.com.cn/weather1d/101010100.shtml" -USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/131.0 Safari/537.36" + +# ============================================================ +# 日志 +# ============================================================ + +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s | %(levelname)s | %(message)s", ) +logger = logging.getLogger(APP_NAME) + # ============================================================ -# 配置 +# 数据结构 # ============================================================ -@dataclass(frozen=True) +@dataclass class Config: - """应用配置。""" + """程序配置。""" app_id: str app_secret: str open_id: str template_id: str - city: str = DEFAULT_CITY - timezone: str = DEFAULT_TIMEZONE + city: str + timezone: str - request_timeout: int = DEFAULT_REQUEST_TIMEOUT - note_timeout: int = DEFAULT_NOTE_TIMEOUT + date_field: str + region_field: str + weather_field: str + temp_field: str + wind_field: str + note_field: str @classmethod - def from_env(cls) -> "Config": + def from_environment(cls) -> "Config": """从环境变量读取配置。""" return cls( @@ -125,21 +175,45 @@ def from_env(cls) -> "Config": app_secret=os.getenv("APP_SECRET", "").strip(), open_id=os.getenv("OPEN_ID", "").strip(), template_id=os.getenv("TEMPLATE_ID", "").strip(), - city=os.getenv("CITY", DEFAULT_CITY).strip() or DEFAULT_CITY, - timezone=os.getenv("TZ", DEFAULT_TIMEZONE).strip() - or DEFAULT_TIMEZONE, - request_timeout=_read_positive_int( - "REQUEST_TIMEOUT", - DEFAULT_REQUEST_TIMEOUT, + + city=( + os.getenv("CITY", DEFAULT_CITY).strip() + or DEFAULT_CITY + ), + + timezone=( + os.getenv("TZ", DEFAULT_TIMEZONE).strip() + or DEFAULT_TIMEZONE + ), + + # 微信模板字段 + date_field=( + os.getenv("DATE_FIELD", "date").strip() ), - note_timeout=_read_positive_int( - "NOTE_TIMEOUT", - DEFAULT_NOTE_TIMEOUT, + + region_field=( + os.getenv("REGION_FIELD", "region").strip() + ), + + weather_field=( + os.getenv("WEATHER_FIELD", "weather").strip() + ), + + temp_field=( + os.getenv("TEMP_FIELD", "temp").strip() + ), + + wind_field=( + os.getenv("WIND_FIELD", "wind_dir").strip() + ), + + note_field=( + os.getenv("NOTE_FIELD", "today_note").strip() ), ) def validate(self) -> None: - """检查必要配置。""" + """检查配置。""" missing = [] @@ -156,134 +230,88 @@ def validate(self) -> None: missing.append("TEMPLATE_ID") if missing: - raise RuntimeError( - "缺少必要的环境变量:" + raise ValueError( + "缺少必要的 GitHub Secrets:" + ", ".join(missing) ) try: ZoneInfo(self.timezone) except Exception as exc: - raise RuntimeError( - f"无效时区配置:{self.timezone}" + raise ValueError( + f"无效时区:{self.timezone}" ) from exc -def _read_positive_int(name: str, default: int) -> int: - """读取正整数环境变量。""" - - value = os.getenv(name) - - if not value: - return default - - try: - result = int(value) - - if result <= 0: - raise ValueError - - return result - - except ValueError: - logger.warning( - "%s=%r 不是有效正整数,使用默认值 %s", - name, - value, - default, - ) - return default - - -# ============================================================ -# HTTP Session -# ============================================================ +@dataclass +class WeatherData: + """天气数据。""" -def create_session() -> requests.Session: - """创建带连接池和重试机制的 HTTP Session。""" + city: str + date: datetime_module.date - session = requests.Session() + weather: str + high_temperature: str + low_temperature: str - retry = Retry( - total=3, - connect=3, - read=3, - backoff_factor=0.5, - status_forcelist=(429, 500, 502, 503, 504), - allowed_methods=frozenset({"GET", "POST"}), - raise_on_status=False, - ) + wind_day: str + wind_night: str - adapter = HTTPAdapter( - max_retries=retry, - pool_connections=10, - pool_maxsize=10, - ) + @property + def temperature(self) -> str: + """生成温度范围。""" - session.mount("http://", adapter) - session.mount("https://", adapter) + high = self.high_temperature + low = self.low_temperature - session.headers.update( - { - "User-Agent": USER_AGENT, - "Accept": ( - "text/html,application/xhtml+xml," - "application/json;q=0.9,*/*;q=0.8" - ), - } - ) + if high != "-" and low != "-": + return f"{low}~{high}℃" - return session + if high != "-": + return f"{high}℃" + if low != "-": + return f"{low}℃" -# ============================================================ -# 数据模型 -# ============================================================ + return "暂无" -@dataclass(frozen=True) -class WeatherData: - """天气数据。""" + @property + def wind(self) -> str: + """生成风力信息。""" - city: str - temperature: str - weather: str - wind: str - date: dt.date + if self.wind_day not in ("", "-", "--"): + return self.wind_day - def as_tuple(self) -> Tuple[str, str, str, str]: - """兼容原来的元组结构。""" + if self.wind_night not in ("", "-", "--"): + return self.wind_night - return ( - self.city, - self.temperature, - self.weather, - self.wind, - ) + return "暂无" # ============================================================ # 工具函数 # ============================================================ -def get_today(timezone_name: str) -> dt.date: - """按照指定时区获取今天的日期。""" +def clean_text(value: str) -> str: + """清洗文本。""" - timezone = ZoneInfo(timezone_name) + if value is None: + return "" - return dt.datetime.now(timezone).date() + value = value.replace("\xa0", " ") + value = value.replace("\u3000", " ") + return re.sub( + r"\s+", + " ", + value, + ).strip() -def normalize_city_name(city: str) -> str: - """ - 标准化城市名称。 - 例如: - 吉安 -> 吉安 - 吉安市 -> 吉安 - 北京市 -> 北京 - """ +def normalize_city(city: str) -> str: + """标准化城市名称。""" - city = re.sub(r"\s+", "", city.strip()) + city = clean_text(city) suffixes = ( "特别行政区", @@ -295,38 +323,109 @@ def normalize_city_name(city: str) -> str: for suffix in suffixes: if city.endswith(suffix): - city = city[: -len(suffix)] + city = city[:-len(suffix)] break return city -def clean_text(value: str) -> str: - """清洗 HTML 文本。""" +def city_equal( + actual_city: str, + target_city: str, +) -> bool: + """判断城市是否相同。""" - value = value.replace("\xa0", " ") - value = value.replace("\u3000", " ") + return ( + normalize_city(actual_city) + == normalize_city(target_city) + ) - return re.sub(r"\s+", " ", value).strip() +def get_today(timezone_name: str) -> datetime_module.date: + """按照指定时区获取今天。""" -def is_missing(value: str) -> bool: - """判断天气字段是否为空。""" + now = datetime_module.datetime.now( + ZoneInfo(timezone_name) + ) + + return now.date() + + +def clean_temperature(value: str) -> str: + """清洗温度。""" value = clean_text(value) - return value in { - "", - "-", - "--", - "---", - "暂无", - "无", - } + value = value.replace("℃", "") + value = value.replace("°C", "") + value = value.replace("°", "") + + if value in ("", "-", "--", "---"): + return "-" + + return value + + +def create_session() -> requests.Session: + """创建 HTTP Session。""" + + session = requests.Session() + + retry = Retry( + total=3, + connect=3, + read=3, + backoff_factor=0.5, + + status_forcelist=( + 429, + 500, + 502, + 503, + 504, + ), + + allowed_methods=frozenset( + { + "GET", + "POST", + } + ), + + raise_on_status=False, + ) + + adapter = HTTPAdapter( + max_retries=retry, + pool_connections=5, + pool_maxsize=5, + ) + + session.mount( + "https://", + adapter, + ) + + session.mount( + "http://", + adapter, + ) + + session.headers.update( + { + "User-Agent": USER_AGENT, + "Accept": ( + "text/html,application/xhtml+xml," + "application/json;q=0.9,*/*;q=0.8" + ), + } + ) + + return session # ============================================================ -# 天气抓取 +# 天气获取 # ============================================================ class WeatherFetcher: @@ -335,332 +434,225 @@ class WeatherFetcher: def __init__( self, session: requests.Session, - timeout: int = DEFAULT_REQUEST_TIMEOUT, - ) -> None: + ): self.session = session - self.timeout = timeout - def fetch_weather_data( + def get_weather( self, city: str, - target_date: Optional[dt.date] = None, + target_date: datetime_module.date, ) -> Optional[WeatherData]: - """ - 获取指定城市指定日期的天气。 - - 如果 target_date 未提供,则使用当天日期。 - """ + """获取指定城市当天的天气。""" - city = city.strip() + city = clean_text(city) if not city: - logger.error("城市名称不能为空") + logger.error("城市名称为空") return None - if target_date is None: - target_date = dt.date.today() - - normalized_target = normalize_city_name(city) - - logger.info( - "开始获取天气:城市=%s,目标日期=%s", - city, - target_date.isoformat(), + # 优先使用城市专属页面。 + url = WEATHER_CITY_URLS.get( + normalize_city(city) ) - for url in WEATHER_URLS: + if url: + urls = [url] + else: + logger.warning( + "没有找到 %s 的专属页面配置", + city, + ) + return None + + for url in urls: try: - logger.debug("请求天气页面:%s", url) + logger.info( + "正在获取天气:%s", + url, + ) response = self.session.get( url, - timeout=self.timeout, + timeout=REQUEST_TIMEOUT, ) response.raise_for_status() - # 中国天气网当前页面是 UTF-8。 - response.encoding = response.apparent_encoding or "utf-8" + response.encoding = ( + response.apparent_encoding + or "utf-8" + ) soup = BeautifulSoup( response.text, "html.parser", ) - result = self._search_city( + result = self._parse_city_page( soup=soup, - target_city=normalized_target, + city=city, target_date=target_date, ) if result: logger.info( - "成功获取天气:%s", - result, + "天气获取成功:" + "%s | %s | %s | %s", + result.city, + result.weather, + result.temperature, + result.wind, ) + return result - except requests.RequestException as exc: logger.warning( - "请求天气页面失败:%s | %s", - url, + "页面中没有解析到目标天气:" + "city=%s date=%s", + city, + target_date, + ) + + except requests.RequestException as exc: + logger.error( + "天气请求失败:%s", exc, ) except Exception: logger.exception( - "解析天气页面时发生异常:%s", - url, + "解析天气页面发生异常" ) - logger.error( - "所有天气页面都没有找到城市:%s,日期:%s", - city, - target_date, - ) - return None - def _search_city( + def _parse_city_page( self, soup: BeautifulSoup, - target_city: str, - target_date: dt.date, + city: str, + target_date: datetime_module.date, ) -> Optional[WeatherData]: """ - 在页面中查找目标日期对应的城市。 + 解析中国天气网城市页面。 - 中国天气网页面按日期组织多组 table。 - 不再简单使用 table.find_all("tr")[2:]。 + 注意: + 中国天气网城市主页目前包含城市列表, + 页面中的详细天气区域可能通过 iframe 加载。 + + 因此: + 1. 先检查页面 HTML 中的天气表格; + 2. 再检查页面中的 iframe; + 3. 如果找到 iframe,则继续请求 iframe; + 4. 最后尝试从页面中的城市预报摘要读取数据。 """ - tables = soup.find_all("table") - - if not tables: - logger.warning("页面中没有找到 table") - return None - - # 当前页面通常以“周X(月日)”作为日期标题。 - target_date_text = ( - f"({target_date.month}月{target_date.day}日)" - ) - - for table in tables: - rows = table.find_all("tr") - - if len(rows) < 2: - continue - - header_text = clean_text( - table.get_text(" ", strip=True) - ) - - # 判断这个 table 是否属于目标日期。 - # - # 页面日期可能出现在表头附近,例如: - # 周日(8月30日)白天 - # 周日(8月30日)夜间 - if target_date_text not in header_text: - continue - - result = self._search_city_in_table( - table=table, - target_city=target_city, - target_date=target_date, - ) - - if result: - return result - - # 如果页面结构变化导致无法识别日期, - # 再使用第二套兼容解析方式。 - logger.warning( - "没有通过日期表头找到数据,尝试兼容解析:%s", - target_city, - ) + # ---------------------------------------------------- + # 方案 1:当前页面 HTML 中直接寻找天气表格 + # ---------------------------------------------------- - return self._fallback_search( + result = self._parse_tables( soup=soup, - target_city=target_city, + city=city, target_date=target_date, ) - def _search_city_in_table( - self, - table, - target_city: str, - target_date: dt.date, - ) -> Optional[WeatherData]: - """在一个日期对应的表格中搜索城市。""" - - rows = table.find_all("tr") - - for row in rows: - cells = row.find_all("td") - - # 当前中国天气网文字版表格: - # - # 省/城市 - # 城市 - # 白天天气 - # 白天风力 - # 最高气温 - # 夜间天气 - # 夜间风力 - # 最低气温 - # - # 最后还可能存在“详情”单元格。 - if len(cells) < 8: - continue + if result: + return result + + # ---------------------------------------------------- + # 方案 2:寻找 iframe + # ---------------------------------------------------- - values = [ - clean_text(cell.get_text(" ", strip=True)) - for cell in cells - ] + iframe_urls = [] - # 去掉最后的“详情”字段。 - if values and values[-1] == "详情": - values = values[:-1] + for iframe in soup.find_all("iframe"): + src = ( + iframe.get("src") + or iframe.get("data-src") + or "" + ).strip() - if len(values) < 7: + if not src: continue - # 正常情况下: - # [省, 城市, 白天天气, 白天风力, - # 最高温, 夜间天气, 夜间风力, 最低温] - city_candidates = values[:2] + if src.startswith("//"): + src = "https:" + src - if not any( - self._city_matches( - candidate, - target_city, + elif src.startswith("/"): + src = ( + "https://www.weather.com.cn" + + src ) - for candidate in city_candidates - ): - continue - return self._build_weather_data( - values=values, - target_date=target_date, - ) - - return None + elif src.startswith("http://"): + src = "https://" + src[len("http://"):] - @staticmethod - def _city_matches( - actual: str, - target: str, - ) -> bool: - """判断城市名称是否匹配。""" + elif not src.startswith("http"): + continue - actual_normalized = normalize_city_name(actual) - target_normalized = normalize_city_name(target) + iframe_urls.append(src) - return ( - actual_normalized == target_normalized - or actual_normalized.endswith(target_normalized) - or target_normalized.endswith(actual_normalized) - ) + for iframe_url in iframe_urls: + try: + logger.debug( + "请求天气 iframe:%s", + iframe_url, + ) - def _build_weather_data( - self, - values, - target_date: dt.date, - ) -> WeatherData: - """根据天气表格字段构建 WeatherData。""" + response = self.session.get( + iframe_url, + timeout=REQUEST_TIMEOUT, + ) - # 通常情况下 values 为: - # - # 0 省 - # 1 城市 - # 2 白天天气 - # 3 白天风力 - # 4 最高温 - # 5 夜间天气 - # 6 夜间风力 - # 7 最低温 - - city_name = values[1] - - day_weather = values[2] if len(values) > 2 else "-" - day_wind = values[3] if len(values) > 3 else "-" - high_temp = values[4] if len(values) > 4 else "-" - - night_weather = values[5] if len(values) > 5 else "-" - night_wind = values[6] if len(values) > 6 else "-" - low_temp = values[7] if len(values) > 7 else "-" - - # 天气: - # 优先白天,白天无数据则使用夜间。 - if not is_missing(day_weather): - weather = day_weather - elif not is_missing(night_weather): - weather = night_weather - else: - weather = "天气数据缺失" + response.raise_for_status() - # 温度。 - high_temp = self._clean_temperature(high_temp) - low_temp = self._clean_temperature(low_temp) + response.encoding = ( + response.apparent_encoding + or "utf-8" + ) - if ( - not is_missing(high_temp) - and not is_missing(low_temp) - ): - temperature = f"{low_temp}~{high_temp}℃" + iframe_soup = BeautifulSoup( + response.text, + "html.parser", + ) - elif not is_missing(low_temp): - temperature = f"{low_temp}℃" + result = self._parse_tables( + soup=iframe_soup, + city=city, + target_date=target_date, + ) - elif not is_missing(high_temp): - temperature = f"{high_temp}℃" + if result: + return result - else: - temperature = "温度数据缺失" + except requests.RequestException as exc: + logger.debug( + "iframe 请求失败:%s", + exc, + ) - # 风力。 - if not is_missing(day_wind): - wind = day_wind - elif not is_missing(night_wind): - wind = night_wind - else: - wind = "风力数据缺失" + # ---------------------------------------------------- + # 方案 3:从页面文本中的城市摘要读取 + # + # 这个方案主要用于页面结构发生变化时, + # 至少能够获得城市、最高温、最低温。 + # ---------------------------------------------------- - return WeatherData( - city=city_name, - temperature=temperature, - weather=weather, - wind=wind, - date=target_date, + result = self._parse_summary( + soup=soup, + city=city, + target_date=target_date, ) - @staticmethod - def _clean_temperature(value: str) -> str: - """清洗温度数据。""" - - value = clean_text(value) - - value = value.replace("℃", "") - value = value.replace("°C", "") - value = value.replace("°", "") - - return value + return result - def _fallback_search( + def _parse_tables( self, soup: BeautifulSoup, - target_city: str, - target_date: dt.date, + city: str, + target_date: datetime_module.date, ) -> Optional[WeatherData]: - """ - 兼容解析。 - - 如果页面结构变化导致无法识别日期, - 尝试从所有表格中寻找城市。 - - 注意: - 这只是兜底方案,优先使用日期匹配。 - """ + """解析天气表格。""" tables = soup.find_all("table") @@ -670,462 +662,632 @@ def _fallback_search( for row in rows: cells = row.find_all("td") - if len(cells) < 8: + if len(cells) < 7: continue values = [ - clean_text(cell.get_text(" ", strip=True)) + clean_text( + cell.get_text( + " ", + strip=True, + ) + ) for cell in cells ] + # 去除详情列 if values and values[-1] == "详情": values = values[:-1] if len(values) < 7: continue - if not any( - self._city_matches( - candidate, - target_city, + # ------------------------------------------------ + # 情况 A: + # + # 城市 + # 白天天气 + # 白天风力 + # 最高温 + # 夜间天气 + # 夜间风力 + # 最低温 + # + # ------------------------------------------------ + + if len(values) >= 7: + city_index = None + + # 城市通常在前两个字段之一。 + for index in range( + min(2, len(values)) + ): + if city_equal( + values[index], + city, + ): + city_index = index + break + + if city_index is None: + continue + + # 城市之后至少还需要 6 个字段。 + if ( + len(values) + < city_index + 7 + ): + continue + + data = values[ + city_index: + ] + + actual_city = data[0] + + day_weather = ( + data[1] + if len(data) > 1 + else "-" ) - for candidate in values[:2] - ): - continue - logger.warning( - "使用兼容解析获取 %s," - "请注意日期结构可能发生变化。", - target_city, - ) + wind_day = ( + data[2] + if len(data) > 2 + else "-" + ) - return self._build_weather_data( - values=values, - target_date=target_date, - ) + high = ( + data[3] + if len(data) > 3 + else "-" + ) - return None + night_weather = ( + data[4] + if len(data) > 4 + else "-" + ) + wind_night = ( + data[5] + if len(data) > 5 + else "-" + ) -# ============================================================ -# 微信 API -# ============================================================ + low = ( + data[6] + if len(data) > 6 + else "-" + ) -class WeChatAPI: - """微信公众号 API 封装。""" + if day_weather in ( + "", + "-", + "--", + ): + weather = night_weather + else: + weather = day_weather + + if weather in ( + "", + "-", + "--", + ): + weather = "暂无" + + return WeatherData( + city=actual_city, + date=target_date, + weather=weather, + high_temperature=( + clean_temperature(high) + ), + low_temperature=( + clean_temperature(low) + ), + wind_day=wind_day or "-", + wind_night=wind_night or "-", + ) - _token_cache: Optional[str] = None - _token_expires_at: Optional[dt.datetime] = None - _token_lock = threading.Lock() + return None - def __init__( + def _parse_summary( self, - config: Config, - session: requests.Session, - ) -> None: - self.config = config - self.session = session - - def get_access_token(self) -> Optional[str]: + soup: BeautifulSoup, + city: str, + target_date: datetime_module.date, + ) -> Optional[WeatherData]: """ - 获取 access_token。 + 从城市页面的摘要区域获取最高/最低温。 + + 中国天气网当前吉安页面会展示: + + 吉安 + 25℃/33℃ - 自动缓存 access_token,避免每次运行都重复请求。 + 这能作为表格解析失败时的兜底。 """ - now = dt.datetime.now(dt.timezone.utc) + city_link = None - with self._token_lock: - if ( - self._token_cache - and self._token_expires_at - and now < self._token_expires_at - ): - logger.debug("使用缓存中的 access_token") - return self._token_cache + for link in soup.find_all("a"): + text = clean_text( + link.get_text( + " ", + strip=True, + ) + ) - logger.info("正在获取新的微信 access_token...") + if city_equal(text, city): + city_link = link + break - params = { - "grant_type": "client_credential", - "appid": self.config.app_id, - "secret": self.config.app_secret, - } + if city_link is None: + return None - try: - response = self.session.get( - WECHAT_TOKEN_URL, - params=params, - timeout=self.config.request_timeout, - ) + # 城市链接后面通常紧跟温度文本。 + parent = city_link.parent - response.raise_for_status() + if parent: + text = clean_text( + parent.get_text( + " ", + strip=True, + ) + ) - data = response.json() + result = self._extract_temperature_pair( + text + ) - except requests.RequestException as exc: - logger.error( - "请求微信 access_token 接口失败:%s", - exc, + if result: + low, high = result + + return WeatherData( + city=city, + date=target_date, + weather="暂无", + high_temperature=high, + low_temperature=low, + wind_day="-", + wind_night="-", ) - return None - except ValueError as exc: - logger.error( - "微信 access_token 返回的不是有效 JSON:%s", - exc, + # 如果父节点没有温度,再检查附近文本。 + for element in ( + city_link.find_next( + string=True + ), + city_link.find_next( + "li" + ), + city_link.find_parent("li"), + ): + if not element: + continue + + text = clean_text( + element.get_text( + " ", + strip=True, + ) + if hasattr( + element, + "get_text", ) - return None + else str(element) + ) - access_token = data.get("access_token") + result = self._extract_temperature_pair( + text + ) - if not access_token: - logger.error( - "获取 access_token 失败:errcode=%s, errmsg=%s", - data.get("errcode"), - data.get("errmsg"), + if result: + low, high = result + + return WeatherData( + city=city, + date=target_date, + weather="暂无", + high_temperature=high, + low_temperature=low, + wind_day="-", + wind_night="-", ) - return None - expires_in = int(data.get("expires_in", 7200)) + return None - # 提前 5 分钟过期,避免临界时间请求失败。 - cache_seconds = max(expires_in - 300, 60) + @staticmethod + def _extract_temperature_pair( + text: str, + ) -> Optional[tuple[str, str]]: + """ + 解析: + 25℃/33℃ + 25°C/33°C + """ - self._token_cache = access_token - self._token_expires_at = ( - now + dt.timedelta(seconds=cache_seconds) - ) + match = re.search( + r"(-?\d+(?:\.\d+)?)" + r"\s*[℃°]?\s*/\s*" + r"(-?\d+(?:\.\d+)?)" + r"\s*[℃°]?", + text, + ) - logger.info("access_token 获取成功") + if not match: + return None - return access_token + low = match.group(1) + high = match.group(2) - def send_weather_message( - self, - access_token: str, - weather_data: WeatherData, - daily_note: str, - ) -> bool: - """发送天气模板消息。""" + return low, high - if not access_token: - logger.error("access_token 为空") - return False - message_data = self._build_message_data( - weather_data=weather_data, - daily_note=daily_note, - ) +# ============================================================ +# 每日寄语 +# ============================================================ - params = { - "access_token": access_token, - } +class InspirationService: + """每日寄语服务。""" + + def __init__( + self, + session: requests.Session, + ): + self.session = session + + def get(self) -> str: + """获取每日寄语。""" try: - response = self.session.post( - WECHAT_TEMPLATE_SEND_URL, - params=params, - json=message_data, - timeout=self.config.request_timeout, + response = self.session.get( + INSPIRATION_URL, + timeout=INSPIRATION_TIMEOUT, ) response.raise_for_status() - result = response.json() + data: Any = response.json() + + if not isinstance(data, dict): + return DEFAULT_INSPIRATION + + return_obj = data.get( + "returnObj" + ) + + if ( + isinstance( + return_obj, + list, + ) + and return_obj + ): + note = return_obj[0] + + if ( + isinstance(note, str) + and note.strip() + ): + return clean_text(note) except requests.RequestException as exc: - logger.error( - "发送微信模板消息请求失败:%s", + logger.warning( + "每日寄语请求失败:%s", exc, ) - return False except ValueError as exc: - logger.error( - "微信发送接口返回的不是有效 JSON:%s", + logger.warning( + "每日寄语 JSON 解析失败:%s", exc, ) - return False - - errcode = result.get("errcode") - if errcode == 0: - logger.info("微信天气消息发送成功") - return True + except Exception: + logger.exception( + "每日寄语解析失败" + ) - logger.error( - "微信天气消息发送失败:errcode=%s, errmsg=%s, result=%s", - errcode, - result.get("errmsg"), - result, - ) + return DEFAULT_INSPIRATION - # access_token 失效。 - # 清除缓存,下次重新获取。 - if errcode in {40001, 40014, 42001}: - logger.warning( - "检测到 access_token 可能失效,清除 Token 缓存" - ) - self.clear_token_cache() +# ============================================================ +# 微信公众号 +# ============================================================ - return False +class WeChatService: + """微信公众号服务。""" - def _build_message_data( + def __init__( self, - weather_data: WeatherData, - daily_note: str, - ) -> Dict[str, Any]: - """构建微信模板消息 JSON。""" + config: Config, + session: requests.Session, + ): + self.config = config + self.session = session - today_str = weather_data.date.strftime( - "%Y年%m月%d日" + def get_access_token(self) -> str: + """获取 access_token。""" + + logger.info( + "正在获取微信 access_token..." ) - return { - "touser": self.config.open_id, - "template_id": self.config.template_id, - - # 用户点击模板消息后的跳转地址。 - "url": DEFAULT_WEATHER_URL, - - "data": { - "date": { - "value": today_str, - "color": "#173177", - }, - "region": { - "value": weather_data.city, - "color": "#173177", - }, - "weather": { - "value": weather_data.weather, - "color": "#173177", - }, - "temp": { - "value": weather_data.temperature, - "color": "#FF0000", - }, - "wind_dir": { - "value": weather_data.wind, - "color": "#173177", - }, - "today_note": { - "value": daily_note, - "color": "#FF00FF", - }, + response = self.session.get( + WECHAT_TOKEN_URL, + params={ + "grant_type": "client_credential", + "appid": self.config.app_id, + "secret": self.config.app_secret, }, - } + timeout=REQUEST_TIMEOUT, + ) - @classmethod - def clear_token_cache(cls) -> None: - """清除 Token 缓存。""" + response.raise_for_status() - with cls._token_lock: - cls._token_cache = None - cls._token_expires_at = None + data = response.json() + access_token = data.get( + "access_token" + ) -# ============================================================ -# 每日寄语 -# ============================================================ + if access_token: + logger.info( + "access_token 获取成功" + ) -class DailyInspiration: - """每日寄语获取器。""" + return access_token - def __init__( - self, - session: requests.Session, - timeout: int = DEFAULT_NOTE_TIMEOUT, - ) -> None: - self.session = session - self.timeout = timeout + raise RuntimeError( + "获取微信 access_token 失败:" + f"errcode={data.get('errcode')} " + f"errmsg={data.get('errmsg')}" + ) - def get_daily_inspiration(self) -> str: - """获取每日寄语,失败时使用备用内容。""" + def send_weather( + self, + weather: WeatherData, + note: str, + ) -> bool: + """发送天气模板消息。""" - try: - response = self.session.get( - INSPIRATION_URL, - timeout=self.timeout, - ) + access_token = ( + self.get_access_token() + ) - response.raise_for_status() + # ---------------------------------------------------- + # 模板字段 + # ---------------------------------------------------- - data = response.json() + message_data: Dict[str, Any] = { + self.config.date_field: { + "value": weather.date.strftime( + "%Y年%m月%d日" + ), + "color": "#173177", + }, - except requests.RequestException as exc: - logger.warning( - "获取每日寄语请求失败:%s", - exc, - ) - return DEFAULT_NOTE + self.config.region_field: { + "value": weather.city, + "color": "#173177", + }, - except ValueError as exc: - logger.warning( - "每日寄语接口返回无效 JSON:%s", - exc, - ) - return DEFAULT_NOTE + self.config.weather_field: { + "value": weather.weather, + "color": "#173177", + }, - try: - return self._extract_note(data) + self.config.temp_field: { + "value": weather.temperature, + "color": "#FF0000", + }, - except (KeyError, IndexError, TypeError, AttributeError): - logger.warning( - "每日寄语接口数据结构异常:%s", - data, - ) - return DEFAULT_NOTE + self.config.wind_field: { + "value": weather.wind, + "color": "#173177", + }, - @staticmethod - def _extract_note(data: Any) -> str: - """从 API 返回值中提取寄语。""" + self.config.note_field: { + "value": note, + "color": "#FF00FF", + }, + } - if not isinstance(data, dict): - return DEFAULT_NOTE + payload = { + "touser": self.config.open_id, - return_obj = data.get("returnObj") + "template_id": ( + self.config.template_id + ), - if isinstance(return_obj, list) and return_obj: - note = return_obj[0] + "url": ( + "https://www.weather.com.cn/" + ), - if isinstance(note, str) and note.strip(): - return clean_text(note) + "data": message_data, + } - return DEFAULT_NOTE + logger.info( + "正在发送微信天气消息..." + ) + response = self.session.post( + WECHAT_TEMPLATE_SEND_URL, + params={ + "access_token": access_token + }, + json=payload, + timeout=REQUEST_TIMEOUT, + ) -# ============================================================ -# 主控制器 -# ============================================================ + response.raise_for_status() -class WeatherReporter: - """天气推送主控制器。""" + result = response.json() - def __init__(self, config: Config) -> None: - self.config = config + errcode = result.get( + "errcode" + ) - self.session = create_session() + if errcode == 0: + logger.info( + "微信天气消息发送成功" + ) + return True - self.weather_fetcher = WeatherFetcher( - session=self.session, - timeout=config.request_timeout, + # 不打印 AppSecret。 + logger.error( + "微信消息发送失败:" + "errcode=%s, errmsg=%s", + errcode, + result.get("errmsg"), ) - self.wechat_api = WeChatAPI( - config=config, - session=self.session, + raise RuntimeError( + "微信消息发送失败:" + f"errcode={errcode}, " + f"errmsg={result.get('errmsg')}" ) - self.daily_inspiration = DailyInspiration( - session=self.session, - timeout=config.note_timeout, - ) - def report_weather(self, city: str) -> bool: - """执行完整天气推送流程。""" +# ============================================================ +# 主程序 +# ============================================================ - city = city.strip() +def run( + config: Config, + city: str, +) -> bool: + """运行完整推送流程。""" - if not city: - logger.error("城市不能为空") - return False + target_date = get_today( + config.timezone + ) - target_date = get_today(self.config.timezone) + logger.info( + "========================================" + ) - logger.info("=" * 60) - logger.info("开始天气推送") - logger.info("城市:%s", city) - logger.info("日期:%s", target_date) - logger.info("时区:%s", self.config.timezone) - logger.info("=" * 60) + logger.info( + "%s 开始", + APP_NAME, + ) + logger.info( + "城市:%s", + city, + ) + + logger.info( + "日期:%s", + target_date.strftime( + "%Y-%m-%d" + ), + ) + + logger.info( + "时区:%s", + config.timezone, + ) + + logger.info( + "========================================" + ) + + session = create_session() + + try: # ---------------------------------------------------- - # 1. 获取天气 + # 1. 天气 # ---------------------------------------------------- - weather_data = self.weather_fetcher.fetch_weather_data( + weather = WeatherFetcher( + session + ).get_weather( city=city, target_date=target_date, ) - if weather_data is None: - logger.error("天气数据获取失败") - return False + if weather is None: + raise RuntimeError( + f"没有获取到 {city} 的天气数据" + ) logger.info( - "天气:城市=%s,日期=%s,天气=%s,温度=%s,风力=%s", - weather_data.city, - weather_data.date, - weather_data.weather, - weather_data.temperature, - weather_data.wind, + "天气:%s", + weather.weather, ) - # ---------------------------------------------------- - # 2. 获取微信 Token - # ---------------------------------------------------- - - access_token = self.wechat_api.get_access_token() + logger.info( + "温度:%s", + weather.temperature, + ) - if not access_token: - logger.error("获取微信 access_token 失败") - return False + logger.info( + "风力:%s", + weather.wind, + ) # ---------------------------------------------------- - # 3. 获取每日寄语 + # 2. 每日寄语 # ---------------------------------------------------- - daily_note = ( - self.daily_inspiration.get_daily_inspiration() - ) + note = InspirationService( + session + ).get() - logger.info("每日寄语:%s", daily_note) + logger.info( + "每日寄语:%s", + note, + ) # ---------------------------------------------------- - # 4. 发送微信 + # 3. 微信推送 # ---------------------------------------------------- - success = self.wechat_api.send_weather_message( - access_token=access_token, - weather_data=weather_data, - daily_note=daily_note, + WeChatService( + config=config, + session=session, + ).send_weather( + weather=weather, + note=note, ) - if success: - logger.info("=" * 60) - logger.info("天气推送完成") - logger.info("=" * 60) - else: - logger.error("=" * 60) - logger.error("天气推送失败") - logger.error("=" * 60) + logger.info( + "========================================" + ) - return success + logger.info( + "天气推送全部完成" + ) - def close(self) -> None: - """关闭 HTTP Session。""" + logger.info( + "========================================" + ) - self.session.close() + return True + + finally: + session.close() # ============================================================ # 命令行 # ============================================================ -def parse_args() -> argparse.Namespace: - """解析命令行参数。""" +def main() -> int: + """程序入口。""" parser = argparse.ArgumentParser( - description="微信公众号天气推送服务" + description="微信公众号天气推送" ) parser.add_argument( "--city", default=None, - help="城市名称,例如:吉安、北京、上海", + help="城市名称,例如:吉安", ) parser.add_argument( @@ -1134,51 +1296,74 @@ def parse_args() -> argparse.Namespace: help="开启 DEBUG 日志", ) - return parser.parse_args() - + args = parser.parse_args() -# ============================================================ -# main -# ============================================================ - -def main() -> int: - """程序入口。""" + if args.debug: + logger.setLevel( + logging.DEBUG + ) - args = parse_args() + # -------------------------------------------------------- + # 读取配置 + # -------------------------------------------------------- - if args.debug: - logger.setLevel(logging.DEBUG) + config = Config.from_environment() try: - config = Config.from_env() - config.validate() - except RuntimeError as exc: - logger.error("%s", exc) + except ValueError as exc: + logger.error( + "%s", + exc, + ) return 1 - city = args.city or config.city + city = ( + args.city + or config.city + ).strip() - reporter = WeatherReporter(config) + if not city: + logger.error( + "城市不能为空" + ) + return 1 + + # -------------------------------------------------------- + # 执行 + # -------------------------------------------------------- try: - success = reporter.report_weather(city) + success = run( + config=config, + city=city, + ) return 0 if success else 1 - except KeyboardInterrupt: - logger.warning("程序被用户中断") - return 130 + except requests.RequestException as exc: + logger.error( + "网络请求失败:%s", + exc, + ) - except Exception: - logger.exception("程序发生未处理异常") return 1 - finally: - reporter.close() + except Exception as exc: + logger.error( + "程序运行失败:%s", + exc, + ) + + logger.debug( + "完整异常:", + exc_info=True, + ) + + return 1 if __name__ == "__main__": - raise SystemExit(main()) + sys.exit(main()) ``` From 31a26c1002bd236f7bb6b75fff045a610b5b7b67 Mon Sep 17 00:00:00 2001 From: tie4453 Date: Sun, 30 Aug 2026 05:48:41 +0800 Subject: [PATCH 19/21] Update weather_report.py --- weather_report.py | 1716 ++++++++++++++++++--------------------------- 1 file changed, 663 insertions(+), 1053 deletions(-) diff --git a/weather_report.py b/weather_report.py index 075ea8c3..2019ed01 100644 --- a/weather_report.py +++ b/weather_report.py @@ -1,373 +1,75 @@ ```python -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - """ -微信公众号天气推送 -================================================== - -功能: -1. 获取指定城市当天的天气 -2. 获取每日寄语 -3. 获取微信公众号 access_token -4. 发送微信公众号模板消息 - -适用于: - GitHub Actions - -默认城市: - 吉安 - -默认时区: - Asia/Shanghai - -必需环境变量: - APP_ID - APP_SECRET - OPEN_ID - TEMPLATE_ID - -可选环境变量: - CITY - -模板字段默认: - date - region - weather - temp - wind_dir - today_note +微信天气推送服务 +功能:从天气网站抓取数据,通过微信模板消息推送给指定用户 +运行环境:GitHub Actions """ - -from __future__ import annotations - -import argparse -import datetime as datetime_module -import logging import os -import re -import sys -from dataclasses import dataclass -from typing import Any, Dict, Optional +import datetime +from zoneinfo import ZoneInfo +from typing import Optional, Tuple, Dict, Any import requests from bs4 import BeautifulSoup from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -from zoneinfo import ZoneInfo # ============================================================ -# 基础配置 +# 配置 # ============================================================ -APP_NAME = "微信天气推送" - -DEFAULT_CITY = "吉安" -DEFAULT_TIMEZONE = "Asia/Shanghai" - -REQUEST_TIMEOUT = 15 -INSPIRATION_TIMEOUT = 8 - -USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/131.0 Safari/537.36" -) +# 中国天气网天气页面 +WEATHER_URLS = [ + "https://www.weather.com.cn/textFC/hb.shtml", # 华北 + "https://www.weather.com.cn/textFC/db.shtml", # 东北 + "https://www.weather.com.cn/textFC/hd.shtml", # 华东 + "https://www.weather.com.cn/textFC/hz.shtml", # 华中 + "https://www.weather.com.cn/textFC/hn.shtml", # 华南 + "https://www.weather.com.cn/textFC/xb.shtml", # 西北 + "https://www.weather.com.cn/textFC/xn.shtml", # 西南 +] -# ============================================================ -# 微信接口 -# ============================================================ +# 默认城市 +CITY = os.environ.get("CITY", "吉安").strip() -WECHAT_TOKEN_URL = ( - "https://api.weixin.qq.com/cgi-bin/token" -) -WECHAT_TEMPLATE_SEND_URL = ( - "https://api.weixin.qq.com/cgi-bin/message/template/send" -) +# 微信公众号配置 +APP_ID = os.environ.get("APP_ID", "").strip() +APP_SECRET = os.environ.get("APP_SECRET", "").strip() +OPEN_ID = os.environ.get("OPEN_ID", "").strip() +WEATHER_TEMPLATE_ID = os.environ.get("TEMPLATE_ID", "").strip() -# ============================================================ -# 每日寄语接口 -# ============================================================ - -INSPIRATION_URL = ( - "https://api.lovelive.tools/api/SweetNothings/Serialization/Json" -) +# 中国时区 +TIMEZONE = "Asia/Shanghai" -DEFAULT_INSPIRATION = ( - "愿你的一天充满阳光和微笑!" -) - -# ============================================================ -# 中国天气网 -# ============================================================ - -# 吉安目前有独立城市页面。 -# 中国天气网当前页面可以确认吉安属于江西, -# 并提供吉安及下属地区的城市预报列表。 -WEATHER_CITY_URLS = { - "吉安": "https://jx.weather.com.cn/jian/index.shtml", - "南昌": "https://jx.weather.com.cn/nanchang/index.shtml", - "九江": "https://jx.weather.com.cn/jiujiang/index.shtml", - "上饶": "https://jx.weather.com.cn/shangrao/index.shtml", - "抚州": "https://jx.weather.com.cn/fuzhou/index.shtml", - "宜春": "https://jx.weather.com.cn/yichun/index.shtml", - "赣州": "https://jx.weather.com.cn/ganzhou/index.shtml", - "景德镇": "https://jx.weather.com.cn/jingdezhen/index.shtml", - "萍乡": "https://jx.weather.com.cn/pingxiang/index.shtml", - "新余": "https://jx.weather.com.cn/xinyu/index.shtml", - "鹰潭": "https://jx.weather.com.cn/yingtan/index.shtml", -} - -# 如果以后增加城市,可以继续在这里添加。 -# -# 例如: -# -# "北京": "https://www.weather.com.cn/weather1d/101010100.shtml" +# 请求超时时间 +REQUEST_TIMEOUT = 15 # ============================================================ # 日志 # ============================================================ -logging.basicConfig( - level=os.getenv("LOG_LEVEL", "INFO").upper(), - format="%(asctime)s | %(levelname)s | %(message)s", -) +def log(message: str) -> None: + """统一输出日志""" + now = datetime.datetime.now( + ZoneInfo(TIMEZONE) + ).strftime("%Y-%m-%d %H:%M:%S") -logger = logging.getLogger(APP_NAME) + print(f"[{now}] {message}") # ============================================================ -# 数据结构 +# HTTP Session # ============================================================ -@dataclass -class Config: - """程序配置。""" - - app_id: str - app_secret: str - open_id: str - template_id: str - - city: str - timezone: str - - date_field: str - region_field: str - weather_field: str - temp_field: str - wind_field: str - note_field: str - - @classmethod - def from_environment(cls) -> "Config": - """从环境变量读取配置。""" - - return cls( - app_id=os.getenv("APP_ID", "").strip(), - app_secret=os.getenv("APP_SECRET", "").strip(), - open_id=os.getenv("OPEN_ID", "").strip(), - template_id=os.getenv("TEMPLATE_ID", "").strip(), - - city=( - os.getenv("CITY", DEFAULT_CITY).strip() - or DEFAULT_CITY - ), - - timezone=( - os.getenv("TZ", DEFAULT_TIMEZONE).strip() - or DEFAULT_TIMEZONE - ), - - # 微信模板字段 - date_field=( - os.getenv("DATE_FIELD", "date").strip() - ), - - region_field=( - os.getenv("REGION_FIELD", "region").strip() - ), - - weather_field=( - os.getenv("WEATHER_FIELD", "weather").strip() - ), - - temp_field=( - os.getenv("TEMP_FIELD", "temp").strip() - ), - - wind_field=( - os.getenv("WIND_FIELD", "wind_dir").strip() - ), - - note_field=( - os.getenv("NOTE_FIELD", "today_note").strip() - ), - ) - - def validate(self) -> None: - """检查配置。""" - - missing = [] - - if not self.app_id: - missing.append("APP_ID") - - if not self.app_secret: - missing.append("APP_SECRET") - - if not self.open_id: - missing.append("OPEN_ID") - - if not self.template_id: - missing.append("TEMPLATE_ID") - - if missing: - raise ValueError( - "缺少必要的 GitHub Secrets:" - + ", ".join(missing) - ) - - try: - ZoneInfo(self.timezone) - except Exception as exc: - raise ValueError( - f"无效时区:{self.timezone}" - ) from exc - - -@dataclass -class WeatherData: - """天气数据。""" - - city: str - date: datetime_module.date - - weather: str - high_temperature: str - low_temperature: str - - wind_day: str - wind_night: str - - @property - def temperature(self) -> str: - """生成温度范围。""" - - high = self.high_temperature - low = self.low_temperature - - if high != "-" and low != "-": - return f"{low}~{high}℃" - - if high != "-": - return f"{high}℃" - - if low != "-": - return f"{low}℃" - - return "暂无" - - @property - def wind(self) -> str: - """生成风力信息。""" - - if self.wind_day not in ("", "-", "--"): - return self.wind_day - - if self.wind_night not in ("", "-", "--"): - return self.wind_night - - return "暂无" - - -# ============================================================ -# 工具函数 -# ============================================================ - -def clean_text(value: str) -> str: - """清洗文本。""" - - if value is None: - return "" - - value = value.replace("\xa0", " ") - value = value.replace("\u3000", " ") - - return re.sub( - r"\s+", - " ", - value, - ).strip() - - -def normalize_city(city: str) -> str: - """标准化城市名称。""" - - city = clean_text(city) - - suffixes = ( - "特别行政区", - "自治州", - "地区", - "盟", - "市", - ) - - for suffix in suffixes: - if city.endswith(suffix): - city = city[:-len(suffix)] - break - - return city - - -def city_equal( - actual_city: str, - target_city: str, -) -> bool: - """判断城市是否相同。""" - - return ( - normalize_city(actual_city) - == normalize_city(target_city) - ) - - -def get_today(timezone_name: str) -> datetime_module.date: - """按照指定时区获取今天。""" - - now = datetime_module.datetime.now( - ZoneInfo(timezone_name) - ) - - return now.date() - - -def clean_temperature(value: str) -> str: - """清洗温度。""" - - value = clean_text(value) - - value = value.replace("℃", "") - value = value.replace("°C", "") - value = value.replace("°", "") - - if value in ("", "-", "--", "---"): - return "-" - - return value - - def create_session() -> requests.Session: - """创建 HTTP Session。""" + """创建带自动重试功能的 HTTP Session""" session = requests.Session() @@ -375,995 +77,903 @@ def create_session() -> requests.Session: total=3, connect=3, read=3, - backoff_factor=0.5, - - status_forcelist=( + backoff_factor=1, + status_forcelist=[ 429, 500, 502, 503, 504, - ), - - allowed_methods=frozenset( - { - "GET", - "POST", - } - ), - - raise_on_status=False, + ], + allowed_methods=[ + "GET", + "POST", + ], ) adapter = HTTPAdapter( - max_retries=retry, - pool_connections=5, - pool_maxsize=5, + max_retries=retry ) session.mount( - "https://", - adapter, + "http://", + adapter ) session.mount( - "http://", - adapter, + "https://", + adapter ) - session.headers.update( - { - "User-Agent": USER_AGENT, - "Accept": ( - "text/html,application/xhtml+xml," - "application/json;q=0.9,*/*;q=0.8" - ), - } - ) + session.headers.update({ + "User-Agent": ( + "Mozilla/5.0 " + "(Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 " + "(KHTML, like Gecko) " + "Chrome/131.0 Safari/537.36" + ) + }) return session # ============================================================ -# 天气获取 +# 天气数据获取 # ============================================================ class WeatherFetcher: - """中国天气网天气获取器。""" + """天气数据获取器""" - def __init__( - self, - session: requests.Session, - ): + def __init__(self, session: requests.Session): self.session = session - def get_weather( + def fetch_weather_data( self, - city: str, - target_date: datetime_module.date, - ) -> Optional[WeatherData]: - """获取指定城市当天的天气。""" + city: str + ) -> Optional[Tuple[str, str, str, str]]: + """ + 获取指定城市的天气信息 - city = clean_text(city) + Args: + city: 城市名称 - if not city: - logger.error("城市名称为空") - return None + Returns: + 元组: + (城市名, 温度范围, 天气类型, 风向风力) - # 优先使用城市专属页面。 - url = WEATHER_CITY_URLS.get( - normalize_city(city) - ) + 如果未找到则返回 None + """ - if url: - urls = [url] - else: - logger.warning( - "没有找到 %s 的专属页面配置", - city, - ) - return None + log(f"开始获取 {city} 的天气信息...") + + for url in WEATHER_URLS: - for url in urls: try: - logger.info( - "正在获取天气:%s", - url, - ) + log(f"正在访问天气网站:{url}") response = self.session.get( url, - timeout=REQUEST_TIMEOUT, + timeout=REQUEST_TIMEOUT ) response.raise_for_status() - response.encoding = ( - response.apparent_encoding - or "utf-8" - ) + # 中国天气网部分页面可能使用 GBK + if response.apparent_encoding: + response.encoding = ( + response.apparent_encoding + ) + else: + response.encoding = "utf-8" soup = BeautifulSoup( response.text, - "html.parser", + "html.parser" ) - result = self._parse_city_page( - soup=soup, - city=city, - target_date=target_date, + div_con_midtab = soup.find( + "div", + class_="conMidtab" + ) + + if not div_con_midtab: + log( + f"页面没有找到 conMidtab:{url}" + ) + continue + + result = self._search_city_in_tables( + div_con_midtab, + city ) if result: - logger.info( - "天气获取成功:" - "%s | %s | %s | %s", - result.city, - result.weather, - result.temperature, - result.wind, + log( + f"天气获取成功:{result}" ) return result - logger.warning( - "页面中没有解析到目标天气:" - "city=%s date=%s", - city, - target_date, + except requests.RequestException as e: + log( + f"请求天气数据失败 {url}: {e}" ) - except requests.RequestException as exc: - logger.error( - "天气请求失败:%s", - exc, + except Exception as e: + log( + f"解析天气数据失败 {url}: {e}" ) - except Exception: - logger.exception( - "解析天气页面发生异常" - ) + log( + f"未找到城市 '{city}' 的天气信息" + ) return None - def _parse_city_page( + def _search_city_in_tables( self, - soup: BeautifulSoup, - city: str, - target_date: datetime_module.date, - ) -> Optional[WeatherData]: - """ - 解析中国天气网城市页面。 - - 注意: - 中国天气网城市主页目前包含城市列表, - 页面中的详细天气区域可能通过 iframe 加载。 - - 因此: - 1. 先检查页面 HTML 中的天气表格; - 2. 再检查页面中的 iframe; - 3. 如果找到 iframe,则继续请求 iframe; - 4. 最后尝试从页面中的城市预报摘要读取数据。 - """ + div_con_midtab, + target_city: str + ) -> Optional[Tuple[str, str, str, str]]: + """在表格中搜索指定城市""" - # ---------------------------------------------------- - # 方案 1:当前页面 HTML 中直接寻找天气表格 - # ---------------------------------------------------- - - result = self._parse_tables( - soup=soup, - city=city, - target_date=target_date, + tables = div_con_midtab.find_all( + "table" ) - if result: - return result - - # ---------------------------------------------------- - # 方案 2:寻找 iframe - # ---------------------------------------------------- + for table in tables: - iframe_urls = [] + rows = table.find_all("tr") - for iframe in soup.find_all("iframe"): - src = ( - iframe.get("src") - or iframe.get("data-src") - or "" - ).strip() + # 保留你原来的跳过表头逻辑 + rows = rows[2:] - if not src: - continue + for row in rows: - if src.startswith("//"): - src = "https:" + src + cells = row.find_all("td") - elif src.startswith("/"): - src = ( - "https://www.weather.com.cn" - + src - ) + if len(cells) < 8: + continue - elif src.startswith("http://"): - src = "https://" + src[len("http://"):] + city_cell = cells[-8] - elif not src.startswith("http"): - continue + city_name = city_cell.get_text( + strip=True + ) - iframe_urls.append(src) + if city_name == target_city: - for iframe_url in iframe_urls: - try: - logger.debug( - "请求天气 iframe:%s", - iframe_url, - ) + return self._extract_weather_data( + cells, + city_name + ) - response = self.session.get( - iframe_url, - timeout=REQUEST_TIMEOUT, - ) + return None - response.raise_for_status() + @staticmethod + def _extract_weather_data( + cells, + city_name: str + ) -> Tuple[str, str, str, str]: + """从表格单元格中提取天气数据""" + + high_temp = cells[-5].get_text( + strip=True + ) - response.encoding = ( - response.apparent_encoding - or "utf-8" - ) + low_temp = cells[-2].get_text( + strip=True + ) - iframe_soup = BeautifulSoup( - response.text, - "html.parser", - ) + weather_day = cells[-7].get_text( + strip=True + ) - result = self._parse_tables( - soup=iframe_soup, - city=city, - target_date=target_date, - ) + weather_night = cells[-4].get_text( + strip=True + ) - if result: - return result + wind_day = cells[-6].get_text( + strip=True + ) - except requests.RequestException as exc: - logger.debug( - "iframe 请求失败:%s", - exc, - ) + wind_night = cells[-3].get_text( + strip=True + ) # ---------------------------------------------------- - # 方案 3:从页面文本中的城市摘要读取 - # - # 这个方案主要用于页面结构发生变化时, - # 至少能够获得城市、最高温、最低温。 + # 温度 # ---------------------------------------------------- - result = self._parse_summary( - soup=soup, - city=city, - target_date=target_date, - ) + if ( + high_temp != "-" + and low_temp != "-" + ): + temperature = ( + f"{low_temp}~{high_temp}°C" + ) - return result + elif low_temp != "-": + temperature = ( + f"{low_temp}°C" + ) - def _parse_tables( - self, - soup: BeautifulSoup, - city: str, - target_date: datetime_module.date, - ) -> Optional[WeatherData]: - """解析天气表格。""" + elif high_temp != "-": + temperature = ( + f"{high_temp}°C" + ) - tables = soup.find_all("table") + else: + temperature = "温度数据缺失" - for table in tables: - rows = table.find_all("tr") + # ---------------------------------------------------- + # 天气 + # ---------------------------------------------------- - for row in rows: - cells = row.find_all("td") + weather_type = weather_day - if len(cells) < 7: - continue + if weather_type in ( + "", + "-", + "--" + ): + weather_type = weather_night - values = [ - clean_text( - cell.get_text( - " ", - strip=True, - ) - ) - for cell in cells - ] + if weather_type in ( + "", + "-", + "--" + ): + weather_type = "天气数据缺失" - # 去除详情列 - if values and values[-1] == "详情": - values = values[:-1] + # ---------------------------------------------------- + # 风向风力 + # ---------------------------------------------------- - if len(values) < 7: - continue + if wind_day and wind_day not in ( + "-", + "--" + ): + wind = wind_day - # ------------------------------------------------ - # 情况 A: - # - # 城市 - # 白天天气 - # 白天风力 - # 最高温 - # 夜间天气 - # 夜间风力 - # 最低温 - # - # ------------------------------------------------ - - if len(values) >= 7: - city_index = None - - # 城市通常在前两个字段之一。 - for index in range( - min(2, len(values)) - ): - if city_equal( - values[index], - city, - ): - city_index = index - break - - if city_index is None: - continue - - # 城市之后至少还需要 6 个字段。 - if ( - len(values) - < city_index + 7 - ): - continue - - data = values[ - city_index: - ] - - actual_city = data[0] - - day_weather = ( - data[1] - if len(data) > 1 - else "-" - ) + elif wind_night and wind_night not in ( + "-", + "--" + ): + wind = wind_night - wind_day = ( - data[2] - if len(data) > 2 - else "-" - ) + else: + wind = "风力数据缺失" - high = ( - data[3] - if len(data) > 3 - else "-" - ) + return ( + city_name, + temperature, + weather_type, + wind + ) - night_weather = ( - data[4] - if len(data) > 4 - else "-" - ) - wind_night = ( - data[5] - if len(data) > 5 - else "-" - ) +# ============================================================ +# 微信 API +# ============================================================ - low = ( - data[6] - if len(data) > 6 - else "-" - ) +class WeChatAPI: + """微信 API 接口封装""" - if day_weather in ( - "", - "-", - "--", - ): - weather = night_weather - else: - weather = day_weather - - if weather in ( - "", - "-", - "--", - ): - weather = "暂无" - - return WeatherData( - city=actual_city, - date=target_date, - weather=weather, - high_temperature=( - clean_temperature(high) - ), - low_temperature=( - clean_temperature(low) - ), - wind_day=wind_day or "-", - wind_night=wind_night or "-", - ) + def __init__(self, session: requests.Session): + self.session = session - return None + def get_access_token( + self + ) -> Optional[str]: + """ + 获取微信公众号 access_token - def _parse_summary( - self, - soup: BeautifulSoup, - city: str, - target_date: datetime_module.date, - ) -> Optional[WeatherData]: + Returns: + access_token """ - 从城市页面的摘要区域获取最高/最低温。 - 中国天气网当前吉安页面会展示: + if not APP_ID or not APP_SECRET: + log( + "错误:APP_ID 或 APP_SECRET 未配置" + ) - 吉安 - 25℃/33℃ + return None - 这能作为表格解析失败时的兜底。 - """ + url = ( + "https://api.weixin.qq.com/" + "cgi-bin/token" + ) + + params = { + "grant_type": "client_credential", + "appid": APP_ID, + "secret": APP_SECRET + } - city_link = None + try: - for link in soup.find_all("a"): - text = clean_text( - link.get_text( - " ", - strip=True, - ) + log( + "正在获取微信 access_token..." ) - if city_equal(text, city): - city_link = link - break + response = self.session.get( + url, + params=params, + timeout=REQUEST_TIMEOUT + ) - if city_link is None: - return None + response.raise_for_status() - # 城市链接后面通常紧跟温度文本。 - parent = city_link.parent + data = response.json() - if parent: - text = clean_text( - parent.get_text( - " ", - strip=True, - ) + access_token = data.get( + "access_token" ) - result = self._extract_temperature_pair( - text - ) + if access_token: - if result: - low, high = result - - return WeatherData( - city=city, - date=target_date, - weather="暂无", - high_temperature=high, - low_temperature=low, - wind_day="-", - wind_night="-", + log( + "微信 access_token 获取成功" ) - # 如果父节点没有温度,再检查附近文本。 - for element in ( - city_link.find_next( - string=True - ), - city_link.find_next( - "li" - ), - city_link.find_parent("li"), - ): - if not element: - continue + return access_token - text = clean_text( - element.get_text( - " ", - strip=True, - ) - if hasattr( - element, - "get_text", - ) - else str(element) + log( + "获取 access_token 失败:" + f"errcode={data.get('errcode')}, " + f"errmsg={data.get('errmsg')}" ) - result = self._extract_temperature_pair( - text - ) + return None - if result: - low, high = result - - return WeatherData( - city=city, - date=target_date, - weather="暂无", - high_temperature=high, - low_temperature=low, - wind_day="-", - wind_night="-", - ) + except requests.RequestException as e: - return None + log( + f"请求 access_token 失败:{e}" + ) - @staticmethod - def _extract_temperature_pair( - text: str, - ) -> Optional[tuple[str, str]]: - """ - 解析: - 25℃/33℃ - 25°C/33°C - """ + return None - match = re.search( - r"(-?\d+(?:\.\d+)?)" - r"\s*[℃°]?\s*/\s*" - r"(-?\d+(?:\.\d+)?)" - r"\s*[℃°]?", - text, - ) + except ValueError as e: + + log( + f"微信接口返回 JSON 解析失败:{e}" + ) - if not match: return None - low = match.group(1) - high = match.group(2) + def send_weather_message( + self, + access_token: str, + weather_data: Tuple[str, str, str, str], + daily_note: str + ) -> bool: + """ + 发送微信模板消息 - return low, high + Args: + access_token: 微信访问令牌 + weather_data: + (城市, 温度, 天气, 风力) + daily_note: + 每日寄语 + Returns: + 是否发送成功 + """ -# ============================================================ -# 每日寄语 -# ============================================================ + if not access_token: + log( + "错误:access_token 为空" + ) + return False -class InspirationService: - """每日寄语服务。""" + if not OPEN_ID: + log( + "错误:OPEN_ID 未配置" + ) + return False - def __init__( - self, - session: requests.Session, - ): - self.session = session + if not WEATHER_TEMPLATE_ID: + log( + "错误:TEMPLATE_ID 未配置" + ) + return False + + message_data = ( + self._build_message_data( + weather_data, + daily_note + ) + ) - def get(self) -> str: - """获取每日寄语。""" + url = ( + "https://api.weixin.qq.com/" + "cgi-bin/message/template/send" + ) + + params = { + "access_token": access_token + } try: - response = self.session.get( - INSPIRATION_URL, - timeout=INSPIRATION_TIMEOUT, + + log( + "正在发送微信天气消息..." ) - response.raise_for_status() + response = self.session.post( + url, + params=params, + json=message_data, + timeout=REQUEST_TIMEOUT + ) - data: Any = response.json() + response.raise_for_status() - if not isinstance(data, dict): - return DEFAULT_INSPIRATION + result = response.json() - return_obj = data.get( - "returnObj" - ) + if result.get("errcode") == 0: - if ( - isinstance( - return_obj, - list, + log( + "微信天气消息发送成功!" ) - and return_obj - ): - note = return_obj[0] - if ( - isinstance(note, str) - and note.strip() - ): - return clean_text(note) + return True - except requests.RequestException as exc: - logger.warning( - "每日寄语请求失败:%s", - exc, + log( + "微信消息发送失败:" + f"errcode={result.get('errcode')}, " + f"errmsg={result.get('errmsg')}" ) - except ValueError as exc: - logger.warning( - "每日寄语 JSON 解析失败:%s", - exc, + return False + + except requests.RequestException as e: + + log( + f"发送微信消息失败:{e}" ) - except Exception: - logger.exception( - "每日寄语解析失败" + return False + + except ValueError as e: + + log( + f"微信发送接口 JSON 解析失败:{e}" ) - return DEFAULT_INSPIRATION + return False + + @staticmethod + def _build_message_data( + weather_data: Tuple[str, str, str, str], + daily_note: str + ) -> Dict[str, Any]: + """构建微信模板消息数据""" + + # 使用北京时间,而不是 GitHub Runner 的 UTC 时间 + today = datetime.datetime.now( + ZoneInfo(TIMEZONE) + ).date() + + today_str = today.strftime( + "%Y年%m月%d日" + ) + + return { + "touser": OPEN_ID, + + "template_id": WEATHER_TEMPLATE_ID, + + "url": ( + "https://www.weather.com.cn/" + ), + + "data": { + + "date": { + "value": today_str, + "color": "#173177" + }, + + "region": { + "value": weather_data[0], + "color": "#173177" + }, + + "weather": { + "value": weather_data[2], + "color": "#173177" + }, + + "temp": { + "value": weather_data[1], + "color": "#FF0000" + }, + + "wind_dir": { + "value": weather_data[3], + "color": "#173177" + }, + + "today_note": { + "value": daily_note, + "color": "#FF00FF" + } + } + } # ============================================================ -# 微信公众号 +# 每日寄语 # ============================================================ -class WeChatService: - """微信公众号服务。""" +class DailyInspiration: + """每日寄语获取""" - def __init__( - self, - config: Config, - session: requests.Session, - ): - self.config = config + def __init__(self, session: requests.Session): self.session = session - def get_access_token(self) -> str: - """获取 access_token。""" + def get_daily_inspiration(self) -> str: + """ + 获取每日一句情话/寄语 - logger.info( - "正在获取微信 access_token..." - ) + 如果接口失败,则使用默认寄语。 + """ - response = self.session.get( - WECHAT_TOKEN_URL, - params={ - "grant_type": "client_credential", - "appid": self.config.app_id, - "secret": self.config.app_secret, - }, - timeout=REQUEST_TIMEOUT, + url = ( + "https://api.lovelive.tools/" + "api/SweetNothings/Serialization/Json" ) - response.raise_for_status() - - data = response.json() + try: - access_token = data.get( - "access_token" - ) + log( + "正在获取每日寄语..." + ) - if access_token: - logger.info( - "access_token 获取成功" + response = self.session.get( + url, + timeout=8 ) - return access_token + response.raise_for_status() - raise RuntimeError( - "获取微信 access_token 失败:" - f"errcode={data.get('errcode')} " - f"errmsg={data.get('errmsg')}" - ) + data = response.json() - def send_weather( - self, - weather: WeatherData, - note: str, - ) -> bool: - """发送天气模板消息。""" + if ( + isinstance(data, dict) + and data.get("returnObj") + ): - access_token = ( - self.get_access_token() - ) + return_obj = data["returnObj"] - # ---------------------------------------------------- - # 模板字段 - # ---------------------------------------------------- + if ( + isinstance(return_obj, list) + and len(return_obj) > 0 + ): - message_data: Dict[str, Any] = { - self.config.date_field: { - "value": weather.date.strftime( - "%Y年%m月%d日" - ), - "color": "#173177", - }, - - self.config.region_field: { - "value": weather.city, - "color": "#173177", - }, - - self.config.weather_field: { - "value": weather.weather, - "color": "#173177", - }, - - self.config.temp_field: { - "value": weather.temperature, - "color": "#FF0000", - }, - - self.config.wind_field: { - "value": weather.wind, - "color": "#173177", - }, - - self.config.note_field: { - "value": note, - "color": "#FF00FF", - }, - } + note = str( + return_obj[0] + ).strip() - payload = { - "touser": self.config.open_id, + if note: + log( + f"每日寄语:{note}" + ) - "template_id": ( - self.config.template_id - ), + return note - "url": ( - "https://www.weather.com.cn/" - ), + except requests.RequestException as e: - "data": message_data, - } + log( + f"获取每日寄语网络失败:{e}" + ) - logger.info( - "正在发送微信天气消息..." - ) + except ValueError as e: - response = self.session.post( - WECHAT_TEMPLATE_SEND_URL, - params={ - "access_token": access_token - }, - json=payload, - timeout=REQUEST_TIMEOUT, - ) + log( + f"每日寄语 JSON 解析失败:{e}" + ) + + except Exception as e: - response.raise_for_status() + log( + f"获取每日寄语失败:{e}" + ) - result = response.json() + # ---------------------------------------------------- + # 默认寄语 + # ---------------------------------------------------- - errcode = result.get( - "errcode" + default_note = ( + "愿你的一天充满阳光和微笑!" ) - if errcode == 0: - logger.info( - "微信天气消息发送成功" - ) - return True - - # 不打印 AppSecret。 - logger.error( - "微信消息发送失败:" - "errcode=%s, errmsg=%s", - errcode, - result.get("errmsg"), + log( + f"使用默认寄语:{default_note}" ) - raise RuntimeError( - "微信消息发送失败:" - f"errcode={errcode}, " - f"errmsg={result.get('errmsg')}" - ) + return default_note # ============================================================ -# 主程序 +# 天气报告主控制器 # ============================================================ -def run( - config: Config, - city: str, -) -> bool: - """运行完整推送流程。""" +class WeatherReporter: + """天气报告主控制器""" - target_date = get_today( - config.timezone - ) + def __init__( + self, + session: requests.Session + ): - logger.info( - "========================================" - ) + self.weather_fetcher = ( + WeatherFetcher(session) + ) - logger.info( - "%s 开始", - APP_NAME, - ) + self.wechat_api = ( + WeChatAPI(session) + ) - logger.info( - "城市:%s", - city, - ) + self.daily_inspiration = ( + DailyInspiration(session) + ) - logger.info( - "日期:%s", - target_date.strftime( - "%Y-%m-%d" - ), - ) + def report_weather( + self, + city: str + ) -> bool: + """ + 执行完整天气报告流程 + """ - logger.info( - "时区:%s", - config.timezone, - ) + log( + "========================================" + ) - logger.info( - "========================================" - ) + log( + f"开始执行天气推送:{city}" + ) - session = create_session() + log( + "========================================" + ) - try: # ---------------------------------------------------- - # 1. 天气 + # 1. 获取天气 # ---------------------------------------------------- - weather = WeatherFetcher( - session - ).get_weather( - city=city, - target_date=target_date, + weather_data = ( + self.weather_fetcher.fetch_weather_data( + city + ) ) - if weather is None: - raise RuntimeError( - f"没有获取到 {city} 的天气数据" + if not weather_data: + + log( + "天气数据获取失败,停止发送" ) - logger.info( - "天气:%s", - weather.weather, - ) + return False - logger.info( - "温度:%s", - weather.temperature, + log( + f"天气信息:{weather_data}" ) - logger.info( - "风力:%s", - weather.wind, + # ---------------------------------------------------- + # 2. 获取微信 access_token + # ---------------------------------------------------- + + access_token = ( + self.wechat_api.get_access_token() ) + if not access_token: + + log( + "无法获取 access_token," + "停止发送" + ) + + return False + # ---------------------------------------------------- - # 2. 每日寄语 + # 3. 获取每日寄语 # ---------------------------------------------------- - note = InspirationService( - session - ).get() - - logger.info( - "每日寄语:%s", - note, + daily_note = ( + self.daily_inspiration + .get_daily_inspiration() ) # ---------------------------------------------------- - # 3. 微信推送 + # 4. 发送微信消息 # ---------------------------------------------------- - WeChatService( - config=config, - session=session, - ).send_weather( - weather=weather, - note=note, + success = ( + self.wechat_api.send_weather_message( + access_token, + weather_data, + daily_note + ) ) - logger.info( - "========================================" - ) + if success: + + log( + "天气报告发送完成!" + ) - logger.info( - "天气推送全部完成" + else: + + log( + "天气报告发送失败!" + ) + + return success + + +# ============================================================ +# 环境变量检查 +# ============================================================ + +def check_environment() -> bool: + """检查必要的环境变量""" + + required_env_vars = [ + "APP_ID", + "APP_SECRET", + "OPEN_ID", + "TEMPLATE_ID" + ] + + missing_vars = [ + var + for var in required_env_vars + if not os.environ.get(var, "").strip() + ] + + if missing_vars: + + log( + "错误:缺少必要的环境变量:" + + ", ".join(missing_vars) ) - logger.info( - "========================================" + log( + "请在 GitHub Settings → " + "Secrets and variables → Actions " + "中配置这些变量。" ) - return True + return False - finally: - session.close() + return True # ============================================================ -# 命令行 +# 主函数 # ============================================================ def main() -> int: - """程序入口。""" + """主函数""" - parser = argparse.ArgumentParser( - description="微信公众号天气推送" + log( + "========================================" ) - parser.add_argument( - "--city", - default=None, - help="城市名称,例如:吉安", + log( + "微信天气推送服务启动" ) - parser.add_argument( - "--debug", - action="store_true", - help="开启 DEBUG 日志", + log( + "========================================" ) - args = parser.parse_args() + # -------------------------------------------------------- + # 检查配置 + # -------------------------------------------------------- - if args.debug: - logger.setLevel( - logging.DEBUG - ) + if not check_environment(): + + return 1 # -------------------------------------------------------- - # 读取配置 + # 城市 # -------------------------------------------------------- - config = Config.from_environment() + city = CITY - try: - config.validate() + if not city: - except ValueError as exc: - logger.error( - "%s", - exc, + log( + "错误:CITY 不能为空" ) + return 1 - city = ( - args.city - or config.city - ).strip() + log( + f"目标城市:{city}" + ) - if not city: - logger.error( - "城市不能为空" + # -------------------------------------------------------- + # 北京时间 + # -------------------------------------------------------- + + now = datetime.datetime.now( + ZoneInfo(TIMEZONE) + ) + + log( + "北京时间:" + + now.strftime( + "%Y-%m-%d %H:%M:%S" ) - return 1 + ) # -------------------------------------------------------- - # 执行 + # 创建 Session # -------------------------------------------------------- + session = create_session() + try: - success = run( - config=config, - city=city, + + reporter = WeatherReporter( + session ) - return 0 if success else 1 + success = ( + reporter.report_weather(city) + ) + + if success: + + log( + "程序执行成功" + ) + + return 0 - except requests.RequestException as exc: - logger.error( - "网络请求失败:%s", - exc, + log( + "程序执行失败" ) return 1 - except Exception as exc: - logger.error( - "程序运行失败:%s", - exc, + except KeyboardInterrupt: + + log( + "程序被手动中断" ) - logger.debug( - "完整异常:", - exc_info=True, + return 130 + + except Exception as e: + + log( + f"程序发生未处理异常:{e}" ) return 1 + finally: + + session.close() + + +# ============================================================ +# 程序入口 +# ============================================================ if __name__ == "__main__": - sys.exit(main()) + raise SystemExit( + main() + ) ``` From f6d63bf791fa45f8e7b4f5e722d9ed20be6f858e Mon Sep 17 00:00:00 2001 From: tie4453 Date: Sun, 30 Aug 2026 05:53:23 +0800 Subject: [PATCH 20/21] Update weather_report.py --- weather_report.py | 339 +++++++++++++++++++++++++++------------------- 1 file changed, 197 insertions(+), 142 deletions(-) diff --git a/weather_report.py b/weather_report.py index 2019ed01..5066922e 100644 --- a/weather_report.py +++ b/weather_report.py @@ -17,38 +17,38 @@ # ============================================================ -# 配置 +# 常量定义 # ============================================================ -# 中国天气网天气页面 WEATHER_URLS = [ - "https://www.weather.com.cn/textFC/hb.shtml", # 华北 - "https://www.weather.com.cn/textFC/db.shtml", # 东北 - "https://www.weather.com.cn/textFC/hd.shtml", # 华东 - "https://www.weather.com.cn/textFC/hz.shtml", # 华中 - "https://www.weather.com.cn/textFC/hn.shtml", # 华南 - "https://www.weather.com.cn/textFC/xb.shtml", # 西北 - "https://www.weather.com.cn/textFC/xn.shtml", # 西南 + "http://www.weather.com.cn/textFC/hb.shtml", # 华北 + "http://www.weather.com.cn/textFC/db.shtml", # 东北 + "http://www.weather.com.cn/textFC/hd.shtml", # 华东 + "http://www.weather.com.cn/textFC/hz.shtml", # 华中 + "http://www.weather.com.cn/textFC/hn.shtml", # 华南 + "http://www.weather.com.cn/textFC/xb.shtml", # 西北 + "http://www.weather.com.cn/textFC/xn.shtml", # 西南 ] - -# 默认城市 -CITY = os.environ.get("CITY", "吉安").strip() - - -# 微信公众号配置 +# 从环境变量获取配置 APP_ID = os.environ.get("APP_ID", "").strip() APP_SECRET = os.environ.get("APP_SECRET", "").strip() OPEN_ID = os.environ.get("OPEN_ID", "").strip() WEATHER_TEMPLATE_ID = os.environ.get("TEMPLATE_ID", "").strip() +# 城市,GitHub Actions 中可以用 CITY 覆盖 +CITY = os.environ.get("CITY", "吉安").strip() or "吉安" -# 中国时区 +# 中国北京时间 TIMEZONE = "Asia/Shanghai" - # 请求超时时间 -REQUEST_TIMEOUT = 15 +WEATHER_TIMEOUT = 10 +WECHAT_TIMEOUT = 10 +INSPIRATION_TIMEOUT = 5 + +# 每日寄语备用内容 +DEFAULT_INSPIRATION = "愿你的一天充满阳光和微笑!" # ============================================================ @@ -56,12 +56,15 @@ # ============================================================ def log(message: str) -> None: - """统一输出日志""" + """统一输出带北京时间的日志""" + now = datetime.datetime.now( ZoneInfo(TIMEZONE) - ).strftime("%Y-%m-%d %H:%M:%S") + ) - print(f"[{now}] {message}") + print( + f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] {message}" + ) # ============================================================ @@ -77,7 +80,7 @@ def create_session() -> requests.Session: total=3, connect=3, read=3, - backoff_factor=1, + backoff_factor=0.5, status_forcelist=[ 429, 500, @@ -85,10 +88,11 @@ def create_session() -> requests.Session: 503, 504, ], - allowed_methods=[ + allowed_methods=frozenset([ "GET", "POST", - ], + ]), + raise_on_status=False, ) adapter = HTTPAdapter( @@ -118,6 +122,27 @@ def create_session() -> requests.Session: return session +# ============================================================ +# 城市名称处理 +# ============================================================ + +def normalize_city_name(city: str) -> str: + """ + 标准化城市名称。 + + 例如: + 吉安 -> 吉安 + 吉安市 -> 吉安 + """ + + city = city.strip() + + if city.endswith("市"): + city = city[:-1] + + return city + + # ============================================================ # 天气数据获取 # ============================================================ @@ -139,33 +164,33 @@ def fetch_weather_data( city: 城市名称 Returns: - 元组: - (城市名, 温度范围, 天气类型, 风向风力) - + 元组 (城市名, 温度范围, 天气类型, 风向风力) 如果未找到则返回 None """ - log(f"开始获取 {city} 的天气信息...") + target_city = normalize_city_name(city) + + log( + f"开始获取 {city} 的天气信息..." + ) for url in WEATHER_URLS: try: - log(f"正在访问天气网站:{url}") + + log( + f"正在请求天气数据:{url}" + ) response = self.session.get( url, - timeout=REQUEST_TIMEOUT + timeout=WEATHER_TIMEOUT ) response.raise_for_status() - # 中国天气网部分页面可能使用 GBK - if response.apparent_encoding: - response.encoding = ( - response.apparent_encoding - ) - else: - response.encoding = "utf-8" + # 保留原程序的 UTF-8 处理方式 + response.encoding = "utf-8" soup = BeautifulSoup( response.text, @@ -178,45 +203,55 @@ def fetch_weather_data( ) if not div_con_midtab: + log( f"页面没有找到 conMidtab:{url}" ) + continue + # 在找到的区域内搜索城市 result = self._search_city_in_tables( div_con_midtab, - city + target_city ) if result: + log( - f"天气获取成功:{result}" + f"天气信息获取成功:{result}" ) return result except requests.RequestException as e: + log( f"请求天气数据失败 {url}: {e}" ) + continue + except Exception as e: + log( f"解析天气数据失败 {url}: {e}" ) + continue + log( f"未找到城市 '{city}' 的天气信息" ) return None + @staticmethod def _search_city_in_tables( - self, div_con_midtab, target_city: str ) -> Optional[Tuple[str, str, str, str]]: - """在表格中搜索指定城市""" + """在表格中搜索特定城市的天气信息""" tables = div_con_midtab.find_all( "table" @@ -224,10 +259,8 @@ def _search_city_in_tables( for table in tables: - rows = table.find_all("tr") - # 保留你原来的跳过表头逻辑 - rows = rows[2:] + rows = table.find_all("tr")[2:] for row in rows: @@ -236,17 +269,24 @@ def _search_city_in_tables( if len(cells) < 8: continue + # 获取城市名 city_cell = cells[-8] city_name = city_cell.get_text( strip=True ) - if city_name == target_city: + if ( + normalize_city_name(city_name) + == target_city + ): - return self._extract_weather_data( - cells, - city_name + return ( + WeatherFetcher + ._extract_weather_data( + cells, + city_name + ) ) return None @@ -258,6 +298,7 @@ def _extract_weather_data( ) -> Tuple[str, str, str, str]: """从表格单元格中提取天气数据""" + # 提取各个数据字段 high_temp = cells[-5].get_text( strip=True ) @@ -283,67 +324,80 @@ def _extract_weather_data( ) # ---------------------------------------------------- - # 温度 + # 处理温度 # ---------------------------------------------------- if ( - high_temp != "-" - and low_temp != "-" + high_temp not in ("", "-") + and low_temp not in ("", "-") ): + temperature = ( f"{low_temp}~{high_temp}°C" ) - elif low_temp != "-": + elif low_temp not in ("", "-"): + temperature = ( f"{low_temp}°C" ) - elif high_temp != "-": + elif high_temp not in ("", "-"): + temperature = ( f"{high_temp}°C" ) else: + temperature = "温度数据缺失" # ---------------------------------------------------- - # 天气 + # 处理天气类型 # ---------------------------------------------------- - weather_type = weather_day - - if weather_type in ( + if weather_day not in ( "", "-", "--" ): - weather_type = weather_night - if weather_type in ( + weather_type = weather_day + + elif weather_night not in ( "", "-", "--" ): + + weather_type = weather_night + + else: + weather_type = "天气数据缺失" # ---------------------------------------------------- - # 风向风力 + # 处理风向风力 # ---------------------------------------------------- - if wind_day and wind_day not in ( + if wind_day not in ( + "", "-", "--" ): + wind = wind_day - elif wind_night and wind_night not in ( + elif wind_night not in ( + "", "-", "--" ): + wind = wind_night else: + wind = "风力数据缺失" return ( @@ -368,13 +422,15 @@ def get_access_token( self ) -> Optional[str]: """ - 获取微信公众号 access_token + 获取微信 access_token Returns: - access_token + access_token 字符串 + 失败返回 None """ if not APP_ID or not APP_SECRET: + log( "错误:APP_ID 或 APP_SECRET 未配置" ) @@ -401,7 +457,7 @@ def get_access_token( response = self.session.get( url, params=params, - timeout=REQUEST_TIMEOUT + timeout=WECHAT_TIMEOUT ) response.raise_for_status() @@ -415,7 +471,7 @@ def get_access_token( if access_token: log( - "微信 access_token 获取成功" + "获取 access_token 成功" ) return access_token @@ -439,7 +495,7 @@ def get_access_token( except ValueError as e: log( - f"微信接口返回 JSON 解析失败:{e}" + f"access_token 返回数据解析失败:{e}" ) return None @@ -447,41 +503,46 @@ def get_access_token( def send_weather_message( self, access_token: str, - weather_data: Tuple[str, str, str, str], + weather_data: Tuple, daily_note: str ) -> bool: """ - 发送微信模板消息 + 发送天气模板消息 Args: access_token: 微信访问令牌 - weather_data: - (城市, 温度, 天气, 风力) - daily_note: - 每日寄语 + weather_data: 天气数据元组 + daily_note: 每日寄语 Returns: - 是否发送成功 + 发送是否成功 """ if not access_token: + log( "错误:access_token 为空" ) + return False if not OPEN_ID: + log( "错误:OPEN_ID 未配置" ) + return False if not WEATHER_TEMPLATE_ID: + log( "错误:TEMPLATE_ID 未配置" ) + return False + # 准备消息数据 message_data = ( self._build_message_data( weather_data, @@ -508,23 +569,25 @@ def send_weather_message( url, params=params, json=message_data, - timeout=REQUEST_TIMEOUT + timeout=WECHAT_TIMEOUT ) response.raise_for_status() result = response.json() - if result.get("errcode") == 0: + if result.get( + "errcode" + ) == 0: log( - "微信天气消息发送成功!" + "消息发送成功" ) return True log( - "微信消息发送失败:" + "消息发送失败:" f"errcode={result.get('errcode')}, " f"errmsg={result.get('errmsg')}" ) @@ -534,7 +597,7 @@ def send_weather_message( except requests.RequestException as e: log( - f"发送微信消息失败:{e}" + f"发送消息失败:{e}" ) return False @@ -549,12 +612,12 @@ def send_weather_message( @staticmethod def _build_message_data( - weather_data: Tuple[str, str, str, str], + weather_data: Tuple, daily_note: str ) -> Dict[str, Any]: """构建微信模板消息数据""" - # 使用北京时间,而不是 GitHub Runner 的 UTC 时间 + # 使用北京时间 today = datetime.datetime.now( ZoneInfo(TIMEZONE) ).date() @@ -564,12 +627,14 @@ def _build_message_data( ) return { - "touser": OPEN_ID, + "touser": OPEN_ID.strip(), - "template_id": WEATHER_TEMPLATE_ID, + "template_id": ( + WEATHER_TEMPLATE_ID.strip() + ), "url": ( - "https://www.weather.com.cn/" + "https://mp.weixin.qq.com" ), "data": { @@ -614,14 +679,19 @@ def _build_message_data( class DailyInspiration: """每日寄语获取""" - def __init__(self, session: requests.Session): + def __init__( + self, + session: requests.Session + ): self.session = session - def get_daily_inspiration(self) -> str: + def get_daily_inspiration( + self + ) -> str: """ 获取每日一句情话/寄语 - 如果接口失败,则使用默认寄语。 + 失败时使用默认寄语 """ url = ( @@ -637,7 +707,7 @@ def get_daily_inspiration(self) -> str: response = self.session.get( url, - timeout=8 + timeout=INSPIRATION_TIMEOUT ) response.raise_for_status() @@ -661,6 +731,7 @@ def get_daily_inspiration(self) -> str: ).strip() if note: + log( f"每日寄语:{note}" ) @@ -685,19 +756,12 @@ def get_daily_inspiration(self) -> str: f"获取每日寄语失败:{e}" ) - # ---------------------------------------------------- - # 默认寄语 - # ---------------------------------------------------- - - default_note = ( - "愿你的一天充满阳光和微笑!" - ) - + # 备用寄语 log( - f"使用默认寄语:{default_note}" + f"使用默认寄语:{DEFAULT_INSPIRATION}" ) - return default_note + return DEFAULT_INSPIRATION # ============================================================ @@ -729,56 +793,47 @@ def report_weather( city: str ) -> bool: """ - 执行完整天气报告流程 + 执行完整的天气报告流程 """ log( - "========================================" - ) - - log( - f"开始执行天气推送:{city}" - ) - - log( - "========================================" + f"开始执行天气报告:{city}" ) # ---------------------------------------------------- - # 1. 获取天气 + # 1. 获取天气数据 # ---------------------------------------------------- weather_data = ( - self.weather_fetcher.fetch_weather_data( - city - ) + self.weather_fetcher + .fetch_weather_data(city) ) if not weather_data: log( - "天气数据获取失败,停止发送" + "天气数据获取失败" ) return False log( - f"天气信息:{weather_data}" + f"天气信息获取成功:{weather_data}" ) # ---------------------------------------------------- - # 2. 获取微信 access_token + # 2. 获取 access_token # ---------------------------------------------------- access_token = ( - self.wechat_api.get_access_token() + self.wechat_api + .get_access_token() ) if not access_token: log( - "无法获取 access_token," - "停止发送" + "access_token 获取失败" ) return False @@ -797,25 +852,14 @@ def report_weather( # ---------------------------------------------------- success = ( - self.wechat_api.send_weather_message( + self.wechat_api + .send_weather_message( access_token, weather_data, daily_note ) ) - if success: - - log( - "天气报告发送完成!" - ) - - else: - - log( - "天气报告发送失败!" - ) - return success @@ -836,7 +880,10 @@ def check_environment() -> bool: missing_vars = [ var for var in required_env_vars - if not os.environ.get(var, "").strip() + if not os.environ.get( + var, + "" + ).strip() ] if missing_vars: @@ -847,9 +894,8 @@ def check_environment() -> bool: ) log( - "请在 GitHub Settings → " - "Secrets and variables → Actions " - "中配置这些变量。" + "请在 GitHub Secrets 中配置:" + "APP_ID、APP_SECRET、OPEN_ID、TEMPLATE_ID" ) return False @@ -877,15 +923,14 @@ def main() -> int: ) # -------------------------------------------------------- - # 检查配置 + # 检查必要配置 # -------------------------------------------------------- if not check_environment(): - return 1 # -------------------------------------------------------- - # 城市 + # 获取城市 # -------------------------------------------------------- city = CITY @@ -903,7 +948,7 @@ def main() -> int: ) # -------------------------------------------------------- - # 北京时间 + # 获取北京时间 # -------------------------------------------------------- now = datetime.datetime.now( @@ -930,19 +975,29 @@ def main() -> int: ) success = ( - reporter.report_weather(city) + reporter.report_weather( + city + ) ) if success: log( - "程序执行成功" + "========================================" + ) + + log( + "天气报告发送完成!" + ) + + log( + "========================================" ) return 0 log( - "程序执行失败" + "天气报告发送失败!" ) return 1 From 08bc420670d8bde192b23f9395000894fb86b89a Mon Sep 17 00:00:00 2001 From: tie4453 Date: Sun, 30 Aug 2026 20:00:25 +0800 Subject: [PATCH 21/21] Update weather_report.py --- weather_report.py | 1090 ++++++++------------------------------------- 1 file changed, 187 insertions(+), 903 deletions(-) diff --git a/weather_report.py b/weather_report.py index 5066922e..8e2db4a8 100644 --- a/weather_report.py +++ b/weather_report.py @@ -1,25 +1,16 @@ -```python """ 微信天气推送服务 功能:从天气网站抓取数据,通过微信模板消息推送给指定用户 -运行环境:GitHub Actions """ import os +import json import datetime -from zoneinfo import ZoneInfo -from typing import Optional, Tuple, Dict, Any - import requests +from typing import Optional, Tuple, Dict, Any from bs4 import BeautifulSoup -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - - -# ============================================================ -# 常量定义 -# ============================================================ +# 常量定义(使用大写命名) WEATHER_URLS = [ "http://www.weather.com.cn/textFC/hb.shtml", # 华北 "http://www.weather.com.cn/textFC/db.shtml", # 东北 @@ -30,1005 +21,298 @@ "http://www.weather.com.cn/textFC/xn.shtml", # 西南 ] -# 从环境变量获取配置 -APP_ID = os.environ.get("APP_ID", "").strip() -APP_SECRET = os.environ.get("APP_SECRET", "").strip() -OPEN_ID = os.environ.get("OPEN_ID", "").strip() -WEATHER_TEMPLATE_ID = os.environ.get("TEMPLATE_ID", "").strip() - -# 城市,GitHub Actions 中可以用 CITY 覆盖 -CITY = os.environ.get("CITY", "吉安").strip() or "吉安" - -# 中国北京时间 -TIMEZONE = "Asia/Shanghai" - -# 请求超时时间 -WEATHER_TIMEOUT = 10 -WECHAT_TIMEOUT = 10 -INSPIRATION_TIMEOUT = 5 - -# 每日寄语备用内容 -DEFAULT_INSPIRATION = "愿你的一天充满阳光和微笑!" - - -# ============================================================ -# 日志 -# ============================================================ - -def log(message: str) -> None: - """统一输出带北京时间的日志""" - - now = datetime.datetime.now( - ZoneInfo(TIMEZONE) - ) - - print( - f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] {message}" - ) - - -# ============================================================ -# HTTP Session -# ============================================================ - -def create_session() -> requests.Session: - """创建带自动重试功能的 HTTP Session""" - - session = requests.Session() - - retry = Retry( - total=3, - connect=3, - read=3, - backoff_factor=0.5, - status_forcelist=[ - 429, - 500, - 502, - 503, - 504, - ], - allowed_methods=frozenset([ - "GET", - "POST", - ]), - raise_on_status=False, - ) +# 从环境变量获取配置(增加默认值) +APP_ID = os.environ.get("APP_ID", "") +APP_SECRET = os.environ.get("APP_SECRET", "") +OPEN_ID = os.environ.get("OPEN_ID", "") +WEATHER_TEMPLATE_ID = os.environ.get("TEMPLATE_ID", "") - adapter = HTTPAdapter( - max_retries=retry - ) - - session.mount( - "http://", - adapter - ) - - session.mount( - "https://", - adapter - ) - - session.headers.update({ - "User-Agent": ( - "Mozilla/5.0 " - "(Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 " - "(KHTML, like Gecko) " - "Chrome/131.0 Safari/537.36" - ) - }) - - return session - - -# ============================================================ -# 城市名称处理 -# ============================================================ - -def normalize_city_name(city: str) -> str: - """ - 标准化城市名称。 - - 例如: - 吉安 -> 吉安 - 吉安市 -> 吉安 - """ - - city = city.strip() - - if city.endswith("市"): - city = city[:-1] - - return city - - -# ============================================================ -# 天气数据获取 -# ============================================================ class WeatherFetcher: """天气数据获取器""" - - def __init__(self, session: requests.Session): - self.session = session - - def fetch_weather_data( - self, - city: str - ) -> Optional[Tuple[str, str, str, str]]: + + @staticmethod + def fetch_weather_data(city: str) -> Optional[Tuple[str, str, str, str]]: """ 获取指定城市的天气信息 - + Args: city: 城市名称 - + Returns: 元组 (城市名, 温度范围, 天气类型, 风向风力) - 如果未找到则返回 None + 如果未找到则返回None """ - - target_city = normalize_city_name(city) - - log( - f"开始获取 {city} 的天气信息..." - ) - for url in WEATHER_URLS: - try: - - log( - f"正在请求天气数据:{url}" - ) - - response = self.session.get( - url, - timeout=WEATHER_TIMEOUT - ) - + response = requests.get(url, timeout=10) response.raise_for_status() - - # 保留原程序的 UTF-8 处理方式 - response.encoding = "utf-8" - - soup = BeautifulSoup( - response.text, - "html.parser" - ) - - div_con_midtab = soup.find( - "div", - class_="conMidtab" - ) - + response.encoding = 'utf-8' + + soup = BeautifulSoup(response.text, 'html.parser') + div_con_midtab = soup.find("div", class_="conMidtab") + if not div_con_midtab: - - log( - f"页面没有找到 conMidtab:{url}" - ) - continue - + # 在找到的区域内搜索城市 - result = self._search_city_in_tables( - div_con_midtab, - target_city - ) - + result = WeatherFetcher._search_city_in_tables(div_con_midtab, city) if result: - - log( - f"天气信息获取成功:{result}" - ) - return result - + except requests.RequestException as e: - - log( - f"请求天气数据失败 {url}: {e}" - ) - + print(f"请求天气数据失败 {url}: {e}") continue - - except Exception as e: - - log( - f"解析天气数据失败 {url}: {e}" - ) - - continue - - log( - f"未找到城市 '{city}' 的天气信息" - ) - + + print(f"未找到城市 '{city}' 的天气信息") return None - + @staticmethod - def _search_city_in_tables( - div_con_midtab, - target_city: str - ) -> Optional[Tuple[str, str, str, str]]: + def _search_city_in_tables(div_con_midtab, target_city: str) -> Optional[Tuple[str, str, str, str]]: """在表格中搜索特定城市的天气信息""" - - tables = div_con_midtab.find_all( - "table" - ) - + tables = div_con_midtab.find_all("table") + for table in tables: - - # 保留你原来的跳过表头逻辑 + # 跳过表头 rows = table.find_all("tr")[2:] - + for row in rows: - cells = row.find_all("td") - if len(cells) < 8: continue - - # 获取城市名 + + # 获取城市名(从倒数第8个单元格) city_cell = cells[-8] - - city_name = city_cell.get_text( - strip=True - ) - - if ( - normalize_city_name(city_name) - == target_city - ): - - return ( - WeatherFetcher - ._extract_weather_data( - cells, - city_name - ) - ) - + city_name = city_cell.get_text(strip=True) + + if city_name == target_city: + return WeatherFetcher._extract_weather_data(cells, city_name) + return None - + @staticmethod - def _extract_weather_data( - cells, - city_name: str - ) -> Tuple[str, str, str, str]: + def _extract_weather_data(cells, city_name: str) -> Tuple[str, str, str, str]: """从表格单元格中提取天气数据""" - # 提取各个数据字段 - high_temp = cells[-5].get_text( - strip=True - ) - - low_temp = cells[-2].get_text( - strip=True - ) - - weather_day = cells[-7].get_text( - strip=True - ) - - weather_night = cells[-4].get_text( - strip=True - ) - - wind_day = cells[-6].get_text( - strip=True - ) - - wind_night = cells[-3].get_text( - strip=True - ) - - # ---------------------------------------------------- - # 处理温度 - # ---------------------------------------------------- - - if ( - high_temp not in ("", "-") - and low_temp not in ("", "-") - ): - - temperature = ( - f"{low_temp}~{high_temp}°C" - ) - - elif low_temp not in ("", "-"): - - temperature = ( - f"{low_temp}°C" - ) - - elif high_temp not in ("", "-"): - - temperature = ( - f"{high_temp}°C" - ) - + high_temp = cells[-5].get_text(strip=True) + low_temp = cells[-2].get_text(strip=True) + weather_day = cells[-7].get_text(strip=True) + weather_night = cells[-4].get_text(strip=True) + wind_day = cells[-6].get_text(strip=True) + wind_night = cells[-3].get_text(strip=True) + + # 处理温度显示 + if high_temp != "-" and low_temp != "-": + temperature = f"{low_temp}~{high_temp}°C" else: - - temperature = "温度数据缺失" - - # ---------------------------------------------------- - # 处理天气类型 - # ---------------------------------------------------- - - if weather_day not in ( - "", - "-", - "--" - ): - - weather_type = weather_day - - elif weather_night not in ( - "", - "-", - "--" - ): - - weather_type = weather_night - - else: - + temperature = f"{low_temp}°C" if low_temp != "-" else "温度数据缺失" + + # 处理天气类型(优先使用白天数据) + weather_type = weather_day if weather_day != "-" else weather_night + if weather_type == "-": weather_type = "天气数据缺失" - - # ---------------------------------------------------- + # 处理风向风力 - # ---------------------------------------------------- - - if wind_day not in ( - "", - "-", - "--" - ): - + if wind_day and wind_day != "--": wind = wind_day - - elif wind_night not in ( - "", - "-", - "--" - ): - + elif wind_night and wind_night != "--": wind = wind_night - else: - wind = "风力数据缺失" + + return city_name, temperature, weather_type, wind - return ( - city_name, - temperature, - weather_type, - wind - ) - - -# ============================================================ -# 微信 API -# ============================================================ class WeChatAPI: - """微信 API 接口封装""" - - def __init__(self, session: requests.Session): - self.session = session - - def get_access_token( - self - ) -> Optional[str]: + """微信API接口封装""" + + @staticmethod + def get_access_token() -> Optional[str]: """ - 获取微信 access_token - + 获取微信access_token + Returns: - access_token 字符串 - 失败返回 None + access_token字符串,失败返回None """ - if not APP_ID or not APP_SECRET: - - log( - "错误:APP_ID 或 APP_SECRET 未配置" - ) - + print("错误:APP_ID或APP_SECRET未配置") return None - - url = ( - "https://api.weixin.qq.com/" - "cgi-bin/token" - ) - + + url = f"https://api.weixin.qq.com/cgi-bin/token" params = { "grant_type": "client_credential", - "appid": APP_ID, - "secret": APP_SECRET + "appid": APP_ID.strip(), + "secret": APP_SECRET.strip() } - + try: - - log( - "正在获取微信 access_token..." - ) - - response = self.session.get( - url, - params=params, - timeout=WECHAT_TIMEOUT - ) - + response = requests.get(url, params=params, timeout=10) response.raise_for_status() - data = response.json() - - access_token = data.get( - "access_token" - ) - - if access_token: - - log( - "获取 access_token 成功" - ) - - return access_token - - log( - "获取 access_token 失败:" - f"errcode={data.get('errcode')}, " - f"errmsg={data.get('errmsg')}" - ) - - return None - + + if 'access_token' in data: + return data['access_token'] + else: + print(f"获取access_token失败: {data}") + return None + except requests.RequestException as e: - - log( - f"请求 access_token 失败:{e}" - ) - + print(f"请求access_token失败: {e}") return None - - except ValueError as e: - - log( - f"access_token 返回数据解析失败:{e}" - ) - - return None - - def send_weather_message( - self, - access_token: str, - weather_data: Tuple, - daily_note: str - ) -> bool: + + @staticmethod + def send_weather_message(access_token: str, weather_data: Tuple, daily_note: str) -> bool: """ 发送天气模板消息 - + Args: access_token: 微信访问令牌 - weather_data: 天气数据元组 + weather_data: 天气数据元组 (城市, 温度, 天气, 风力) daily_note: 每日寄语 - + Returns: 发送是否成功 """ - - if not access_token: - - log( - "错误:access_token 为空" - ) - - return False - - if not OPEN_ID: - - log( - "错误:OPEN_ID 未配置" - ) - - return False - - if not WEATHER_TEMPLATE_ID: - - log( - "错误:TEMPLATE_ID 未配置" - ) - + if not access_token or not OPEN_ID or not WEATHER_TEMPLATE_ID: + print("错误:必要的配置参数缺失") return False - + # 准备消息数据 - message_data = ( - self._build_message_data( - weather_data, - daily_note - ) - ) - - url = ( - "https://api.weixin.qq.com/" - "cgi-bin/message/template/send" - ) - - params = { - "access_token": access_token - } - + message_data = WeChatAPI._build_message_data(weather_data, daily_note) + + url = f"https://api.weixin.qq.com/cgi-bin/message/template/send" + params = {"access_token": access_token} + try: - - log( - "正在发送微信天气消息..." - ) - - response = self.session.post( - url, - params=params, - json=message_data, - timeout=WECHAT_TIMEOUT - ) - + response = requests.post(url, params=params, json=message_data, timeout=10) response.raise_for_status() - result = response.json() - - if result.get( - "errcode" - ) == 0: - - log( - "消息发送成功" - ) - + + if result.get('errcode') == 0: + print("消息发送成功") return True - - log( - "消息发送失败:" - f"errcode={result.get('errcode')}, " - f"errmsg={result.get('errmsg')}" - ) - - return False - + else: + print(f"消息发送失败: {result}") + return False + except requests.RequestException as e: - - log( - f"发送消息失败:{e}" - ) - - return False - - except ValueError as e: - - log( - f"微信发送接口 JSON 解析失败:{e}" - ) - + print(f"发送消息失败: {e}") return False - + @staticmethod - def _build_message_data( - weather_data: Tuple, - daily_note: str - ) -> Dict[str, Any]: + def _build_message_data(weather_data: Tuple, daily_note: str) -> Dict[str, Any]: """构建微信模板消息数据""" - - # 使用北京时间 - today = datetime.datetime.now( - ZoneInfo(TIMEZONE) - ).date() - - today_str = today.strftime( - "%Y年%m月%d日" - ) - + today_str = datetime.date.today().strftime("%Y年%m月%d日") + return { "touser": OPEN_ID.strip(), - - "template_id": ( - WEATHER_TEMPLATE_ID.strip() - ), - - "url": ( - "https://mp.weixin.qq.com" - ), - + "template_id": WEATHER_TEMPLATE_ID.strip(), + "url": "https://mp.weixin.qq.com", # 更合适的跳转链接 "data": { - - "date": { - "value": today_str, - "color": "#173177" - }, - - "region": { - "value": weather_data[0], - "color": "#173177" - }, - - "weather": { - "value": weather_data[2], - "color": "#173177" - }, - - "temp": { - "value": weather_data[1], - "color": "#FF0000" - }, - - "wind_dir": { - "value": weather_data[3], - "color": "#173177" - }, - - "today_note": { - "value": daily_note, - "color": "#FF00FF" - } + "date": {"value": today_str, "color": "#173177"}, + "region": {"value": weather_data[0], "color": "#173177"}, + "weather": {"value": weather_data[2], "color": "#173177"}, + "temp": {"value": weather_data[1], "color": "#FF0000"}, + "wind_dir": {"value": weather_data[3], "color": "#173177"}, + "today_note": {"value": daily_note, "color": "#FF00FF"} } } -# ============================================================ -# 每日寄语 -# ============================================================ - class DailyInspiration: """每日寄语获取""" - - def __init__( - self, - session: requests.Session - ): - self.session = session - - def get_daily_inspiration( - self - ) -> str: + + @staticmethod + def get_daily_inspiration() -> str: """ 获取每日一句情话/寄语 - - 失败时使用默认寄语 + + Returns: + 寄语字符串,失败时返回默认寄语 """ - - url = ( - "https://api.lovelive.tools/" - "api/SweetNothings/Serialization/Json" - ) - + url = "https://api.lovelive.tools/api/SweetNothings/Serialization/Json" + try: - - log( - "正在获取每日寄语..." - ) - - response = self.session.get( - url, - timeout=INSPIRATION_TIMEOUT - ) - + response = requests.get(url, timeout=5) response.raise_for_status() - data = response.json() - - if ( - isinstance(data, dict) - and data.get("returnObj") - ): - - return_obj = data["returnObj"] - - if ( - isinstance(return_obj, list) - and len(return_obj) > 0 - ): - - note = str( - return_obj[0] - ).strip() - - if note: - - log( - f"每日寄语:{note}" - ) - - return note - - except requests.RequestException as e: - - log( - f"获取每日寄语网络失败:{e}" - ) - - except ValueError as e: - - log( - f"每日寄语 JSON 解析失败:{e}" - ) - + + if data and 'returnObj' in data and data['returnObj']: + return data['returnObj'][0] except Exception as e: - - log( - f"获取每日寄语失败:{e}" - ) - + print(f"获取每日寄语失败: {e}") + # 备用寄语 - log( - f"使用默认寄语:{DEFAULT_INSPIRATION}" - ) + return "愿你的一天充满阳光和微笑!" - return DEFAULT_INSPIRATION - - -# ============================================================ -# 天气报告主控制器 -# ============================================================ class WeatherReporter: """天气报告主控制器""" - - def __init__( - self, - session: requests.Session - ): - - self.weather_fetcher = ( - WeatherFetcher(session) - ) - - self.wechat_api = ( - WeChatAPI(session) - ) - - self.daily_inspiration = ( - DailyInspiration(session) - ) - - def report_weather( - self, - city: str - ) -> bool: + + def __init__(self): + self.weather_fetcher = WeatherFetcher() + self.wechat_api = WeChatAPI() + self.daily_inspiration = DailyInspiration() + + def report_weather(self, city: str) -> bool: """ 执行完整的天气报告流程 + + Args: + city: 城市名称 + + Returns: + 是否成功执行 """ - - log( - f"开始执行天气报告:{city}" - ) - - # ---------------------------------------------------- + print(f"开始获取 {city} 的天气信息...") + # 1. 获取天气数据 - # ---------------------------------------------------- - - weather_data = ( - self.weather_fetcher - .fetch_weather_data(city) - ) - + weather_data = self.weather_fetcher.fetch_weather_data(city) if not weather_data: - - log( - "天气数据获取失败" - ) - return False - - log( - f"天气信息获取成功:{weather_data}" - ) - - # ---------------------------------------------------- - # 2. 获取 access_token - # ---------------------------------------------------- - - access_token = ( - self.wechat_api - .get_access_token() - ) - + + print(f"天气信息获取成功: {weather_data}") + + # 2. 获取access_token + access_token = self.wechat_api.get_access_token() if not access_token: - - log( - "access_token 获取失败" - ) - return False - - # ---------------------------------------------------- + # 3. 获取每日寄语 - # ---------------------------------------------------- - - daily_note = ( - self.daily_inspiration - .get_daily_inspiration() - ) - - # ---------------------------------------------------- + daily_note = self.daily_inspiration.get_daily_inspiration() + print(f"每日寄语: {daily_note}") + # 4. 发送微信消息 - # ---------------------------------------------------- - - success = ( - self.wechat_api - .send_weather_message( - access_token, - weather_data, - daily_note - ) - ) - + success = self.wechat_api.send_weather_message(access_token, weather_data, daily_note) + return success -# ============================================================ -# 环境变量检查 -# ============================================================ - -def check_environment() -> bool: - """检查必要的环境变量""" - - required_env_vars = [ - "APP_ID", - "APP_SECRET", - "OPEN_ID", - "TEMPLATE_ID" - ] - - missing_vars = [ - var - for var in required_env_vars - if not os.environ.get( - var, - "" - ).strip() - ] - - if missing_vars: - - log( - "错误:缺少必要的环境变量:" - + ", ".join(missing_vars) - ) - - log( - "请在 GitHub Secrets 中配置:" - "APP_ID、APP_SECRET、OPEN_ID、TEMPLATE_ID" - ) - - return False - - return True - - -# ============================================================ -# 主函数 -# ============================================================ - -def main() -> int: +def main(): """主函数""" - - log( - "========================================" - ) - - log( - "微信天气推送服务启动" - ) - - log( - "========================================" - ) - - # -------------------------------------------------------- - # 检查必要配置 - # -------------------------------------------------------- - - if not check_environment(): - return 1 - - # -------------------------------------------------------- - # 获取城市 - # -------------------------------------------------------- - - city = CITY - - if not city: - - log( - "错误:CITY 不能为空" - ) - - return 1 - - log( - f"目标城市:{city}" - ) - - # -------------------------------------------------------- - # 获取北京时间 - # -------------------------------------------------------- - - now = datetime.datetime.now( - ZoneInfo(TIMEZONE) - ) - - log( - "北京时间:" - + now.strftime( - "%Y-%m-%d %H:%M:%S" - ) - ) - - # -------------------------------------------------------- - # 创建 Session - # -------------------------------------------------------- - - session = create_session() - - try: - - reporter = WeatherReporter( - session - ) - - success = ( - reporter.report_weather( - city - ) - ) - - if success: - - log( - "========================================" - ) - - log( - "天气报告发送完成!" - ) - - log( - "========================================" - ) - - return 0 - - log( - "天气报告发送失败!" - ) - - return 1 - - except KeyboardInterrupt: - - log( - "程序被手动中断" - ) - - return 130 - - except Exception as e: - - log( - f"程序发生未处理异常:{e}" - ) - - return 1 - - finally: - - session.close() - - -# ============================================================ -# 程序入口 -# ============================================================ - -if __name__ == "__main__": - raise SystemExit( - main() - ) -``` + # 可以改为从命令行参数或配置文件读取城市 + city = "吉安" + + # 检查必要的环境变量 + required_env_vars = ["APP_ID", "APP_SECRET", "OPEN_ID", "TEMPLATE_ID"] + missing_vars = [var for var in required_env_vars if not os.environ.get(var)] + + if missing_vars: + print(f"错误:缺少必要的环境变量: {', '.join(missing_vars)}") + print("请设置以下环境变量:") + for var in missing_vars: + print(f" export {var}='your_value'") + return + + # 创建并运行天气报告器 + reporter = WeatherReporter() + success = reporter.report_weather(city) + + if success: + print("天气报告发送完成!") + else: + print("天气报告发送失败!") + + +if __name__ == '__main__': + main()