#创作灵感#
灵感来源于服务器的不稳定,有一天打开网站时,发现网站停了,马上检查是什么原因,结果是服务器出问题了,服务器停了四个小时了,才知道,看到这种情况我是不能容忍的,于是就想如何才能在第一时间知道网站的状态,至少不至于是四个小时那么长,几分钟的时间还可以。
#程序优点#
- 放入计划任务中,每2分钟执行一次检测,如果连续三次网站打不开,就会发送邮件通知。这里的三次可以自行设置,你也可以设置一次打不开就发邮件。
- 如果发送了邮件后,程序会继续检测,网站还是打不开,不会再发送邮件,不会一直发送邮件,避免垃圾邮件。
- 如果网站得到了人工解决或是服务器由于当时的带宽限制或流量原因导致的暂时性打不开而自行恢复后,这时又会发邮件通知提示网站正常了(意思是你不用再处理了,服务器又正常了)
- 不知道我描述清楚了没有,总结一下,不用担心频繁的发送邮件。只有不正常和恢复后才会各发送一次邮件。
#开干#
把以下代码存于一个文件中,路径可以自己定,我的系统使用是 ubuntu 24 server , 我存放路径在 /www/server/checkwebsite.py
python
# -*- coding: utf-8 -*-
"""
本升级版程序的主要功能 :
网站可用性检查工具 可以放在计划任务里定时执行,支持多站点配置,每个站点独立参数配置,
每个站点独立计数、独立告警周期;连续3次失败告警,恢复发送恢复通知
"""
import time
import ssl
import socket
import logging
import json
import os
import warnings
# 屏蔽urllib3关于strict参数的FutureWarning,不影响其他告警
warnings.filterwarnings("ignore", category=FutureWarning, module="urllib3.*")
from urllib.parse import urlparse
from typing import Optional, Dict, Any
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from common.sendemail import EmailHelper
# ====================== 配置区,请修改这里 ======================
# 多站点配置,每个站点独立参数,下面是想要检测的网站
MONITOR_SITES = [
{
"url": "https://www.hao366.net/",
"timeout": 5,
"expected_status": 200,
"check_keyword": "网站建设", # 检测页面关键字包含此内容,这个参数可选,留空则不检测关键字
"verify_ssl": True,
},
# {
# "url": "https://www.hao366.net/", #这里改成另一个站点
# "timeout": 5,
# "expected_status": 200,
# "check_keyword": "",
# "verify_ssl": True,
# },
]
FAIL_THRESHOLD = 3 # 连续失败多少次触发告警(全局统一),目前设置为 3 次
STATE_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "monitor_state.json"
)
# ===============================================================
# 日志配置
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
def sendemail(subject: str, content: str):
"""邮件发送"""
emailcontent = f"【邮件待发送】标题:{subject} 内容:{content}"
logger.info(emailcontent)
#调用发送邮件的函数(后面单独的,因为这个是成熟的代码,使用QQ邮箱中继)
def load_state() -> Dict[str, Any]:
"""
多站点状态读取:
返回格式 { "https://www.hao366.net/": {"continuous_fail_count":0,"alert_sent":False}, ... }
"""
default_site_state = {"continuous_fail_count": 0, "alert_sent": False}
if not os.path.exists(STATE_FILE):
return {}
try:
with open(STATE_FILE, "r", encoding="utf-8") as f:
state = json.load(f)
# 重建多站点格式
if "continuous_fail_count" in state and not any(
k.startswith("http") for k in state.keys()
):
logger.warning("检测到旧版单站点状态文件,将重建多站点状态")
return {}
# 补全每个站点缺失key
for site_url in list(state.keys()):
for k in default_site_state:
if k not in state[site_url]:
state[site_url][k] = default_site_state[k]
return state
except Exception as e:
logger.error(f"读取状态文件异常,重置全部状态: {e}")
return {}
def save_state(state: Dict[str, Any]):
"""保存多站点状态字典"""
try:
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"写入状态文件失败: {e}")
def check_website(
url: str,
timeout: int = 10,
retries: int = 2,
expected_status: int = 200,
check_keyword: Optional[str] = None,
verify_ssl: bool = True,
user_agent: str = "Mozilla/5.0 (Site-Monitor/1.0)",
) -> Dict[str, Any]:
result: Dict[str, Any] = {
"ok": False,
"url": url,
"status_code": None,
"elapsed_ms": 0.0,
"dns_ok": False,
"ssl_ok": None,
"ssl_expire_days": None,
"error": None,
"checked_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
parsed = urlparse(url)
host = parsed.hostname
port = parsed.port or (443 if parsed.scheme == "https" else 80)
# ---------- 修复:判断host为None的情况,消除类型警告 ----------
if host is None:
result["error"] = f"URL解析失败,无法获取host域名: {url}"
logger.error(result["error"])
return result
# ---------- 1. DNS 解析检查 ----------
try:
ip = socket.gethostbyname(host)
result["dns_ok"] = True
logger.info(f"[DNS] {host} -> {ip}")
except socket.gaierror as e:
result["error"] = f"DNS 解析失败: {e}"
logger.error(result["error"])
return result
# ---------- 2. SSL 证书检查(仅 HTTPS) ----------
if parsed.scheme == "https":
try:
ctx = ssl.create_default_context()
with socket.create_connection((host, port), timeout=timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
cert = ssock.getpeercert()
if cert is None:
result["ssl_ok"] = False
logger.warning("[SSL] 获取对端证书返回None")
else:
expire_str = cert.get("notAfter")
if isinstance(expire_str, str):
expire_ts = ssl.cert_time_to_seconds(expire_str)
days = int((expire_ts - time.time()) / 86400)
result["ssl_expire_days"] = days
# 剩余小于 15 天告警
if days < 15:
logger.warning(f"[SSL] 证书即将过期,剩余 {days} 天")
result["ssl_ok"] = True
expire_show = (
f"{result['ssl_expire_days']} 天"
if result["ssl_expire_days"] is not None
else "未知"
)
logger.info(f"[SSL] 证书有效,剩余 {expire_show}")
except ssl.SSLCertVerificationError as e:
result["ssl_ok"] = False
logger.warning(f"[SSL] 证书验证失败: {e}")
except Exception as e:
result["ssl_ok"] = False
logger.warning(f"[SSL] 检查异常: {e}")
# ---------- 3. HTTP 请求检查(带重试) ----------
session = requests.Session()
retry_strategy = Retry(
total=retries,
backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "HEAD"],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("http://", adapter)
session.mount("https://", adapter)
headers = {"User-Agent": user_agent}
try:
start = time.perf_counter()
resp = session.get(
url,
headers=headers,
timeout=timeout,
verify=verify_ssl,
allow_redirects=True,
)
elapsed_ms = (time.perf_counter() - start) * 1000
resp.encoding = resp.apparent_encoding
result["status_code"] = resp.status_code
result["elapsed_ms"] = round(elapsed_ms, 2)
logger.info(f"[HTTP] {url} -> {resp.status_code} in {result['elapsed_ms']}ms")
# 状态码校验
if resp.status_code != expected_status:
result["error"] = (
f"状态码异常: 期望 {expected_status}, 实际 {resp.status_code}"
)
logger.error(result["error"])
return result
# 关键字校验
if check_keyword and check_keyword not in resp.text:
result["error"] = f"页面未包含关键字: '{check_keyword}'"
logger.error(result["error"])
return result
result["ok"] = True
logger.info(f"[OK] {url} 检查通过")
return result
except requests.exceptions.SSLError as e:
result["error"] = f"SSL 错误: {e}"
except requests.exceptions.ConnectTimeout:
result["error"] = f"连接超时 (>{timeout}s)"
except requests.exceptions.ReadTimeout:
result["error"] = f"读取超时 (>{timeout}s)"
except requests.exceptions.ConnectionError as e:
result["error"] = f"连接失败: {e}"
except requests.exceptions.RequestException as e:
result["error"] = f"请求异常: {e}"
finally:
session.close()
logger.error(result["error"])
return result
if __name__ == "__main__":
all_state = load_state()
logger.info(f"读取多站点状态,共 {len(all_state)} 个站点历史状态")
for site_conf in MONITOR_SITES:
site_url = site_conf["url"]
logger.info(f"====== 开始检测站点: {site_url} ======")
try:
# 当前站点不存在状态则初始化
if site_url not in all_state:
all_state[site_url] = {"continuous_fail_count": 0, "alert_sent": False}
site_state = all_state[site_url]
# 执行检测
r = check_website(
url=site_conf["url"],
timeout=site_conf["timeout"],
expected_status=site_conf["expected_status"],
check_keyword=site_conf["check_keyword"],
verify_ssl=site_conf["verify_ssl"],
)
if r["ok"]:
# 站点正常
logger.info(f"{site_url} 网站访问正常")
if site_state["alert_sent"] is True:
subject = f"【网站恢复通知】{site_url} 网站已经恢复访问"
ssl_day_text = (
f"{r['ssl_expire_days']} 天"
if r["ssl_expire_days"] is not None
else "未知"
)
content = f"""监控时间:{r['checked_at']}
监控地址:{site_url}
状态:网站已恢复正常访问
请求耗时:{r['elapsed_ms']} ms
SSL证书剩余天数:{ssl_day_text}
"""
sendemail(subject, content)
logger.info(f"{site_url} 已发送网站恢复通知邮件")
# 重置本站点状态
site_state["continuous_fail_count"] = 0
site_state["alert_sent"] = False
else:
# 站点异常,本站点独立计数
site_state["continuous_fail_count"] += 1
fail_cnt = site_state["continuous_fail_count"]
logger.warning(
f"{site_url} 网站异常!当前连续失败次数:{fail_cnt},错误信息:{r['error']}"
)
if fail_cnt >= FAIL_THRESHOLD and site_state["alert_sent"] is False:
subject = f"【网站故障告警】{site_url} 网站访问异常!"
dns_text = "成功" if r["dns_ok"] else "失败"
if r["ssl_ok"] is True:
ssl_text = "正常"
elif r["ssl_ok"] is False:
ssl_text = "异常"
else:
ssl_text = "未检测"
content = f"""监控时间:{r['checked_at']}
监控地址:{site_url}
连续检测失败次数:{fail_cnt}次
错误详情:{r['error']}
状态码:{r['status_code']}
DNS解析:{dns_text}
SSL状态:{ssl_text}
每隔2分钟检测一次,网站恢复后将发送恢复通知。
"""
sendemail(subject, content)
site_state["alert_sent"] = True
logger.info(
f"{site_url} 达到连续{FAIL_THRESHOLD}次失败,发送故障告警邮件完成"
)
# 回写状态对象
all_state[site_url] = site_state
except Exception as loop_err:
# 单个站点发生未捕获异常,不中断其他站点
logger.error(f"站点 {site_url} 检测过程发生异常,跳过该站点: {loop_err}")
# 全部站点跑完后统一写文件(减少IO次数)
save_state(all_state)
logger.info("===== 本轮全部站点检测完成 =====")
我使用的是宝塔服务器管理软件, 在计划任务中直接加入一条任务,
bash
python3 /www/server/checkwebsite.py
每两分钟执行一次,当然时间可以自己定,服务器抗造,可以30秒一次,注意一点,如果站点较多,最好轮循的时间长一点,不然一次任务没有执行完第二次就来了,当然你可以锁定,不过太频繁了。站点少的话,一般都是秒级执行完,所以按2分钟的话, 我里面设置的是3次网站状态不正常,就发送邮件。
最后运行的结果 :

#附:发送邮件的代码#
以下是可以上传附件的邮件发送代码
python
#!/usr/bin/env python3
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
import os
class EmailHelper(object):
@staticmethod
def send_direct(
smtp_server, port, sender, auth_code, to_addr, subject, body, attach_files=None
):
"""
:param smtp_server: SMTP服务器
:param port: 端口
:param sender: 发件人邮箱
:param auth_code: 授权码
:param to_addr: 收件邮箱
:param subject: 邮件标题
:param body: 邮件正文文本
:param attach_files: list,附件文件绝对路径列表,不传则无附件
:return: True成功 / False失败
"""
if attach_files is None:
attach_files = []
msg = MIMEMultipart()
msg["Subject"] = subject
msg["From"] = sender
msg["To"] = to_addr
# 添加正文
msg.attach(MIMEText(body, "plain", "utf-8"))
# 追加附件
for file_path in attach_files:
if not os.path.isfile(file_path):
continue
filename = os.path.basename(file_path)
part = MIMEBase("application", "octet‑stream")
with open(file_path, "rb") as fp:
part.set_payload(fp.read())
encoders.encode_base64(part)
part.add_header("Content‑Disposition", f'attachment; filename="{filename}"')
msg.attach(part)
try:
server = smtplib.SMTP(smtp_server, port, timeout=10)
server.starttls()
server.login(sender, auth_code)
server.send_message(msg)
server.quit()
print("✅ 发送成功")
return True
except Exception as e:
print(f"❌ 发送失败: {e}")
return False
if __name__ == "__main__":
# 测试示例
EmailHelper.send_direct(
"smtp.qq.com",
587,
"00000000@qq.com",
"你的QQ授权码",
"11111111@qq.com",
"Python直连SMTP测试",
"绕过Postfix,直接和QQ SMTP通信",
attach_files=[],
)
写到这里就没了,有什么问题可以回复我。