0.0.1
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
__pycache__
|
||||||
|
.env
|
||||||
|
.trae
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.baiduyun.*
|
||||||
|
.vscode
|
||||||
|
对比
|
||||||
|
logs/sessions.json
|
||||||
|
logs/sessions.log
|
||||||
|
222.py
|
||||||
|
333.py
|
||||||
|
444.py
|
||||||
|
chain
|
||||||
30
back/Dockerfile
Executable file
30
back/Dockerfile
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
# 运行环境
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
|
# 设置工作目录和Python环境变量
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.aliyun.com/debian|g' /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& sed -i 's|http://security.debian.org/debian-security|http://mirrors.aliyun.com/debian-security|g' /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
python3-dev \
|
||||||
|
tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 优化:先复制依赖文件,避免每次代码变更都重新安装依赖
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.cloud.tencent.com/pypi/simple
|
||||||
|
# 复制项目文件
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
# 设置启动命令
|
||||||
|
CMD ["python", "main.py"]
|
||||||
5
back/apis/__init__.py
Normal file
5
back/apis/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from .country import app as country_app
|
||||||
|
|
||||||
|
app = APIRouter()
|
||||||
|
app.include_router(country_app, prefix='/country')
|
||||||
9
back/apis/country/__init__.py
Normal file
9
back/apis/country/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from .info.view import app as info_app
|
||||||
|
from .food.view import app as food_app
|
||||||
|
from .shop.view import app as shop_app
|
||||||
|
|
||||||
|
app = APIRouter()
|
||||||
|
app.include_router(info_app, prefix='/info', tags=['信息'])
|
||||||
|
app.include_router(food_app, prefix='/food', tags=['食物'])
|
||||||
|
app.include_router(shop_app, prefix='/shop', tags=['商店'])
|
||||||
66
back/apis/country/food/schema.py
Normal file
66
back/apis/country/food/schema.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pydantic import BaseModel, Field, computed_field
|
||||||
|
from typing import List
|
||||||
|
from uuid import UUID
|
||||||
|
from utils.time_tool import TimestampModel
|
||||||
|
|
||||||
|
CHINA_TZ = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
class Base(BaseModel):
|
||||||
|
"""
|
||||||
|
基础食物信息模型
|
||||||
|
|
||||||
|
仅包含食物名称
|
||||||
|
"""
|
||||||
|
name: str = Field(..., description='食物名称')
|
||||||
|
|
||||||
|
|
||||||
|
class Create(Base):
|
||||||
|
"""
|
||||||
|
创建请求模型
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Update(BaseModel):
|
||||||
|
"""
|
||||||
|
更新请求模型,支持部分更新
|
||||||
|
"""
|
||||||
|
name: str | None = Field(None, description='食物名称')
|
||||||
|
|
||||||
|
|
||||||
|
class Out(TimestampModel, Base):
|
||||||
|
"""
|
||||||
|
输出模型
|
||||||
|
"""
|
||||||
|
code: int = Field(200, description='状态码')
|
||||||
|
message: str = Field('成功', description='提示信息')
|
||||||
|
id: UUID = Field(..., description='ID')
|
||||||
|
|
||||||
|
create_time: datetime = Field(..., description='创建时间')
|
||||||
|
update_time: datetime = Field(..., description='更新时间')
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def create_time_cn(self) -> str:
|
||||||
|
return self.create_time.astimezone(CHINA_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def update_time_cn(self) -> str:
|
||||||
|
return self.update_time.astimezone(CHINA_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class OutList(BaseModel):
|
||||||
|
"""
|
||||||
|
列表输出模型
|
||||||
|
"""
|
||||||
|
code: int = Field(200, description='状态码')
|
||||||
|
message: str = Field('成功', description='提示信息')
|
||||||
|
count: int = Field(0, description='总数')
|
||||||
|
num: int = Field(0, description='当前数量')
|
||||||
|
items: List[Out] = Field([], description='列表数据')
|
||||||
122
back/apis/country/food/view.py
Normal file
122
back/apis/country/food/view.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
|
||||||
|
from fastapi import APIRouter, Query, Body, HTTPException
|
||||||
|
from uuid import UUID
|
||||||
|
from .schema import Create, Update, Out, OutList
|
||||||
|
from ..models import Food
|
||||||
|
from utils.decorators import handle_exceptions_unified
|
||||||
|
from utils.time_tool import parse_time
|
||||||
|
from utils.out_base import CommonOut
|
||||||
|
|
||||||
|
app = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# 创建食物
|
||||||
|
@app.post("", response_model=Out, description='创建食物', summary='创建食物')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def post(item: Create = Body(..., description='创建数据')):
|
||||||
|
"""
|
||||||
|
创建食物记录
|
||||||
|
"""
|
||||||
|
res = await Food.create(**item.model_dump())
|
||||||
|
if not res:
|
||||||
|
raise HTTPException(status_code=400, detail='创建失败')
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# 查询食物
|
||||||
|
@app.get("", response_model=OutList, description='获取食物', summary='获取食物')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def gets(
|
||||||
|
id: UUID | None = Query(None, description='主键ID'),
|
||||||
|
name: str | None = Query(None, description='食物名称'),
|
||||||
|
order_by: str | None = Query('create_time', description='排序字段',
|
||||||
|
regex='^(-)?(id|name|create_time|update_time)$'),
|
||||||
|
res_count: bool = Query(False, description='是否返回总数'),
|
||||||
|
create_time_start: str | int | None = Query(
|
||||||
|
None, description='创建时间开始 (支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 13位时间戳)'),
|
||||||
|
create_time_end: str | int | None = Query(
|
||||||
|
None, description='创建时间结束 (支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 13位时间戳)'),
|
||||||
|
update_time_start: str | int | None = Query(
|
||||||
|
None, description='更新时间开始 (支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 13位时间戳)'),
|
||||||
|
update_time_end: str | int | None = Query(
|
||||||
|
None, description='更新时间结束 (支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 13位时间戳)'),
|
||||||
|
page: int = Query(1, ge=1, description='页码'),
|
||||||
|
limit: int = Query(10, ge=1, le=1000, description='每页数量'),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取食物列表
|
||||||
|
"""
|
||||||
|
query = Food.all()
|
||||||
|
if id:
|
||||||
|
query = query.filter(id=id)
|
||||||
|
if name:
|
||||||
|
query = query.filter(name=name)
|
||||||
|
if create_time_start:
|
||||||
|
query = query.filter(create_time__gte=parse_time(create_time_start))
|
||||||
|
if create_time_end:
|
||||||
|
query = query.filter(create_time__lte=parse_time(
|
||||||
|
create_time_end, is_end=True))
|
||||||
|
if update_time_start:
|
||||||
|
query = query.filter(update_time__gte=parse_time(update_time_start))
|
||||||
|
if update_time_end:
|
||||||
|
query = query.filter(update_time__lte=parse_time(
|
||||||
|
update_time_end, is_end=True))
|
||||||
|
|
||||||
|
if order_by:
|
||||||
|
query = query.order_by(order_by)
|
||||||
|
|
||||||
|
if res_count:
|
||||||
|
count = await query.count()
|
||||||
|
else:
|
||||||
|
count = -1
|
||||||
|
offset = (page - 1) * limit # 计算偏移量
|
||||||
|
query = query.limit(limit).offset(offset) # 应用分页
|
||||||
|
|
||||||
|
res = await query
|
||||||
|
if not res:
|
||||||
|
raise HTTPException(status_code=404, detail='食物不存在')
|
||||||
|
num = len(res)
|
||||||
|
return OutList(count=count, num=num, items=res)
|
||||||
|
|
||||||
|
|
||||||
|
# 更新食物
|
||||||
|
@app.put("", response_model=Out, description='更新食物', summary='更新食物')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def put(id: UUID = Query(..., description='主键ID'),
|
||||||
|
item: Update = Body(..., description='更新数据'),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
部分更新食物,只更新传入的非空字段
|
||||||
|
"""
|
||||||
|
# 检查食物是否存在
|
||||||
|
secret = await Food.get_or_none(id=id)
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(status_code=404, detail='食物不存在')
|
||||||
|
|
||||||
|
# 获取要更新的字段(排除None值的字段)
|
||||||
|
update_data = item.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# 如果没有要更新的字段
|
||||||
|
if not update_data:
|
||||||
|
raise HTTPException(status_code=400, detail='没有要更新的字段')
|
||||||
|
|
||||||
|
# 更新食物字段
|
||||||
|
await secret.update_from_dict(update_data)
|
||||||
|
await secret.save()
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
# 删除食物
|
||||||
|
|
||||||
|
@app.delete("", response_model=CommonOut, description='删除食物', summary='删除食物')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def delete(id: UUID = Query(..., description='主键ID'),
|
||||||
|
):
|
||||||
|
"""删除食物"""
|
||||||
|
secret = await Food.get_or_none(id=id)
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(status_code=404, detail='食物不存在')
|
||||||
|
await secret.delete()
|
||||||
|
# Tortoise ORM 单个实例的 delete() 方法返回 None,而不是删除的记录数
|
||||||
|
# 删除成功时手动返回 1,如果有异常会被装饰器捕获
|
||||||
|
return CommonOut(count=1)
|
||||||
88
back/apis/country/info/schema.py
Normal file
88
back/apis/country/info/schema.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pydantic import BaseModel, Field, computed_field
|
||||||
|
from typing import List
|
||||||
|
from uuid import UUID
|
||||||
|
from utils.time_tool import TimestampModel
|
||||||
|
|
||||||
|
CHINA_TZ = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
class Base(BaseModel):
|
||||||
|
"""
|
||||||
|
基础信息模型
|
||||||
|
|
||||||
|
字段与数据库模型 Info 保持一致(孩子与家长字段)
|
||||||
|
"""
|
||||||
|
child_full_name: str = Field(..., description='孩子全名')
|
||||||
|
parent_full_name: str = Field(..., description='家长全名')
|
||||||
|
child_birthday: str = Field(..., description='孩子生日')
|
||||||
|
address_str: str = Field(..., description='街道地址')
|
||||||
|
city_name: str = Field(..., description='城市')
|
||||||
|
parent_phone: str = Field(..., description='家长电话')
|
||||||
|
postcode: str = Field(..., description='邮编')
|
||||||
|
province: str = Field(..., description='省/州全称')
|
||||||
|
status: bool = Field(False, description='状态')
|
||||||
|
email: str | None = Field(None, description='邮箱')
|
||||||
|
email_content: str | None = Field(None, description='邮件内容')
|
||||||
|
text: str | None = Field(None, description='文本内容')
|
||||||
|
|
||||||
|
|
||||||
|
class Create(Base):
|
||||||
|
"""
|
||||||
|
创建请求模型
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Update(BaseModel):
|
||||||
|
"""
|
||||||
|
更新请求模型,支持部分更新
|
||||||
|
"""
|
||||||
|
child_full_name: str | None = Field(None, description='孩子全名')
|
||||||
|
parent_full_name: str | None = Field(None, description='家长全名')
|
||||||
|
child_birthday: str | None = Field(None, description='孩子生日')
|
||||||
|
address_str: str | None = Field(None, description='街道地址')
|
||||||
|
city_name: str | None = Field(None, description='城市')
|
||||||
|
parent_phone: str | None = Field(None, description='家长电话')
|
||||||
|
postcode: str | None = Field(None, description='邮编')
|
||||||
|
province: str | None = Field(None, description='省/州全称')
|
||||||
|
status: bool | None = Field(None, description='状态')
|
||||||
|
email: str | None = Field(None, description='邮箱')
|
||||||
|
email_content: str | None = Field(None, description='邮件内容')
|
||||||
|
text: str | None = Field(None, description='文本内容')
|
||||||
|
|
||||||
|
|
||||||
|
class Out(TimestampModel, Base):
|
||||||
|
"""
|
||||||
|
输出模型
|
||||||
|
"""
|
||||||
|
code: int = Field(200, description='状态码')
|
||||||
|
message: str = Field('成功', description='提示信息')
|
||||||
|
id: UUID = Field(..., description='ID')
|
||||||
|
|
||||||
|
create_time: datetime = Field(..., description='创建时间')
|
||||||
|
update_time: datetime = Field(..., description='更新时间')
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def create_time_cn(self) -> str:
|
||||||
|
return self.create_time.astimezone(CHINA_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def update_time_cn(self) -> str:
|
||||||
|
return self.update_time.astimezone(CHINA_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class OutList(BaseModel):
|
||||||
|
"""
|
||||||
|
列表输出模型
|
||||||
|
"""
|
||||||
|
code: int = Field(200, description='状态码')
|
||||||
|
message: str = Field('成功', description='提示信息')
|
||||||
|
count: int = Field(0, description='总数')
|
||||||
|
num: int = Field(0, description='当前数量')
|
||||||
|
items: List[Out] = Field([], description='列表数据')
|
||||||
171
back/apis/country/info/view.py
Normal file
171
back/apis/country/info/view.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
|
||||||
|
from fastapi import APIRouter, Query, Body, HTTPException
|
||||||
|
import random
|
||||||
|
from uuid import UUID
|
||||||
|
from .schema import Create, Update, Out, OutList
|
||||||
|
from ..models import Info
|
||||||
|
from utils.decorators import handle_exceptions_unified
|
||||||
|
from utils.time_tool import parse_time
|
||||||
|
from utils.out_base import CommonOut
|
||||||
|
from tortoise.transactions import in_transaction
|
||||||
|
|
||||||
|
app = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# 创建信息
|
||||||
|
@app.post("", response_model=Out, description='创建信息', summary='创建信息')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def post(item: Create = Body(..., description='创建数据')):
|
||||||
|
"""
|
||||||
|
创建信息记录
|
||||||
|
"""
|
||||||
|
res = await Info.create(**item.model_dump())
|
||||||
|
if not res:
|
||||||
|
raise HTTPException(status_code=400, detail='创建失败')
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# 查询信息
|
||||||
|
@app.get("", response_model=OutList, description='获取信息', summary='获取信息')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def gets(
|
||||||
|
id: UUID | None = Query(None, description='主键ID'),
|
||||||
|
child_full_name: str | None = Query(None, description='孩子全名'),
|
||||||
|
parent_full_name: str | None = Query(None, description='家长全名'),
|
||||||
|
child_birthday: str | None = Query(None, description='孩子生日'),
|
||||||
|
address_str: str | None = Query(None, description='街道地址'),
|
||||||
|
city_name: str | None = Query(None, description='城市'),
|
||||||
|
parent_phone: str | None = Query(None, description='家长电话'),
|
||||||
|
postcode: str | None = Query(None, description='邮编'),
|
||||||
|
province: str | None = Query(None, description='州全称'),
|
||||||
|
status: bool | None = Query(None, description='状态'),
|
||||||
|
email: str | None = Query(None, description='邮箱'),
|
||||||
|
order_by: str | None = Query('create_time', description='排序字段',
|
||||||
|
regex='^(-)?(id|child_full_name|parent_full_name|city_name|postcode|province|create_time|update_time)$'),
|
||||||
|
res_count: bool = Query(False, description='是否返回总数'),
|
||||||
|
create_time_start: str | int | None = Query(
|
||||||
|
None, description='创建时间开始 (支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 13位时间戳)'),
|
||||||
|
create_time_end: str | int | None = Query(
|
||||||
|
None, description='创建时间结束 (支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 13位时间戳)'),
|
||||||
|
update_time_start: str | int | None = Query(
|
||||||
|
None, description='更新时间开始 (支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 13位时间戳)'),
|
||||||
|
update_time_end: str | int | None = Query(
|
||||||
|
None, description='更新时间结束 (支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 13位时间戳)'),
|
||||||
|
page: int = Query(1, ge=1, description='页码'),
|
||||||
|
limit: int = Query(10, ge=1, le=1000, description='每页数量'),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取信息列表
|
||||||
|
"""
|
||||||
|
query = Info.all()
|
||||||
|
if id:
|
||||||
|
query = query.filter(id=id)
|
||||||
|
if child_full_name:
|
||||||
|
query = query.filter(child_full_name=child_full_name)
|
||||||
|
if parent_full_name:
|
||||||
|
query = query.filter(parent_full_name=parent_full_name)
|
||||||
|
if child_birthday:
|
||||||
|
query = query.filter(child_birthday=child_birthday)
|
||||||
|
if address_str:
|
||||||
|
query = query.filter(address_str=address_str)
|
||||||
|
if city_name:
|
||||||
|
query = query.filter(city_name=city_name)
|
||||||
|
if parent_phone:
|
||||||
|
query = query.filter(parent_phone=parent_phone)
|
||||||
|
if postcode:
|
||||||
|
query = query.filter(postcode=postcode)
|
||||||
|
if province:
|
||||||
|
query = query.filter(province=province)
|
||||||
|
if email:
|
||||||
|
query = query.filter(email=email)
|
||||||
|
if status is not None:
|
||||||
|
query = query.filter(status=status)
|
||||||
|
if create_time_start:
|
||||||
|
query = query.filter(create_time__gte=parse_time(create_time_start))
|
||||||
|
if create_time_end:
|
||||||
|
query = query.filter(create_time__lte=parse_time(
|
||||||
|
create_time_end, is_end=True))
|
||||||
|
if update_time_start:
|
||||||
|
query = query.filter(update_time__gte=parse_time(update_time_start))
|
||||||
|
if update_time_end:
|
||||||
|
query = query.filter(update_time__lte=parse_time(
|
||||||
|
update_time_end, is_end=True))
|
||||||
|
|
||||||
|
if order_by:
|
||||||
|
query = query.order_by(order_by)
|
||||||
|
|
||||||
|
if res_count:
|
||||||
|
count = await query.count()
|
||||||
|
else:
|
||||||
|
count = -1
|
||||||
|
offset = (page - 1) * limit # 计算偏移量
|
||||||
|
query = query.limit(limit).offset(offset) # 应用分页
|
||||||
|
|
||||||
|
res = await query
|
||||||
|
if not res:
|
||||||
|
raise HTTPException(status_code=404, detail='信息不存在')
|
||||||
|
num = len(res)
|
||||||
|
return OutList(count=count, num=num, items=res)
|
||||||
|
|
||||||
|
|
||||||
|
# 更新信息
|
||||||
|
@app.put("", response_model=Out, description='更新信息', summary='更新信息')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def put(id: UUID = Query(..., description='主键ID'),
|
||||||
|
item: Update = Body(..., description='更新数据'),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
部分更新信息,只更新传入的非空字段
|
||||||
|
"""
|
||||||
|
# 检查信息是否存在
|
||||||
|
secret = await Info.get_or_none(id=id)
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(status_code=404, detail='信息不存在')
|
||||||
|
|
||||||
|
# 获取要更新的字段(排除None值的字段)
|
||||||
|
update_data = item.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# 如果没有要更新的字段
|
||||||
|
if not update_data:
|
||||||
|
raise HTTPException(status_code=400, detail='没有要更新的字段')
|
||||||
|
|
||||||
|
# 更新信息字段
|
||||||
|
await secret.update_from_dict(update_data)
|
||||||
|
await secret.save()
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
# 删除信息
|
||||||
|
|
||||||
|
@app.delete("", response_model=CommonOut, description='删除信息', summary='删除信息')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def delete(id: UUID = Query(..., description='主键ID'),
|
||||||
|
):
|
||||||
|
"""删除信息"""
|
||||||
|
secret = await Info.get_or_none(id=id)
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(status_code=404, detail='信息不存在')
|
||||||
|
await secret.delete()
|
||||||
|
# Tortoise ORM 单个实例的 delete() 方法返回 None,而不是删除的记录数
|
||||||
|
# 删除成功时手动返回 1,如果有异常会被装饰器捕获
|
||||||
|
return CommonOut(count=1)
|
||||||
|
|
||||||
|
|
||||||
|
# 随机获取一条状态修改为True的记录
|
||||||
|
@app.get("/one", response_model=Out, description='随机获取一条状态修改为True的记录', summary='随机获取一条状态修改为True的记录')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def random_update_status():
|
||||||
|
"""
|
||||||
|
随机获取一条状态为 False 的记录并在事务中更新为 True
|
||||||
|
"""
|
||||||
|
async with in_transaction() as conn:
|
||||||
|
q = Info.filter(status=False).using_db(conn)
|
||||||
|
current_running_count = await q.count()
|
||||||
|
if current_running_count == 0:
|
||||||
|
raise HTTPException(status_code=404, detail='没有状态为False的记录')
|
||||||
|
pick_index = random.choice(range(current_running_count))
|
||||||
|
item = await q.order_by('create_time').offset(pick_index).first()
|
||||||
|
updated = await Info.filter(id=item.id, status=False).using_db(conn).update(status=True)
|
||||||
|
if updated == 0:
|
||||||
|
raise HTTPException(status_code=400, detail='并发冲突,未更新')
|
||||||
|
return item
|
||||||
116
back/apis/country/models.py
Normal file
116
back/apis/country/models.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import uuid
|
||||||
|
from tortoise import fields
|
||||||
|
from tortoise.models import Model
|
||||||
|
|
||||||
|
|
||||||
|
class Shop(Model):
|
||||||
|
"""
|
||||||
|
店铺模型
|
||||||
|
|
||||||
|
字段:
|
||||||
|
id (UUIDField): 主键,默认使用 UUID 生成
|
||||||
|
province (CharField): 省份,最大长度 255
|
||||||
|
city (CharField): 城市,最大长度 255
|
||||||
|
street (CharField): 街道,最大长度 255
|
||||||
|
shop_name (CharField): 店铺名称,最大长度 255
|
||||||
|
shop_number (CharField): 店铺号码,最大长度 255, nullable 为 True
|
||||||
|
"""
|
||||||
|
id = fields.UUIDField(pk=True, default=uuid.uuid4, description="ID")
|
||||||
|
province = fields.CharField(max_length=255, null=True, index=True, description="省份")
|
||||||
|
city = fields.CharField(max_length=255, index=True, description="城市")
|
||||||
|
street = fields.CharField(max_length=255, index=True, description="街道")
|
||||||
|
shop_name = fields.CharField(max_length=255, index=True, description="店铺名称")
|
||||||
|
shop_number = fields.CharField(max_length=255, null=True, description="店铺号码")
|
||||||
|
create_time = fields.DatetimeField(auto_now_add=True, index=True, description='创建时间')
|
||||||
|
update_time = fields.DatetimeField(auto_now=True, description='更新时间')
|
||||||
|
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "shop"
|
||||||
|
table_description = "店铺表"
|
||||||
|
ordering = ["create_time"]
|
||||||
|
indexes = [
|
||||||
|
("province", "city", "street"),
|
||||||
|
]
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Shop(id={self.id}, province={self.province}, city={self.city}, street={self.street}, shop_name={self.shop_name})>"
|
||||||
|
|
||||||
|
__str__ = __repr__
|
||||||
|
|
||||||
|
class Food(Model):
|
||||||
|
"""
|
||||||
|
食物模型
|
||||||
|
|
||||||
|
字段:
|
||||||
|
id (UUIDField): 主键,默认使用 UUID 生成
|
||||||
|
name (CharField): 食物名称,最大长度 255
|
||||||
|
"""
|
||||||
|
id = fields.UUIDField(pk=True, default=uuid.uuid4, description="ID")
|
||||||
|
name = fields.CharField(max_length=255, index=True, description="食物名称")
|
||||||
|
create_time = fields.DatetimeField(auto_now_add=True, index=True, description='创建时间')
|
||||||
|
update_time = fields.DatetimeField(auto_now=True, description='更新时间')
|
||||||
|
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "food"
|
||||||
|
table_description = "食物表"
|
||||||
|
ordering = ["create_time"]
|
||||||
|
indexes = [
|
||||||
|
("name",),
|
||||||
|
]
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Food(id={self.id}, name={self.name})>"
|
||||||
|
|
||||||
|
__str__ = __repr__
|
||||||
|
|
||||||
|
|
||||||
|
class Info(Model):
|
||||||
|
"""
|
||||||
|
信息模型(孩子与家长字段)
|
||||||
|
|
||||||
|
字段:
|
||||||
|
id (UUIDField): 主键,默认使用 UUID 生成
|
||||||
|
child_full_name (CharField): 孩子全名,最大长度 255
|
||||||
|
parent_full_name (CharField): 家长全名,最大长度 255
|
||||||
|
child_birthday (CharField): 孩子生日(原始字符串),最大长度 32
|
||||||
|
address_str (CharField): 街道地址,最大长度 255
|
||||||
|
city_name (CharField): 城市,最大长度 255
|
||||||
|
parent_phone (CharField): 家长电话,最大长度 64
|
||||||
|
postcode (CharField): 邮编,最大长度 20
|
||||||
|
province (CharField): 省/州全称,最大长度 255
|
||||||
|
status (BooleanField): 状态,默认值 False
|
||||||
|
email (CharField): 邮箱,最大长度 255, nullable 为 True
|
||||||
|
text (TextField): 文本内容, nullable 为 True
|
||||||
|
|
||||||
|
"""
|
||||||
|
id = fields.UUIDField(pk=True, default=uuid.uuid4, description="ID")
|
||||||
|
child_full_name = fields.CharField(max_length=255, index=True, description="孩子全名")
|
||||||
|
parent_full_name = fields.CharField(max_length=255, index=True, description="家长全名")
|
||||||
|
child_birthday = fields.CharField(max_length=32, description="孩子生日")
|
||||||
|
address_str = fields.CharField(max_length=255, index=True, description="街道地址")
|
||||||
|
city_name = fields.CharField(max_length=255, index=True, description="城市")
|
||||||
|
parent_phone = fields.CharField(max_length=64, description="家长电话")
|
||||||
|
postcode = fields.CharField(max_length=20, index=True, description="邮编")
|
||||||
|
province = fields.CharField(max_length=255, index=True, description="省/州全称")
|
||||||
|
status = fields.BooleanField(default=False, description="状态")
|
||||||
|
# 邮件内容
|
||||||
|
email = fields.CharField(max_length=255, unique=True, index=True, description="邮箱")
|
||||||
|
email_content = fields.TextField(null=True, description="邮件内容")
|
||||||
|
text = fields.TextField(null=True, description="文本内容")
|
||||||
|
create_time = fields.DatetimeField(auto_now_add=True, index=True, description='创建时间')
|
||||||
|
update_time = fields.DatetimeField(auto_now=True, description='更新时间')
|
||||||
|
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "info"
|
||||||
|
table_description = "信息表"
|
||||||
|
ordering = ["create_time"]
|
||||||
|
indexes = [
|
||||||
|
("city_name", "postcode", "province"),
|
||||||
|
("child_full_name", "parent_full_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Info(id={self.id}, child_full_name={self.child_full_name}, parent_full_name={self.parent_full_name}, child_birthday={self.child_birthday}, address_str={self.address_str}, city_name={self.city_name}, parent_phone={self.parent_phone}, postcode={self.postcode}, province={self.province})>"
|
||||||
|
|
||||||
|
__str__ = __repr__
|
||||||
74
back/apis/country/shop/schema.py
Normal file
74
back/apis/country/shop/schema.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pydantic import BaseModel, Field, computed_field
|
||||||
|
from typing import List
|
||||||
|
from uuid import UUID
|
||||||
|
from utils.time_tool import TimestampModel
|
||||||
|
|
||||||
|
CHINA_TZ = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
class Base(BaseModel):
|
||||||
|
"""
|
||||||
|
基础店铺信息模型
|
||||||
|
|
||||||
|
包含店铺相关的通用字段,供创建与输出模型复用
|
||||||
|
"""
|
||||||
|
province: str | None = Field(None, description='省份')
|
||||||
|
city: str = Field(..., description='城市')
|
||||||
|
street: str = Field(..., description='街道')
|
||||||
|
shop_name: str = Field(..., description='店铺名称')
|
||||||
|
shop_number: str | None = Field(None, description='店铺号码')
|
||||||
|
|
||||||
|
|
||||||
|
class Create(Base):
|
||||||
|
"""
|
||||||
|
创建请求模型
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Update(BaseModel):
|
||||||
|
"""
|
||||||
|
更新请求模型,支持部分更新
|
||||||
|
"""
|
||||||
|
province: str | None = Field(None, description='省份')
|
||||||
|
city: str | None = Field(None, description='城市')
|
||||||
|
street: str | None = Field(None, description='街道')
|
||||||
|
shop_name: str | None = Field(None, description='店铺名称')
|
||||||
|
shop_number: str | None = Field(None, description='店铺号码')
|
||||||
|
|
||||||
|
|
||||||
|
class Out(TimestampModel, Base):
|
||||||
|
"""
|
||||||
|
输出模型
|
||||||
|
"""
|
||||||
|
code: int = Field(200, description='状态码')
|
||||||
|
message: str = Field('成功', description='提示信息')
|
||||||
|
id: UUID = Field(..., description='ID')
|
||||||
|
|
||||||
|
create_time: datetime = Field(..., description='创建时间')
|
||||||
|
update_time: datetime = Field(..., description='更新时间')
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def create_time_cn(self) -> str:
|
||||||
|
return self.create_time.astimezone(CHINA_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def update_time_cn(self) -> str:
|
||||||
|
return self.update_time.astimezone(CHINA_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class OutList(BaseModel):
|
||||||
|
"""
|
||||||
|
列表输出模型
|
||||||
|
"""
|
||||||
|
code: int = Field(200, description='状态码')
|
||||||
|
message: str = Field('成功', description='提示信息')
|
||||||
|
count: int = Field(0, description='总数')
|
||||||
|
num: int = Field(0, description='当前数量')
|
||||||
|
items: List[Out] = Field([], description='列表数据')
|
||||||
155
back/apis/country/shop/view.py
Normal file
155
back/apis/country/shop/view.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
|
||||||
|
from fastapi import APIRouter, Query, Body, HTTPException
|
||||||
|
from uuid import UUID
|
||||||
|
from .schema import Create, Update, Out, OutList
|
||||||
|
from ..models import Shop
|
||||||
|
from utils.decorators import handle_exceptions_unified
|
||||||
|
from utils.time_tool import parse_time
|
||||||
|
from utils.out_base import CommonOut
|
||||||
|
from tortoise.transactions import in_transaction
|
||||||
|
import random
|
||||||
|
|
||||||
|
app = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# 创建店铺
|
||||||
|
@app.post("", response_model=Out, description='创建店铺', summary='创建店铺')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def post(item: Create = Body(..., description='创建数据')):
|
||||||
|
"""
|
||||||
|
创建店铺记录
|
||||||
|
"""
|
||||||
|
res = await Shop.filter(street=item.street).first()
|
||||||
|
if res:
|
||||||
|
raise HTTPException(status_code=400, detail='店铺已存在')
|
||||||
|
res = await Shop.create(**item.model_dump())
|
||||||
|
if not res:
|
||||||
|
raise HTTPException(status_code=400, detail='创建失败')
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# 查询店铺
|
||||||
|
@app.get("", response_model=OutList, description='获取店铺', summary='获取店铺')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def gets(
|
||||||
|
id: UUID | None = Query(None, description='主键ID'),
|
||||||
|
province: str | None = Query(None, description='省份'),
|
||||||
|
city: str | None = Query(None, description='城市'),
|
||||||
|
street: str | None = Query(None, description='街道'),
|
||||||
|
shop_name: str | None = Query(None, description='店铺名称'),
|
||||||
|
shop_number: str | None = Query(None, description='店铺号码'),
|
||||||
|
order_by: str | None = Query('create_time', description='排序字段',
|
||||||
|
regex='^(-)?(id|province|city|street|shop_name|create_time|update_time)$'),
|
||||||
|
res_count: bool = Query(False, description='是否返回总数'),
|
||||||
|
create_time_start: str | int | None = Query(
|
||||||
|
None, description='创建时间开始 (支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 13位时间戳)'),
|
||||||
|
create_time_end: str | int | None = Query(
|
||||||
|
None, description='创建时间结束 (支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 13位时间戳)'),
|
||||||
|
update_time_start: str | int | None = Query(
|
||||||
|
None, description='更新时间开始 (支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 13位时间戳)'),
|
||||||
|
update_time_end: str | int | None = Query(
|
||||||
|
None, description='更新时间结束 (支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 13位时间戳)'),
|
||||||
|
page: int = Query(1, ge=1, description='页码'),
|
||||||
|
limit: int = Query(10, ge=1, le=1000, description='每页数量'),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取店铺列表
|
||||||
|
"""
|
||||||
|
query = Shop.all()
|
||||||
|
if id:
|
||||||
|
query = query.filter(id=id)
|
||||||
|
if province:
|
||||||
|
query = query.filter(province=province)
|
||||||
|
if city:
|
||||||
|
query = query.filter(city=city)
|
||||||
|
if street:
|
||||||
|
query = query.filter(street=street)
|
||||||
|
if shop_name:
|
||||||
|
query = query.filter(shop_name=shop_name)
|
||||||
|
if shop_number:
|
||||||
|
query = query.filter(shop_number=shop_number)
|
||||||
|
if create_time_start:
|
||||||
|
query = query.filter(create_time__gte=parse_time(create_time_start))
|
||||||
|
if create_time_end:
|
||||||
|
query = query.filter(create_time__lte=parse_time(
|
||||||
|
create_time_end, is_end=True))
|
||||||
|
if update_time_start:
|
||||||
|
query = query.filter(update_time__gte=parse_time(update_time_start))
|
||||||
|
if update_time_end:
|
||||||
|
query = query.filter(update_time__lte=parse_time(
|
||||||
|
update_time_end, is_end=True))
|
||||||
|
|
||||||
|
if order_by:
|
||||||
|
query = query.order_by(order_by)
|
||||||
|
|
||||||
|
if res_count:
|
||||||
|
count = await query.count()
|
||||||
|
else:
|
||||||
|
count = -1
|
||||||
|
offset = (page - 1) * limit # 计算偏移量
|
||||||
|
query = query.limit(limit).offset(offset) # 应用分页
|
||||||
|
|
||||||
|
res = await query
|
||||||
|
if not res:
|
||||||
|
raise HTTPException(status_code=404, detail='店铺不存在')
|
||||||
|
num = len(res)
|
||||||
|
return OutList(count=count, num=num, items=res)
|
||||||
|
|
||||||
|
|
||||||
|
# 更新店铺
|
||||||
|
@app.put("", response_model=Out, description='更新店铺', summary='更新店铺')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def put(id: UUID = Query(..., description='主键ID'),
|
||||||
|
item: Update = Body(..., description='更新数据'),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
部分更新店铺,只更新传入的非空字段
|
||||||
|
"""
|
||||||
|
# 检查店铺是否存在
|
||||||
|
secret = await Shop.get_or_none(id=id)
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(status_code=404, detail='店铺不存在')
|
||||||
|
|
||||||
|
# 获取要更新的字段(排除None值的字段)
|
||||||
|
update_data = item.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# 如果没有要更新的字段
|
||||||
|
if not update_data:
|
||||||
|
raise HTTPException(status_code=400, detail='没有要更新的字段')
|
||||||
|
|
||||||
|
# 更新店铺字段
|
||||||
|
await secret.update_from_dict(update_data)
|
||||||
|
await secret.save()
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
# 删除店铺
|
||||||
|
|
||||||
|
@app.delete("", response_model=CommonOut, description='删除店铺', summary='删除店铺')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def delete(id: UUID = Query(..., description='主键ID'),
|
||||||
|
):
|
||||||
|
"""删除店铺"""
|
||||||
|
secret = await Shop.get_or_none(id=id)
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(status_code=404, detail='店铺不存在')
|
||||||
|
await secret.delete()
|
||||||
|
# Tortoise ORM 单个实例的 delete() 方法返回 None,而不是删除的记录数
|
||||||
|
# 删除成功时手动返回 1,如果有异常会被装饰器捕获
|
||||||
|
return CommonOut(count=1)
|
||||||
|
|
||||||
|
# 随机取一个店铺
|
||||||
|
@app.get("/random", response_model=Out, description='随机取一个店铺', summary='随机取一个店铺')
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def get_random_shop():
|
||||||
|
"""
|
||||||
|
随机取一个店铺(事务内计数与偏移选择,避免数据库不稳定的随机排序)
|
||||||
|
"""
|
||||||
|
async with in_transaction() as conn:
|
||||||
|
q = Shop.all().using_db(conn)
|
||||||
|
total = await q.count()
|
||||||
|
if total == 0:
|
||||||
|
raise HTTPException(status_code=404, detail='店铺不存在')
|
||||||
|
pick_index = random.choice(range(total))
|
||||||
|
item = await q.order_by('create_time').offset(pick_index).first()
|
||||||
|
return item
|
||||||
29
back/compose.yml
Executable file
29
back/compose.yml
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
# 容器服务名称
|
||||||
|
ca_auto_table:
|
||||||
|
# 容器名称
|
||||||
|
container_name: ca_auto_table
|
||||||
|
build:
|
||||||
|
# 在当前目录下寻找Dockerfile文件并构建镜像
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
# 重启策略
|
||||||
|
restart: always
|
||||||
|
# 挂载目录 本地化容器数据
|
||||||
|
# 这里挂载了本地当前目录的app目录到容器的/app目录
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
# 环境变量 可以在Dockerfile中配置环境变量,应用中获取
|
||||||
|
environment:
|
||||||
|
- NAME=ca_auto_table
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
# 端口映射 容器端口映射到主机端口
|
||||||
|
ports:
|
||||||
|
- "6060:6060"
|
||||||
|
# 日志配置 - 限制日志大小并启用日志轮转
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m" # 单个日志文件最大10MB
|
||||||
|
max-file: "3" # 保留最多3个日志文件
|
||||||
|
compress: "true" # 压缩旧日志文件
|
||||||
152
back/main.py
Normal file
152
back/main.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from settings import TORTOISE_ORM
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from tortoise.contrib.fastapi import register_tortoise
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
from tortoise import Tortoise
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from apis import app as main_router
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""
|
||||||
|
应用生命周期管理函数
|
||||||
|
|
||||||
|
- 启动:注册定时任务并启动调度器
|
||||||
|
- 关闭:优雅关闭调度器与数据库连接
|
||||||
|
"""
|
||||||
|
print('项目启动...')
|
||||||
|
|
||||||
|
# 初始化数据库连接(使用 Tortoise 直接初始化,确保路由与定时任务可用)
|
||||||
|
try:
|
||||||
|
await Tortoise.init(config=TORTOISE_ORM)
|
||||||
|
print('数据库初始化完成')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'数据库初始化失败: {e}')
|
||||||
|
|
||||||
|
# 每30分钟保持一次数据库连接活跃
|
||||||
|
scheduler.add_job(
|
||||||
|
keep_db_connection_alive,
|
||||||
|
IntervalTrigger(minutes=30),
|
||||||
|
id='keep_db_alive',
|
||||||
|
name='保持数据库连接',
|
||||||
|
coalesce=True,
|
||||||
|
misfire_grace_time=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
print('项目结束...')
|
||||||
|
|
||||||
|
# 关闭数据库连接
|
||||||
|
print('关闭数据库连接...')
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(Tortoise.close_connections(), timeout=2)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print('关闭数据库连接超时')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'关闭数据库连接出错: {e}')
|
||||||
|
|
||||||
|
# 关闭调度器
|
||||||
|
print('关闭调度器...')
|
||||||
|
try:
|
||||||
|
if scheduler is not None and hasattr(scheduler, 'shutdown'):
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'关闭调度器出错: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 创建 FastAPI 应用实例
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
# 配置 CORS 中间件
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建调度器实例
|
||||||
|
scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
# 包含主路由
|
||||||
|
app.include_router(main_router)
|
||||||
|
|
||||||
|
# 注意:使用自定义 lifespan 已在启动时手动初始化数据库。
|
||||||
|
# 若改回默认事件机制,可重新启用 register_tortoise。
|
||||||
|
|
||||||
|
|
||||||
|
async def keep_db_connection_alive():
|
||||||
|
"""
|
||||||
|
保持数据库连接活跃的函数
|
||||||
|
定期执行简单查询以防止连接超时
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = Tortoise.get_connection("default")
|
||||||
|
await conn.execute_query("SELECT 1")
|
||||||
|
print("数据库连接检查成功")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"数据库连接检查失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def signal_handler():
|
||||||
|
"""
|
||||||
|
处理终止信号,确保资源正确释放
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def shutdown():
|
||||||
|
print("收到终止信号,开始优雅关闭...")
|
||||||
|
|
||||||
|
# 关闭数据库连接
|
||||||
|
print("关闭数据库连接...")
|
||||||
|
try:
|
||||||
|
await Tortoise.close_connections()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"关闭数据库连接时出错: {e}")
|
||||||
|
|
||||||
|
# 关闭调度器
|
||||||
|
print("关闭调度器...")
|
||||||
|
try:
|
||||||
|
scheduler.shutdown()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"关闭调度器时出错: {e}")
|
||||||
|
|
||||||
|
print("所有资源已关闭,程序退出")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(shutdown())
|
||||||
|
# 给异步任务一些时间完成
|
||||||
|
loop.run_until_complete(asyncio.sleep(2))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
from uvicorn import run
|
||||||
|
|
||||||
|
# 注册信号处理
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
signal.signal(sig, lambda sig, frame: signal_handler())
|
||||||
|
|
||||||
|
run(
|
||||||
|
'main:app',
|
||||||
|
host='0.0.0.0',
|
||||||
|
port=6060,
|
||||||
|
reload=False,
|
||||||
|
workers=1,
|
||||||
|
# loop='uvloop',
|
||||||
|
http='httptools',
|
||||||
|
limit_concurrency=10000,
|
||||||
|
backlog=4096,
|
||||||
|
timeout_keep_alive=5
|
||||||
|
)
|
||||||
67
back/migrations/models/0_20251212143904_init.py
Normal file
67
back/migrations/models/0_20251212143904_init.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
CREATE TABLE IF NOT EXISTS `food` (
|
||||||
|
`id` CHAR(36) NOT NULL PRIMARY KEY COMMENT 'ID',
|
||||||
|
`name` VARCHAR(255) NOT NULL COMMENT '食物名称',
|
||||||
|
`create_time` DATETIME(6) NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`update_time` DATETIME(6) NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
KEY `idx_food_name_b88f83` (`name`),
|
||||||
|
KEY `idx_food_create__2db565` (`create_time`)
|
||||||
|
) CHARACTER SET utf8mb4 COMMENT='食物表';
|
||||||
|
CREATE TABLE IF NOT EXISTS `info` (
|
||||||
|
`id` CHAR(36) NOT NULL PRIMARY KEY COMMENT 'ID',
|
||||||
|
`child_full_name` VARCHAR(255) NOT NULL COMMENT '孩子全名',
|
||||||
|
`parent_full_name` VARCHAR(255) NOT NULL COMMENT '家长全名',
|
||||||
|
`child_birthday` VARCHAR(32) NOT NULL COMMENT '孩子生日',
|
||||||
|
`address_str` VARCHAR(255) NOT NULL COMMENT '街道地址',
|
||||||
|
`city_name` VARCHAR(255) NOT NULL COMMENT '城市',
|
||||||
|
`parent_phone` VARCHAR(64) NOT NULL COMMENT '家长电话',
|
||||||
|
`postcode` VARCHAR(20) NOT NULL COMMENT '邮编',
|
||||||
|
`province` VARCHAR(255) NOT NULL COMMENT '省/州全称',
|
||||||
|
`status` BOOL NOT NULL COMMENT '状态' DEFAULT 0,
|
||||||
|
`email` VARCHAR(255) NOT NULL UNIQUE COMMENT '邮箱',
|
||||||
|
`email_content` LONGTEXT COMMENT '邮件内容',
|
||||||
|
`text` LONGTEXT COMMENT '文本内容',
|
||||||
|
`create_time` DATETIME(6) NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`update_time` DATETIME(6) NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
KEY `idx_info_child_f_dae7dc` (`child_full_name`),
|
||||||
|
KEY `idx_info_parent__d99e40` (`parent_full_name`),
|
||||||
|
KEY `idx_info_address_8c2b80` (`address_str`),
|
||||||
|
KEY `idx_info_city_na_ac7d8f` (`city_name`),
|
||||||
|
KEY `idx_info_postcod_9a4431` (`postcode`),
|
||||||
|
KEY `idx_info_provinc_58581b` (`province`),
|
||||||
|
KEY `idx_info_email_653be4` (`email`),
|
||||||
|
KEY `idx_info_create__3bea91` (`create_time`),
|
||||||
|
KEY `idx_info_city_na_a8ca74` (`city_name`, `postcode`, `province`),
|
||||||
|
KEY `idx_info_child_f_2cf26a` (`child_full_name`, `parent_full_name`)
|
||||||
|
) CHARACTER SET utf8mb4 COMMENT='信息表';
|
||||||
|
CREATE TABLE IF NOT EXISTS `shop` (
|
||||||
|
`id` CHAR(36) NOT NULL PRIMARY KEY COMMENT 'ID',
|
||||||
|
`province` VARCHAR(255) COMMENT '省份',
|
||||||
|
`city` VARCHAR(255) NOT NULL COMMENT '城市',
|
||||||
|
`street` VARCHAR(255) NOT NULL COMMENT '街道',
|
||||||
|
`shop_name` VARCHAR(255) NOT NULL COMMENT '店铺名称',
|
||||||
|
`shop_number` VARCHAR(255) COMMENT '店铺号码',
|
||||||
|
`create_time` DATETIME(6) NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`update_time` DATETIME(6) NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
KEY `idx_shop_provinc_904758` (`province`),
|
||||||
|
KEY `idx_shop_city_69d82f` (`city`),
|
||||||
|
KEY `idx_shop_street_5aaa95` (`street`),
|
||||||
|
KEY `idx_shop_shop_na_938b2f` (`shop_name`),
|
||||||
|
KEY `idx_shop_create__e13964` (`create_time`),
|
||||||
|
KEY `idx_shop_provinc_72e64a` (`province`, `city`, `street`)
|
||||||
|
) CHARACTER SET utf8mb4 COMMENT='店铺表';
|
||||||
|
CREATE TABLE IF NOT EXISTS `aerich` (
|
||||||
|
`id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
`version` VARCHAR(255) NOT NULL,
|
||||||
|
`app` VARCHAR(100) NOT NULL,
|
||||||
|
`content` JSON NOT NULL
|
||||||
|
) CHARACTER SET utf8mb4;"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
"""
|
||||||
4
back/pyproject.toml
Normal file
4
back/pyproject.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[tool.aerich]
|
||||||
|
tortoise_orm = "settings.TORTOISE_ORM"
|
||||||
|
location = "./migrations"
|
||||||
|
src_folder = "./."
|
||||||
25
back/requirements.txt
Normal file
25
back/requirements.txt
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
aerich
|
||||||
|
aiohttp
|
||||||
|
aiomysql
|
||||||
|
APScheduler
|
||||||
|
fastapi
|
||||||
|
# numpy
|
||||||
|
tenacity
|
||||||
|
tortoise-orm
|
||||||
|
uvicorn
|
||||||
|
pycryptodome
|
||||||
|
curl_cffi
|
||||||
|
fake_useragent
|
||||||
|
aiohttp_socks
|
||||||
|
pynacl
|
||||||
|
eth-account
|
||||||
|
base58
|
||||||
|
aioredis
|
||||||
|
redis
|
||||||
|
httpx
|
||||||
|
loguru
|
||||||
|
uvloop
|
||||||
|
cryptography
|
||||||
|
uvicorn[standard]
|
||||||
|
psutil
|
||||||
|
DrissionPage
|
||||||
34
back/settings.py
Normal file
34
back/settings.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
TORTOISE_ORM = {
|
||||||
|
'connections': {
|
||||||
|
'default': {
|
||||||
|
# 'engine': 'tortoise.backends.asyncpg', PostgreSQL
|
||||||
|
'engine': 'tortoise.backends.mysql', # MySQL or Mariadb
|
||||||
|
'credentials': {
|
||||||
|
'host': '192.168.11.67',
|
||||||
|
'port': 3306,
|
||||||
|
'user': 'us',
|
||||||
|
'password': 'BkftDZfBzjBFAFwD',
|
||||||
|
'database': 'us',
|
||||||
|
'minsize': 10, # 最小连接数设为10,避免连接过多
|
||||||
|
'maxsize': 30, # 最大连接数设为30,避免超出数据库限制
|
||||||
|
'charset': 'utf8mb4',
|
||||||
|
"echo": False,
|
||||||
|
'pool_recycle': 3600, # 增加连接回收时间从300秒到3600秒(1小时)
|
||||||
|
'connect_timeout': 10, # 连接超时时间
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'apps': {
|
||||||
|
'models': {
|
||||||
|
# 仅注册实际存在的模型模块,移除不存在的 apis.project.models,避免 Aerich 初始化失败
|
||||||
|
'models': [
|
||||||
|
"apis.country.models",
|
||||||
|
"aerich.models"
|
||||||
|
],
|
||||||
|
'default_connection': 'default',
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'use_tz': False,
|
||||||
|
'timezone': 'Asia/Shanghai'
|
||||||
|
}
|
||||||
0
back/utils/__init__.py
Normal file
0
back/utils/__init__.py
Normal file
143
back/utils/browser_api.py
Normal file
143
back/utils/browser_api.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import datetime
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
from utils.decorators import handle_exceptions_unified
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserApi:
|
||||||
|
"""
|
||||||
|
浏览器接口
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.local_url = 'http://127.0.0.1:54345'
|
||||||
|
self.headers = {'Content-Type': 'application/json'}
|
||||||
|
# 使用异步 HTTP 客户端,启用连接池和超时设置
|
||||||
|
self.client = httpx.AsyncClient(
|
||||||
|
base_url=self.local_url,
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=httpx.Timeout(30.0, connect=10.0), # 总超时30秒,连接超时10秒
|
||||||
|
limits=httpx.Limits(max_keepalive_connections=50, max_connections=100), # 连接池配置
|
||||||
|
)
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
"""异步上下文管理器入口"""
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""异步上下文管理器出口,关闭客户端"""
|
||||||
|
await self.aclose()
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
"""关闭 HTTP 客户端"""
|
||||||
|
if self.client:
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
# 打开指纹浏览器
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def open_browser(self, id: str, jc: int = 0):
|
||||||
|
"""
|
||||||
|
打开指纹浏览器(异步优化版本)
|
||||||
|
:param jc: 计次
|
||||||
|
:param id: 浏览器id
|
||||||
|
:return:http, pid
|
||||||
|
"""
|
||||||
|
if jc > 3:
|
||||||
|
return None, None
|
||||||
|
url = '/browser/open'
|
||||||
|
data = {
|
||||||
|
'id': id
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
res = await self.client.post(url, json=data)
|
||||||
|
res.raise_for_status() # 检查 HTTP 状态码
|
||||||
|
res_data = res.json()
|
||||||
|
logger.info(f'打开指纹浏览器: {res_data}')
|
||||||
|
if not res_data.get('success'):
|
||||||
|
logger.error(f'打开指纹浏览器失败: {res_data}')
|
||||||
|
return await self.open_browser(id, jc + 1)
|
||||||
|
data = res_data.get('data')
|
||||||
|
http = data.get('http')
|
||||||
|
pid = data.get('pid')
|
||||||
|
logger.info(f'打开指纹浏览器成功: {http}, {pid}')
|
||||||
|
return http, pid
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
logger.error(f'打开指纹浏览器超时: {e}')
|
||||||
|
if jc < 3:
|
||||||
|
return await self.open_browser(id, jc + 1)
|
||||||
|
return None, None
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f'打开指纹浏览器请求错误: {e}')
|
||||||
|
if jc < 3:
|
||||||
|
return await self.open_browser(id, jc + 1)
|
||||||
|
return None, None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'打开指纹浏览器异常: {e}')
|
||||||
|
if jc < 3:
|
||||||
|
return await self.open_browser(id, jc + 1)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# 关闭指纹浏览器
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def close_browser(self, id: str, jc: int = 0):
|
||||||
|
"""
|
||||||
|
关闭指纹浏览器(异步优化版本)
|
||||||
|
:param jc: 计次
|
||||||
|
:param id: 浏览器id
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
if jc > 3:
|
||||||
|
return None
|
||||||
|
url = '/browser/close'
|
||||||
|
data = {
|
||||||
|
'id': id
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
res = await self.client.post(url, json=data)
|
||||||
|
res.raise_for_status() # 检查 HTTP 状态码
|
||||||
|
res_data = res.json()
|
||||||
|
logger.info(f'关闭指纹浏览器: {res_data}')
|
||||||
|
if not res_data.get('success'):
|
||||||
|
msg = res_data.get('msg', '')
|
||||||
|
# 如果浏览器正在打开中,等待后重试(不是真正的错误)
|
||||||
|
if '正在打开中' in msg or 'opening' in msg.lower():
|
||||||
|
if jc < 3:
|
||||||
|
# 等待 1-3 秒后重试(根据重试次数递增等待时间)
|
||||||
|
wait_time = (jc + 1) * 1.0 # 第1次重试等1秒,第2次等2秒,第3次等3秒
|
||||||
|
logger.info(f'浏览器正在打开中,等待 {wait_time} 秒后重试关闭: browser_id={id}')
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
return await self.close_browser(id, jc + 1)
|
||||||
|
else:
|
||||||
|
# 超过重试次数,记录警告但不作为错误
|
||||||
|
logger.warning(f'关闭指纹浏览器失败(浏览器正在打开中,已重试3次): browser_id={id}')
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# 其他错误,记录为错误并重试
|
||||||
|
logger.error(f'关闭指纹浏览器失败: {res_data}')
|
||||||
|
if jc < 3:
|
||||||
|
await asyncio.sleep(0.5) # 短暂等待后重试
|
||||||
|
return await self.close_browser(id, jc + 1)
|
||||||
|
return None
|
||||||
|
logger.info(f'关闭指纹浏览器成功: browser_id={id}')
|
||||||
|
return True
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
logger.error(f'关闭指纹浏览器超时: {e}')
|
||||||
|
if jc < 3:
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
return await self.close_browser(id, jc + 1)
|
||||||
|
return None
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f'关闭指纹浏览器请求错误: {e}')
|
||||||
|
if jc < 3:
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
return await self.close_browser(id, jc + 1)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'关闭指纹浏览器异常: {e}')
|
||||||
|
if jc < 3:
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
return await self.close_browser(id, jc + 1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
browser_api = BrowserApi()
|
||||||
165
back/utils/decorators.py
Normal file
165
back/utils/decorators.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from typing import Callable, Any, Optional
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from tortoise.exceptions import OperationalError
|
||||||
|
|
||||||
|
# 获取日志记录器
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_exceptions_unified(
|
||||||
|
max_retries: int = 0,
|
||||||
|
retry_delay: float = 1.0,
|
||||||
|
status_code: int = 500,
|
||||||
|
custom_message: Optional[str] = None,
|
||||||
|
is_background_task: bool = False
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
统一的异常处理装饰器
|
||||||
|
|
||||||
|
集成了所有异常处理功能:数据库重试、自定义状态码、自定义消息、后台任务处理
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_retries: 最大重试次数,默认0(不重试)
|
||||||
|
retry_delay: 重试间隔时间(秒),默认1秒
|
||||||
|
status_code: HTTP状态码,默认500
|
||||||
|
custom_message: 自定义错误消息前缀
|
||||||
|
is_background_task: 是否为后台任务(不抛出HTTPException)
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
# 基础异常处理
|
||||||
|
@handle_exceptions_unified()
|
||||||
|
async def basic_function(...):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 带数据库重试
|
||||||
|
@handle_exceptions_unified(max_retries=3, retry_delay=1.0)
|
||||||
|
async def db_function(...):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 自定义状态码和消息
|
||||||
|
@handle_exceptions_unified(status_code=400, custom_message="参数错误")
|
||||||
|
async def validation_function(...):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 后台任务处理
|
||||||
|
@handle_exceptions_unified(is_background_task=True)
|
||||||
|
async def background_function(...):
|
||||||
|
pass
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs) -> Any:
|
||||||
|
last_exception = None
|
||||||
|
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
except HTTPException as e:
|
||||||
|
# HTTPException 直接抛出,不重试
|
||||||
|
if is_background_task:
|
||||||
|
logger.error(f"后台任务 {func.__name__} HTTPException: {str(e)}")
|
||||||
|
return False
|
||||||
|
raise
|
||||||
|
except OperationalError as e:
|
||||||
|
last_exception = e
|
||||||
|
error_msg = str(e).lower()
|
||||||
|
|
||||||
|
# 检查是否是连接相关的错误
|
||||||
|
if any(keyword in error_msg for keyword in [
|
||||||
|
'lost connection', 'connection', 'timeout',
|
||||||
|
'server has gone away', 'broken pipe'
|
||||||
|
]):
|
||||||
|
if attempt < max_retries:
|
||||||
|
logger.warning(
|
||||||
|
f"函数 {func.__name__} 数据库连接错误 (尝试 {attempt + 1}/{max_retries + 1}): {str(e)}"
|
||||||
|
)
|
||||||
|
# 等待一段时间后重试,使用指数退避
|
||||||
|
await asyncio.sleep(retry_delay * (2 ** attempt))
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"函数 {func.__name__} 数据库连接错误,已达到最大重试次数: {str(e)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 非连接错误,直接处理
|
||||||
|
logger.error(f"函数 {func.__name__} 发生数据库错误: {str(e)}")
|
||||||
|
if is_background_task:
|
||||||
|
return False
|
||||||
|
error_detail = f"{custom_message}: {str(e)}" if custom_message else f"数据库操作失败: {str(e)}"
|
||||||
|
raise HTTPException(status_code=status_code, detail=error_detail)
|
||||||
|
except Exception as e:
|
||||||
|
last_exception = e
|
||||||
|
if attempt < max_retries:
|
||||||
|
logger.warning(
|
||||||
|
f"函数 {func.__name__} 发生异常 (尝试 {attempt + 1}/{max_retries + 1}): {str(e)}"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(retry_delay * (2 ** attempt))
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.error(f"函数 {func.__name__} 发生异常: {str(e)}", exc_info=True)
|
||||||
|
if is_background_task:
|
||||||
|
return False
|
||||||
|
break
|
||||||
|
|
||||||
|
# 所有重试都失败了,处理最后一个异常
|
||||||
|
if is_background_task:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(last_exception, OperationalError):
|
||||||
|
error_detail = f"{custom_message}: 数据库连接失败: {str(last_exception)}" if custom_message else f"数据库连接失败: {str(last_exception)}"
|
||||||
|
else:
|
||||||
|
error_detail = f"{custom_message}: {str(last_exception)}" if custom_message else str(last_exception)
|
||||||
|
|
||||||
|
raise HTTPException(status_code=status_code, detail=error_detail)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# 向后兼容的别名函数
|
||||||
|
def handle_exceptions_with_db_retry(max_retries: int = 3, retry_delay: float = 1.0):
|
||||||
|
"""
|
||||||
|
带数据库连接重试的异常处理装饰器(向后兼容)
|
||||||
|
|
||||||
|
这是 handle_exceptions_unified 的别名,保持向后兼容性
|
||||||
|
"""
|
||||||
|
return handle_exceptions_unified(max_retries=max_retries, retry_delay=retry_delay)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_exceptions(func: Callable) -> Callable:
|
||||||
|
"""
|
||||||
|
基础异常处理装饰器(向后兼容)
|
||||||
|
|
||||||
|
这是 handle_exceptions_unified() 的别名,保持向后兼容性
|
||||||
|
"""
|
||||||
|
return handle_exceptions_unified()(func)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_background_task_exceptions(func: Callable) -> Callable:
|
||||||
|
"""
|
||||||
|
后台任务异常处理装饰器(向后兼容)
|
||||||
|
|
||||||
|
这是 handle_exceptions_unified 的别名,保持向后兼容性
|
||||||
|
"""
|
||||||
|
return handle_exceptions_unified(is_background_task=True)(func)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_exceptions_with_custom_message(message: str = "操作失败"):
|
||||||
|
"""
|
||||||
|
带自定义错误消息的异常处理装饰器(向后兼容)
|
||||||
|
|
||||||
|
这是 handle_exceptions_unified 的别名,保持向后兼容性
|
||||||
|
"""
|
||||||
|
return handle_exceptions_unified(custom_message=message)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_exceptions_with_status_code(status_code: int = 500, message: str = None):
|
||||||
|
"""
|
||||||
|
带自定义状态码和错误消息的异常处理装饰器(向后兼容)
|
||||||
|
|
||||||
|
这是 handle_exceptions_unified 的别名,保持向后兼容性
|
||||||
|
"""
|
||||||
|
return handle_exceptions_unified(status_code=status_code, custom_message=message)
|
||||||
47
back/utils/exceptions.py
Normal file
47
back/utils/exceptions.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import os
|
||||||
|
from fastapi import Request, status
|
||||||
|
from fastapi.exceptions import HTTPException, RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from .logs import getLogger
|
||||||
|
|
||||||
|
logger = getLogger(os.environ.get('APP_NAME'))
|
||||||
|
|
||||||
|
|
||||||
|
def global_http_exception_handler(request: Request, exc):
|
||||||
|
"""
|
||||||
|
全局HTTP请求处理异常
|
||||||
|
:param request: HTTP请求对象
|
||||||
|
:param exc: 本次发生的异常对象
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 使用日志记录异常
|
||||||
|
logger.error(f"发生异常:{exc.detail}")
|
||||||
|
|
||||||
|
# 直接返回JSONResponse,避免重新抛出异常导致循环
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={
|
||||||
|
'err_msg': exc.detail,
|
||||||
|
'status': False
|
||||||
|
},
|
||||||
|
headers=getattr(exc, 'headers', None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def global_request_exception_handler(request: Request, exc):
|
||||||
|
"""
|
||||||
|
全局请求校验异常处理函数
|
||||||
|
:param request: HTTP请求对象
|
||||||
|
:param exc: 本次发生的异常对象
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 直接返回JSONResponse,避免重新抛出异常
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
content={
|
||||||
|
'err_msg': exc.errors()[0],
|
||||||
|
'status': False
|
||||||
|
}
|
||||||
|
)
|
||||||
218
back/utils/logs.py
Normal file
218
back/utils/logs.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from logging import Logger
|
||||||
|
from concurrent_log_handler import ConcurrentRotatingFileHandler
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
import gzip
|
||||||
|
import shutil
|
||||||
|
import glob
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def getLogger(name: str = 'root') -> Logger:
|
||||||
|
"""
|
||||||
|
创建一个按2小时滚动、支持多进程安全、自动压缩日志的 Logger
|
||||||
|
:param name: 日志器名称
|
||||||
|
:return: 单例 Logger 对象
|
||||||
|
"""
|
||||||
|
logger: Logger = logging.getLogger(name)
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
if not logger.handlers:
|
||||||
|
# 控制台输出
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# 日志目录
|
||||||
|
log_dir = "logs"
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 日志文件路径
|
||||||
|
log_file = os.path.join(log_dir, f"{name}.log")
|
||||||
|
|
||||||
|
# 文件处理器:每2小时滚动一次,保留7天,共84个文件,支持多进程写入
|
||||||
|
file_handler = TimedRotatingFileHandler(
|
||||||
|
filename=log_file,
|
||||||
|
when='H',
|
||||||
|
interval=2, # 每2小时切一次
|
||||||
|
backupCount=84, # 保留7天 = 7 * 24 / 2 = 84个文件
|
||||||
|
encoding='utf-8',
|
||||||
|
delay=False,
|
||||||
|
utc=False # 你也可以改成 True 表示按 UTC 时间切
|
||||||
|
)
|
||||||
|
|
||||||
|
# 设置 Formatter - 简化格式,去掉路径信息
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
fmt="【{name}】{levelname} {asctime} {message}",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
style="{"
|
||||||
|
)
|
||||||
|
console_formatter = logging.Formatter(
|
||||||
|
fmt="{levelname} {asctime} {message}",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
style="{"
|
||||||
|
)
|
||||||
|
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
console_handler.setFormatter(console_formatter)
|
||||||
|
|
||||||
|
logger.addHandler(console_handler)
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
# 添加压缩功能(在第一次创建 logger 时执行一次)
|
||||||
|
_compress_old_logs(log_dir, name)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def _compress_old_logs(log_dir: str, name: str):
|
||||||
|
"""
|
||||||
|
将旧日志压缩成 .gz 格式
|
||||||
|
"""
|
||||||
|
pattern = os.path.join(log_dir, f"{name}.log.*")
|
||||||
|
for filepath in glob.glob(pattern):
|
||||||
|
if filepath.endswith('.gz'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(filepath, 'rb') as f_in:
|
||||||
|
with gzip.open(filepath + '.gz', 'wb') as f_out:
|
||||||
|
shutil.copyfileobj(f_in, f_out)
|
||||||
|
os.remove(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"日志压缩失败: {filepath}, 原因: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def compress_old_logs(log_dir: str = None, name: str = "root"):
|
||||||
|
"""
|
||||||
|
压缩旧的日志文件(公共接口)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_dir: 日志目录,如果不指定则使用默认目录
|
||||||
|
name: 日志器名称
|
||||||
|
"""
|
||||||
|
if log_dir is None:
|
||||||
|
log_dir = "logs"
|
||||||
|
|
||||||
|
_compress_old_logs(log_dir, name)
|
||||||
|
|
||||||
|
|
||||||
|
def log_api_call(logger: Logger, user_id: str = None, endpoint: str = None, method: str = None, params: dict = None, response_status: int = None, client_ip: str = None):
|
||||||
|
"""
|
||||||
|
记录API调用信息,包含用户ID、接口路径、请求方法、参数、响应状态和来源IP
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: 日志器对象
|
||||||
|
user_id: 用户ID
|
||||||
|
endpoint: 接口路径
|
||||||
|
method: 请求方法 (GET, POST, PUT, DELETE等)
|
||||||
|
params: 请求参数
|
||||||
|
response_status: 响应状态码
|
||||||
|
client_ip: 客户端IP地址
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 构建日志信息
|
||||||
|
log_parts = []
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
log_parts.append(f"用户={user_id}")
|
||||||
|
|
||||||
|
if client_ip:
|
||||||
|
log_parts.append(f"IP={client_ip}")
|
||||||
|
|
||||||
|
if method and endpoint:
|
||||||
|
log_parts.append(f"{method} {endpoint}")
|
||||||
|
elif endpoint:
|
||||||
|
log_parts.append(f"接口={endpoint}")
|
||||||
|
|
||||||
|
if params:
|
||||||
|
# 过滤敏感信息
|
||||||
|
safe_params = {k: v for k, v in params.items()
|
||||||
|
if k.lower() not in ['password', 'token', 'secret', 'key']}
|
||||||
|
if safe_params:
|
||||||
|
log_parts.append(f"参数={safe_params}")
|
||||||
|
|
||||||
|
if response_status:
|
||||||
|
log_parts.append(f"状态码={response_status}")
|
||||||
|
|
||||||
|
if log_parts:
|
||||||
|
log_message = " ".join(log_parts)
|
||||||
|
logger.info(log_message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"记录API调用日志失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def delete_old_compressed_logs(log_dir: str = None, days: int = 7):
|
||||||
|
"""
|
||||||
|
删除超过指定天数的压缩日志文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_dir: 日志目录,如果不指定则使用默认目录
|
||||||
|
days: 保留天数,默认7天
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if log_dir is None:
|
||||||
|
log_dir = "logs"
|
||||||
|
|
||||||
|
log_path = Path(log_dir)
|
||||||
|
if not log_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# 计算截止时间
|
||||||
|
cutoff_time = datetime.now() - timedelta(days=days)
|
||||||
|
|
||||||
|
# 获取所有压缩日志文件
|
||||||
|
gz_files = [f for f in log_path.iterdir()
|
||||||
|
if f.is_file() and f.name.endswith('.log.gz')]
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
for gz_file in gz_files:
|
||||||
|
# 获取文件修改时间
|
||||||
|
file_mtime = datetime.fromtimestamp(gz_file.stat().st_mtime)
|
||||||
|
|
||||||
|
# 如果文件超过保留期限,删除它
|
||||||
|
if file_mtime < cutoff_time:
|
||||||
|
gz_file.unlink()
|
||||||
|
print(f"删除旧压缩日志文件: {gz_file}")
|
||||||
|
deleted_count += 1
|
||||||
|
|
||||||
|
if deleted_count > 0:
|
||||||
|
print(f"总共删除了 {deleted_count} 个旧压缩日志文件")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"删除旧压缩日志文件失败: {e}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
logger = getLogger('WebAPI')
|
||||||
|
|
||||||
|
# 基础日志测试
|
||||||
|
logger.info("系统启动")
|
||||||
|
logger.debug("调试信息")
|
||||||
|
logger.warning("警告信息")
|
||||||
|
logger.error("错误信息")
|
||||||
|
|
||||||
|
# API调用日志测试
|
||||||
|
log_api_call(
|
||||||
|
logger=logger,
|
||||||
|
user_id="user123",
|
||||||
|
endpoint="/api/users/info",
|
||||||
|
method="GET",
|
||||||
|
params={"id": 123, "fields": ["name", "email"]},
|
||||||
|
response_status=200,
|
||||||
|
client_ip="192.168.1.100"
|
||||||
|
)
|
||||||
|
|
||||||
|
log_api_call(
|
||||||
|
logger=logger,
|
||||||
|
user_id="user456",
|
||||||
|
endpoint="/api/users/login",
|
||||||
|
method="POST",
|
||||||
|
params={"username": "test", "password": "hidden"}, # password会被过滤
|
||||||
|
response_status=401,
|
||||||
|
client_ip="10.0.0.50"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 单例验证
|
||||||
|
logger2 = getLogger('WebAPI')
|
||||||
|
print(f"Logger单例验证: {id(logger) == id(logger2)}")
|
||||||
8
back/utils/out_base.py
Normal file
8
back/utils/out_base.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CommonOut(BaseModel):
|
||||||
|
"""操作结果详情模型"""
|
||||||
|
code: int = Field(200, description='状态码')
|
||||||
|
message: str = Field('成功', description='提示信息')
|
||||||
|
count: int = Field(0, description='操作影响的记录数')
|
||||||
96
back/utils/redis_tool.py
Normal file
96
back/utils/redis_tool.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import redis
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class RedisClient:
|
||||||
|
def __init__(self, host: str = 'localhost', port: int = 6379, password: str = None):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.password = password
|
||||||
|
self.browser_client = None
|
||||||
|
self.task_client = None
|
||||||
|
self.cache_client = None
|
||||||
|
self.ok_client = None
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
# 初始化
|
||||||
|
def init(self):
|
||||||
|
"""
|
||||||
|
初始化Redis客户端
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
if self.browser_client is None:
|
||||||
|
self.browser_client = redis.Redis(host=self.host, port=self.port, password=self.password, db=0,
|
||||||
|
decode_responses=True)
|
||||||
|
|
||||||
|
if self.task_client is None:
|
||||||
|
self.task_client = redis.Redis(host=self.host, port=self.port, password=self.password, db=1,
|
||||||
|
decode_responses=True)
|
||||||
|
|
||||||
|
if self.cache_client is None:
|
||||||
|
self.cache_client = redis.Redis(host=self.host, port=self.port, password=self.password, db=2,
|
||||||
|
decode_responses=True)
|
||||||
|
|
||||||
|
if self.ok_client is None:
|
||||||
|
self.ok_client = redis.Redis(host=self.host, port=self.port, password=self.password, db=3,
|
||||||
|
decode_responses=True)
|
||||||
|
|
||||||
|
logger.info("Redis连接已初始化")
|
||||||
|
|
||||||
|
# 关闭连接
|
||||||
|
def close(self):
|
||||||
|
self.browser_client.close()
|
||||||
|
self.task_client.close()
|
||||||
|
self.cache_client.close()
|
||||||
|
self.ok_client.close()
|
||||||
|
logger.info("Redis连接已关闭")
|
||||||
|
|
||||||
|
"""browser_client"""
|
||||||
|
|
||||||
|
# 写入浏览器信息
|
||||||
|
async def set_browser(self, browser_id: str, data: dict):
|
||||||
|
try:
|
||||||
|
# 处理None值,将其转换为空字符串
|
||||||
|
processed_data = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
if value is None:
|
||||||
|
processed_data[key] = ""
|
||||||
|
else:
|
||||||
|
processed_data[key] = value
|
||||||
|
|
||||||
|
self.browser_client.hset(browser_id, mapping=processed_data)
|
||||||
|
logger.info(f"写入浏览器信息: {browser_id} - {processed_data}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"写入浏览器信息失败: {browser_id} - {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 获取浏览器信息
|
||||||
|
async def get_browser(self, browser_id: str = None):
|
||||||
|
try:
|
||||||
|
if browser_id is None:
|
||||||
|
# 获取全部数据
|
||||||
|
data = self.browser_client.hgetall()
|
||||||
|
else:
|
||||||
|
data = self.browser_client.hgetall(browser_id)
|
||||||
|
logger.info(f"获取浏览器信息: {browser_id} - {data}")
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取浏览器信息失败: {browser_id} - {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
host = '183.66.27.14'
|
||||||
|
port = 50086
|
||||||
|
password = 'redis_AdJsBP'
|
||||||
|
redis_client = RedisClient(host, port, password)
|
||||||
|
# await redis_client.set_browser('9eac7f95ca2d47359ace4083a566e119', {'status': 'online', 'current_task_id': None})
|
||||||
|
await redis_client.get_browser('9eac7f95ca2d47359ace4083a566e119')
|
||||||
|
# 关闭连接
|
||||||
|
redis_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
177
back/utils/session_store.py
Normal file
177
back/utils/session_store.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStore:
|
||||||
|
"""
|
||||||
|
会话持久化存储(日志文件版 + 内存缓存)
|
||||||
|
|
||||||
|
优化方案:
|
||||||
|
1. 使用日志文件记录(追加模式,性能好,不会因为文件变大而变慢)
|
||||||
|
2. 在内存中保留最近的会话记录(用于快速查询)
|
||||||
|
3. 定期清理过期的内存记录(保留最近1小时或最多1000条)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, file_path: str = 'logs/sessions.log', enable_log: bool = True, max_memory_records: int = 1000):
|
||||||
|
"""
|
||||||
|
初始化会话存储。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path (str): 日志文件路径(默认 logs/sessions.log)
|
||||||
|
enable_log (bool): 是否启用日志记录,False 则不记录到文件
|
||||||
|
max_memory_records (int): 内存中保留的最大记录数,默认1000
|
||||||
|
"""
|
||||||
|
self.file_path = file_path
|
||||||
|
self.enable_log = enable_log
|
||||||
|
self.max_memory_records = max_memory_records
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
# 内存中的会话记录 {pid: record}
|
||||||
|
self._memory_cache: Dict[int, Dict[str, Any]] = {}
|
||||||
|
# 记录创建时间,用于清理过期记录
|
||||||
|
self._cache_timestamps: Dict[int, datetime] = {}
|
||||||
|
|
||||||
|
if enable_log:
|
||||||
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||||
|
|
||||||
|
def _write_log(self, action: str, record: Dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
写入日志文件(追加模式,性能好)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action (str): 操作类型(CREATE/UPDATE)
|
||||||
|
record (Dict[str, Any]): 会话记录
|
||||||
|
"""
|
||||||
|
if not self.enable_log:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
log_line = json.dumps({
|
||||||
|
'action': action,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'data': record
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
with open(self.file_path, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(log_line + '\n')
|
||||||
|
except Exception as e:
|
||||||
|
# 静默处理日志写入错误,避免影响主流程
|
||||||
|
logger.debug(f"写入会话日志失败: {e}")
|
||||||
|
|
||||||
|
def _cleanup_old_cache(self) -> None:
|
||||||
|
"""
|
||||||
|
清理过期的内存缓存记录
|
||||||
|
- 保留最近1小时的记录
|
||||||
|
- 最多保留 max_memory_records 条记录
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
expire_time = now - timedelta(hours=1)
|
||||||
|
|
||||||
|
# 清理过期记录
|
||||||
|
expired_pids = [
|
||||||
|
pid for pid, timestamp in self._cache_timestamps.items()
|
||||||
|
if timestamp < expire_time
|
||||||
|
]
|
||||||
|
for pid in expired_pids:
|
||||||
|
self._memory_cache.pop(pid, None)
|
||||||
|
self._cache_timestamps.pop(pid, None)
|
||||||
|
|
||||||
|
# 如果记录数仍然超过限制,删除最旧的记录
|
||||||
|
if len(self._memory_cache) > self.max_memory_records:
|
||||||
|
# 按时间戳排序,删除最旧的
|
||||||
|
sorted_pids = sorted(
|
||||||
|
self._cache_timestamps.items(),
|
||||||
|
key=lambda x: x[1]
|
||||||
|
)
|
||||||
|
# 计算需要删除的数量
|
||||||
|
to_remove = len(self._memory_cache) - self.max_memory_records
|
||||||
|
for pid, _ in sorted_pids[:to_remove]:
|
||||||
|
self._memory_cache.pop(pid, None)
|
||||||
|
self._cache_timestamps.pop(pid, None)
|
||||||
|
|
||||||
|
def create_session(self, record: Dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
创建新会话记录。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
record (Dict[str, Any]): 会话信息字典
|
||||||
|
"""
|
||||||
|
record = dict(record)
|
||||||
|
record.setdefault('created_at', datetime.now().isoformat())
|
||||||
|
pid = record.get('pid')
|
||||||
|
|
||||||
|
if pid is not None:
|
||||||
|
with self._lock:
|
||||||
|
# 保存到内存缓存
|
||||||
|
self._memory_cache[pid] = record
|
||||||
|
self._cache_timestamps[pid] = datetime.now()
|
||||||
|
# 清理过期记录
|
||||||
|
self._cleanup_old_cache()
|
||||||
|
|
||||||
|
# 写入日志文件(追加模式,性能好)
|
||||||
|
self._write_log('CREATE', record)
|
||||||
|
|
||||||
|
def update_session(self, pid: int, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
按 PID 更新会话记录。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pid (int): 进程ID
|
||||||
|
updates (Dict[str, Any]): 更新字段字典
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[Dict[str, Any]]: 更新后的会话记录
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
# 从内存缓存获取
|
||||||
|
record = self._memory_cache.get(pid)
|
||||||
|
if record:
|
||||||
|
record.update(updates)
|
||||||
|
record.setdefault('updated_at', datetime.now().isoformat())
|
||||||
|
self._cache_timestamps[pid] = datetime.now()
|
||||||
|
else:
|
||||||
|
# 如果内存中没有,创建一个新记录
|
||||||
|
record = {'pid': pid}
|
||||||
|
record.update(updates)
|
||||||
|
record.setdefault('created_at', datetime.now().isoformat())
|
||||||
|
record.setdefault('updated_at', datetime.now().isoformat())
|
||||||
|
self._memory_cache[pid] = record
|
||||||
|
self._cache_timestamps[pid] = datetime.now()
|
||||||
|
|
||||||
|
if record:
|
||||||
|
# 写入日志文件
|
||||||
|
self._write_log('UPDATE', record)
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
def get_session_by_pid(self, pid: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
按 PID 查询会话记录(仅从内存缓存查询,性能好)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pid (int): 进程ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[Dict[str, Any]]: 会话记录
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
return self._memory_cache.get(pid)
|
||||||
|
|
||||||
|
def list_sessions(self, status: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
列出会话记录,可按状态过滤(仅从内存缓存查询)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status (Optional[int]): 状态码过滤(如 100 运行中、200 已结束、500 失败)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[Dict[str, Any]]: 会话记录列表
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
records = list(self._memory_cache.values())
|
||||||
|
if status is None:
|
||||||
|
return records
|
||||||
|
return [r for r in records if r.get('status') == status]
|
||||||
56
back/utils/time_tool.py
Normal file
56
back/utils/time_tool.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pydantic import BaseModel, field_serializer
|
||||||
|
CN_TZ = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
def now_cn() -> datetime:
|
||||||
|
"""
|
||||||
|
获取中国时区的当前时间
|
||||||
|
返回带有中国时区信息的 datetime 对象
|
||||||
|
"""
|
||||||
|
return datetime.now(CN_TZ)
|
||||||
|
|
||||||
|
def parse_time(val: str | int, is_end: bool = False) -> datetime:
|
||||||
|
"""
|
||||||
|
将传入的字符串或时间戳解析为中国时区的 datetime,用于数据库查询时间比较。
|
||||||
|
支持格式:
|
||||||
|
- "YYYY-MM-DD"
|
||||||
|
- "YYYY-MM-DD HH:mm:ss"
|
||||||
|
- 10 位时间戳(秒)
|
||||||
|
- 13 位时间戳(毫秒)
|
||||||
|
"""
|
||||||
|
dt_cn: datetime
|
||||||
|
|
||||||
|
if isinstance(val, int) or (isinstance(val, str) and val.isdigit()):
|
||||||
|
ts = int(val)
|
||||||
|
# 根据量级判断是秒还是毫秒
|
||||||
|
if ts >= 10**12:
|
||||||
|
dt_cn = datetime.fromtimestamp(ts / 1000, CN_TZ)
|
||||||
|
else:
|
||||||
|
dt_cn = datetime.fromtimestamp(ts, CN_TZ)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dt_cn = datetime.strptime(val, "%Y-%m-%d").replace(tzinfo=CN_TZ)
|
||||||
|
if is_end:
|
||||||
|
dt_cn = dt_cn.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
dt_cn = datetime.strptime(val, "%Y-%m-%d %H:%M:%S").replace(tzinfo=CN_TZ)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("时间格式错误,支持 'YYYY-MM-DD' 或 'YYYY-MM-DD HH:mm:ss' 或 10/13位时间戳")
|
||||||
|
|
||||||
|
# 与 ORM 配置保持一致(use_tz=False),返回本地时区的“朴素”时间
|
||||||
|
return dt_cn.replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
# 自动把 datetime 序列化为 13位时间戳的基类
|
||||||
|
class TimestampModel(BaseModel):
|
||||||
|
"""自动把 datetime 序列化为 13位时间戳的基类"""
|
||||||
|
|
||||||
|
model_config = {"arbitrary_types_allowed": True}
|
||||||
|
|
||||||
|
@field_serializer("*", when_used="json", check_fields=False) # "*" 表示作用于所有字段
|
||||||
|
def serialize_datetime(self, value):
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return int(value.timestamp()*1000) # 转成 13 位 int 时间戳
|
||||||
|
return value
|
||||||
120
spider/api.py
Normal file
120
spider/api.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import requests
|
||||||
|
from loguru import logger
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
class Api:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# self.base_url = 'http://127.0.0.1:6060'
|
||||||
|
self.base_url = 'http://192.168.11.67:6060'
|
||||||
|
|
||||||
|
# 创建店铺
|
||||||
|
def create_shop(self, city: str, street: str, shop_name: str) -> dict:
|
||||||
|
url = f'{self.base_url}/country/shop'
|
||||||
|
item = {
|
||||||
|
'city': city,
|
||||||
|
'street': street,
|
||||||
|
'shop_name': shop_name,
|
||||||
|
}
|
||||||
|
response = requests.post(url, json=item).json()
|
||||||
|
logger.info(response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# 查询店铺
|
||||||
|
def get_shop(self, city: str) -> dict:
|
||||||
|
url = f'{self.base_url}/country/shop'
|
||||||
|
response = requests.get(url).json()
|
||||||
|
# logger.info(response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# 创建信息
|
||||||
|
def create_info(self, child_full_name: str, parent_full_name: str, child_birthday: str, address_str: str, city_name: str, parent_phone: str, postcode: str, province: str, email: str, text: str, status: bool = False, email_content: str | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
创建信息记录(孩子与家长字段)
|
||||||
|
|
||||||
|
参数:
|
||||||
|
child_full_name (str): 孩子全名
|
||||||
|
parent_full_name (str): 家长全名
|
||||||
|
child_birthday (str): 孩子生日(字符串)
|
||||||
|
address_str (str): 街道地址
|
||||||
|
city_name (str): 城市
|
||||||
|
parent_phone (str): 家长电话
|
||||||
|
postcode (str): 邮编
|
||||||
|
province (str): 省/州全称
|
||||||
|
email (str): 邮箱
|
||||||
|
text (str): 文本内容(如反馈地址)
|
||||||
|
status (bool): 状态
|
||||||
|
email_content (str | None): 邮件内容
|
||||||
|
|
||||||
|
返回值:
|
||||||
|
dict: 接口返回的数据
|
||||||
|
"""
|
||||||
|
url = f'{self.base_url}/country/info'
|
||||||
|
item = {
|
||||||
|
"child_full_name": child_full_name,
|
||||||
|
"parent_full_name": parent_full_name,
|
||||||
|
"child_birthday": child_birthday,
|
||||||
|
"address_str": address_str,
|
||||||
|
"city_name": city_name,
|
||||||
|
"parent_phone": parent_phone,
|
||||||
|
"postcode": postcode,
|
||||||
|
"province": province,
|
||||||
|
"status": status,
|
||||||
|
"email": email,
|
||||||
|
"email_content": email_content,
|
||||||
|
"text": text
|
||||||
|
}
|
||||||
|
response = requests.post(url, json=item).json()
|
||||||
|
logger.info(response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# 根据城市 随机获取一个店铺
|
||||||
|
def get_random_shop(self) -> dict:
|
||||||
|
url = f'{self.base_url}/country/shop/random'
|
||||||
|
response = requests.get(url).json()
|
||||||
|
# logger.info(response)
|
||||||
|
if not response.get('street'):
|
||||||
|
logger.error(f'没有店铺')
|
||||||
|
return None
|
||||||
|
return response
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
从同目录的 `bakeries.csv` 读取面包店数据,按列映射输出或创建店铺
|
||||||
|
|
||||||
|
列顺序:`Name,Address,City`
|
||||||
|
"""
|
||||||
|
api = Api()
|
||||||
|
csv_path = os.path.join(os.path.dirname(__file__), 'data.csv')
|
||||||
|
if not os.path.exists(csv_path):
|
||||||
|
logger.error(f'CSV 文件不存在: {csv_path}')
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(csv_path, 'r', encoding='utf-8') as file:
|
||||||
|
reader = csv.reader(file)
|
||||||
|
header = next(reader, None)
|
||||||
|
for row in reader:
|
||||||
|
if len(row) < 3:
|
||||||
|
logger.warning(f'行列数不足,跳过: {row}')
|
||||||
|
continue
|
||||||
|
shop_name, street, city = row[1], row[2], row[0]
|
||||||
|
if ' (city)' in city:
|
||||||
|
city = city.replace(' (city)', '')
|
||||||
|
if 'Quebec' in city:
|
||||||
|
continue
|
||||||
|
if ',' in city:
|
||||||
|
city = city.split(',')[0]
|
||||||
|
logger.info(f'city: {city}, street: {street}, shop_name: {shop_name}')
|
||||||
|
api.create_shop(city, street, shop_name)
|
||||||
|
|
||||||
|
# def main2():
|
||||||
|
# api = Api()
|
||||||
|
# city = 'Toronto'
|
||||||
|
# shop = api.get_random_shop()
|
||||||
|
# if shop:
|
||||||
|
# logger.info(shop)
|
||||||
|
|
||||||
|
# if __name__ == '__main__':
|
||||||
|
# main()
|
||||||
|
|
||||||
|
api = Api()
|
||||||
313
spider/auto_challenge.py
Normal file
313
spider/auto_challenge.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import io
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Optional, List
|
||||||
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
import base64
|
||||||
|
from loguru import logger
|
||||||
|
RESAMPLE_FILTER = Image.Resampling.LANCZOS
|
||||||
|
class ReCaptchaHandler:
|
||||||
|
|
||||||
|
path_map_44 = {
|
||||||
|
0: "//table/tbody/tr[1]/td[1]",
|
||||||
|
1: "//table/tbody/tr[1]/td[2]",
|
||||||
|
2: "//table/tbody/tr[1]/td[3]",
|
||||||
|
3: "//table/tbody/tr[1]/td[4]",
|
||||||
|
4: "//table/tbody/tr[2]/td[1]",
|
||||||
|
5: "//table/tbody/tr[2]/td[2]",
|
||||||
|
6: "//table/tbody/tr[2]/td[3]",
|
||||||
|
7: "//table/tbody/tr[2]/td[4]",
|
||||||
|
8: "//table/tbody/tr[3]/td[1]",
|
||||||
|
9: "//table/tbody/tr[3]/td[2]",
|
||||||
|
10: "//table/tbody/tr[3]/td[3]",
|
||||||
|
11: "//table/tbody/tr[3]/td[4]",
|
||||||
|
12: "//table/tbody/tr[4]/td[1]",
|
||||||
|
13: "//table/tbody/tr[4]/td[2]",
|
||||||
|
14: "//table/tbody/tr[4]/td[3]",
|
||||||
|
15: "//table/tbody/tr[4]/td[4]",
|
||||||
|
}
|
||||||
|
|
||||||
|
path_map_33 = {
|
||||||
|
0: "//table/tbody/tr[1]/td[1]",
|
||||||
|
1: "//table/tbody/tr[1]/td[2]",
|
||||||
|
2: "//table/tbody/tr[1]/td[3]",
|
||||||
|
3: "//table/tbody/tr[2]/td[1]",
|
||||||
|
4: "//table/tbody/tr[2]/td[2]",
|
||||||
|
5: "//table/tbody/tr[2]/td[3]",
|
||||||
|
6: "//table/tbody/tr[3]/td[1]",
|
||||||
|
7: "//table/tbody/tr[3]/td[2]",
|
||||||
|
8: "//table/tbody/tr[3]/td[3]",
|
||||||
|
}
|
||||||
|
|
||||||
|
api_host="http://192.168.11.13:7070/analyze_batch/"
|
||||||
|
def __init__(self, driver):
|
||||||
|
self.driver = driver
|
||||||
|
self.checkbox_iframe = None
|
||||||
|
self.challenge_iframe = None
|
||||||
|
self.challenge_type = None
|
||||||
|
self.challenge_question = None
|
||||||
|
self.challenge_i33_first = True
|
||||||
|
self.i11s = {}
|
||||||
|
self.challenge_44_img = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def split_image(image_bytes: bytes) -> Optional[List[str]]:
|
||||||
|
try:
|
||||||
|
image_stream = io.BytesIO(image_bytes)
|
||||||
|
img = Image.open(image_stream)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
width, height = img.size
|
||||||
|
tile_width = width // 3
|
||||||
|
tile_height = height // 3
|
||||||
|
|
||||||
|
base64_tiles = []
|
||||||
|
for i in range(3):
|
||||||
|
for j in range(3):
|
||||||
|
left = j * tile_width
|
||||||
|
upper = i * tile_height
|
||||||
|
right = (j + 1) * tile_width if j < 2 else width
|
||||||
|
lower = (i + 1) * tile_height if i < 2 else height
|
||||||
|
|
||||||
|
tile = img.crop((left, upper, right, lower))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
tile.save(buf, format="PNG")
|
||||||
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||||
|
base64_tiles.append(b64)
|
||||||
|
|
||||||
|
return base64_tiles
|
||||||
|
|
||||||
|
def find_checkbox_iframe(self):
|
||||||
|
time.sleep(1)
|
||||||
|
try:
|
||||||
|
iframe = self.driver.ele('css: iframe[title="reCAPTCHA"]')
|
||||||
|
if iframe:
|
||||||
|
self.checkbox_iframe = iframe
|
||||||
|
self.checkbox_iframe.ele("#recaptcha-anchor").click()
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
def find_challenge_iframe(self):
|
||||||
|
try:
|
||||||
|
iframe = self.driver.ele("@|title=recaptcha challenge expires in two minutes@|title=reCAPTCHA 验证任务将于 2 分钟后过期")
|
||||||
|
# logger.info(f"iframe: {iframe}")
|
||||||
|
if iframe:
|
||||||
|
self.challenge_iframe = iframe
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_11_refresh(self, check_ele):
|
||||||
|
for k, v in self.i11s.items():
|
||||||
|
if v.get("new"):
|
||||||
|
self.i11s[k]['new'] = False
|
||||||
|
|
||||||
|
check_ele = [i[0] for i in check_ele]
|
||||||
|
|
||||||
|
for idx in check_ele:
|
||||||
|
if idx not in self.i11s:
|
||||||
|
self.i11s[idx] = {'srcs': [], 'new': False}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
ele = self.challenge_iframe.ele('#rc-imageselect-target').ele(
|
||||||
|
f"xpath:{self.path_map_33[idx]}")
|
||||||
|
|
||||||
|
img_ele = ele.ele('.rc-image-tile-11', timeout=0.1)
|
||||||
|
if not img_ele:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
byte_data = img_ele.src()
|
||||||
|
b64_str = base64.b64encode(byte_data).decode()
|
||||||
|
|
||||||
|
if b64_str not in self.i11s[idx]['srcs']:
|
||||||
|
self.i11s[idx]['srcs'].append(b64_str)
|
||||||
|
self.i11s[idx]['new'] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
def click_answer(self, result, challenge_type):
|
||||||
|
if challenge_type == 4:
|
||||||
|
for x in result["results"][0]['result']:
|
||||||
|
self.challenge_iframe.ele('#rc-imageselect-target').ele(
|
||||||
|
f"xpath:{self.path_map_44[x]}").click()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# if not result["results"][0]['result']:
|
||||||
|
# try:
|
||||||
|
# image_bytes = base64.b64decode(self.challenge_44_img)
|
||||||
|
# name = str(uuid.uuid4())
|
||||||
|
# with open(rf"{name}.png",'wb') as f:
|
||||||
|
# f.write(image_bytes)
|
||||||
|
# except:
|
||||||
|
# pass
|
||||||
|
|
||||||
|
self.challenge_iframe.ele('#recaptcha-verify-button').click()
|
||||||
|
self.i11s.clear()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if challenge_type == 3:
|
||||||
|
found_ele = []
|
||||||
|
|
||||||
|
for res in result["results"]:
|
||||||
|
if res["result"].get('target_found'):
|
||||||
|
idx = int(res["image_id"])
|
||||||
|
self.challenge_iframe.ele('#rc-imageselect-target').ele(
|
||||||
|
f"xpath:{self.path_map_33[idx]}").click()
|
||||||
|
found_ele.append((idx, self.path_map_33[idx]))
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if found_ele:
|
||||||
|
if len(found_ele) <= 2 and self.challenge_i33_first:
|
||||||
|
self.challenge_iframe.ele('#recaptcha-reload-button').click()
|
||||||
|
return False
|
||||||
|
|
||||||
|
cls = self.challenge_iframe.ele('#rc-imageselect-target').ele(
|
||||||
|
f"xpath:{found_ele[0][1]}").attr('class')
|
||||||
|
if 'rc-imageselect-tileselected' in cls:
|
||||||
|
self.challenge_iframe.ele('#recaptcha-verify-button').click()
|
||||||
|
self.i11s.clear()
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.check_11_refresh(found_ele)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.challenge_iframe.ele('#recaptcha-verify-button').click()
|
||||||
|
self.i11s.clear()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def challenge_i33(self):
|
||||||
|
if len(self.challenge_iframe.eles('.rc-image-tile-33', timeout=1)) == 9:
|
||||||
|
self.challenge_i33_first = True
|
||||||
|
self.i11s.clear()
|
||||||
|
|
||||||
|
first_ele = self.challenge_iframe.eles('.rc-image-tile-33')[0]
|
||||||
|
byte_data = first_ele.src()
|
||||||
|
|
||||||
|
tiles = self.split_image(byte_data)
|
||||||
|
if tiles:
|
||||||
|
images = {i: t for i, t in enumerate(tiles)}
|
||||||
|
if res := self.identify_verification_code(images):
|
||||||
|
self.click_answer(res, 3)
|
||||||
|
else:
|
||||||
|
self.challenge_i33_first = False
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
for k, v in self.i11s.items():
|
||||||
|
if v['new']:
|
||||||
|
img_b64 = v['srcs'][-1]
|
||||||
|
data[k] = img_b64
|
||||||
|
if res := self.identify_verification_code(data):
|
||||||
|
self.click_answer(res, 3)
|
||||||
|
|
||||||
|
def challenge_i44(self):
|
||||||
|
ele = self.challenge_iframe.eles('.rc-image-tile-44')[0]
|
||||||
|
byte_data = ele.src()
|
||||||
|
b64_str = base64.b64encode(byte_data).decode()
|
||||||
|
self.challenge_44_img = b64_str
|
||||||
|
if res := self.identify_verification_code({0: b64_str}):
|
||||||
|
self.click_answer(res, 4)
|
||||||
|
def identify_verification_code(self, images):
|
||||||
|
data = {"images": []}
|
||||||
|
for k, img in images.items():
|
||||||
|
if img:
|
||||||
|
data["images"].append({
|
||||||
|
"image_id": str(k),
|
||||||
|
"image_base64": img,
|
||||||
|
"target_class": self.challenge_question
|
||||||
|
})
|
||||||
|
if data['images']:
|
||||||
|
res = requests.post(self.api_host, json=data)
|
||||||
|
return res.json()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def challenge(self):
|
||||||
|
if not self.find_checkbox_iframe():
|
||||||
|
return {"status": False, "message": "no verification code found"}
|
||||||
|
url_before = self.driver.url
|
||||||
|
# logger.info(f"url_before: {url_before}")
|
||||||
|
self.find_challenge_iframe()
|
||||||
|
if not self.challenge_iframe:
|
||||||
|
return {"status": False, "message": "no verification code found"}
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
if self.driver.url != url_before:
|
||||||
|
return {"status": True, "message": "验证码自动通过1"}
|
||||||
|
if self.checkbox_iframe.ele("#recaptcha-anchor").attr('aria-checked') == 'true':
|
||||||
|
return {"status": True, "message": "验证码自动通过2"}
|
||||||
|
# 兼容 ChromiumFrame 无 style() 方法:优先读取 style 属性,其次使用 JS 计算样式
|
||||||
|
vis = None
|
||||||
|
try:
|
||||||
|
style_str = self.challenge_iframe.attr('style') or ''
|
||||||
|
if 'visibility' in style_str:
|
||||||
|
vis = 'hidden' if 'visibility: hidden' in style_str.replace(' ', '') else 'visible'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if vis is None:
|
||||||
|
try:
|
||||||
|
# 通过 JS 获取 iframe 的可见性
|
||||||
|
vis = self.driver.run_js(
|
||||||
|
'var f = document.querySelector("iframe[title=\\"recaptcha challenge expires in two minutes\\"]") || document.querySelector("iframe[title=\\"reCAPTCHA 验证任务将于 2 分钟后过期\\"]");'
|
||||||
|
'f ? getComputedStyle(f).visibility : null;'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
vis = None
|
||||||
|
if vis != 'hidden':
|
||||||
|
break
|
||||||
|
# try:
|
||||||
|
# if self.driver.url != url_before:
|
||||||
|
# return {"status": True, "message": "验证码自动通过1"}
|
||||||
|
# if self.checkbox_iframe.ele("#recaptcha-anchor").attr('aria-checked') == 'true':
|
||||||
|
# return {"status": True, "message": "验证码自动通过2"}
|
||||||
|
# if self.challenge_iframe.style('visibility') != 'hidden':
|
||||||
|
# logger.info(222)
|
||||||
|
# break
|
||||||
|
# except:
|
||||||
|
# logger.error("challenge error")
|
||||||
|
# pass
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# 重复使用可见性判断,避免依赖不存在的 style()
|
||||||
|
vis = None
|
||||||
|
try:
|
||||||
|
style_str = self.challenge_iframe.attr('style') or ''
|
||||||
|
if 'visibility' in style_str:
|
||||||
|
vis = 'hidden' if 'visibility: hidden' in style_str.replace(' ', '') else 'visible'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if vis is None:
|
||||||
|
try:
|
||||||
|
vis = self.driver.run_js(
|
||||||
|
'var f = document.querySelector("iframe[title=\\"recaptcha challenge expires in two minutes\\"]") || document.querySelector("iframe[title=\\"reCAPTCHA 验证任务将于 2 分钟后过期\\"]");'
|
||||||
|
'f ? getComputedStyle(f).visibility : null;'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
vis = None
|
||||||
|
if vis == 'hidden':
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
if self.driver.url != url_before:
|
||||||
|
return {"status": True, "message": "captcha successfully resolved"}
|
||||||
|
if self.checkbox_iframe.ele("#recaptcha-anchor").attr('aria-checked') == 'true':
|
||||||
|
return {"status": True, "message": "captcha successfully resolved"}
|
||||||
|
# 获取题目
|
||||||
|
self.challenge_question = self.challenge_iframe.ele("tag:strong").text
|
||||||
|
|
||||||
|
# 判断 4×4
|
||||||
|
if self.challenge_iframe.ele('.rc-image-tile-44', timeout=0.1):
|
||||||
|
self.challenge_i44()
|
||||||
|
|
||||||
|
# 判断 3×3 或 1×1
|
||||||
|
elif self.challenge_iframe.ele('.rc-image-tile-33', timeout=0.1) or \
|
||||||
|
self.challenge_iframe.ele('.rc-image-tile-11', timeout=0.1):
|
||||||
|
self.challenge_i33()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return {"status": True, "message": "captcha successfully resolved"}
|
||||||
318
spider/bit_browser.py
Normal file
318
spider/bit_browser.py
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from loguru import logger
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
|
def retry(max_retries: int = 3, delay: float = 1.0, backoff: float = 1.0):
|
||||||
|
"""
|
||||||
|
通用重试装饰器
|
||||||
|
:param max_retries: 最大重试次数
|
||||||
|
:param delay: 每次重试的初始延迟(秒)
|
||||||
|
:param backoff: 每次重试延迟的递增倍数
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
retries = 0
|
||||||
|
current_delay = delay
|
||||||
|
while retries < max_retries:
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
retries += 1
|
||||||
|
if retries >= max_retries:
|
||||||
|
logger.warning(f"函数 {func.__name__} 在尝试了 {max_retries} 次后失败,错误信息: {e}")
|
||||||
|
return None # 重试次数用尽后返回 None
|
||||||
|
logger.warning(f"正在重试 {func.__name__} {retries + 1}/{max_retries} 因错误: {e}")
|
||||||
|
time.sleep(current_delay)
|
||||||
|
current_delay *= backoff
|
||||||
|
|
||||||
|
return None # 三次重试仍未成功,返回 None
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 比特浏览器模块
|
||||||
|
class BitBrowser:
|
||||||
|
def __init__(self):
|
||||||
|
self.bit_host = "http://127.0.0.1"
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 创建比特币浏览器
|
||||||
|
@retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
def bit_browser_create(self, remark: str = '指纹浏览器', ua: str = None, host: str = None, port: str = None,
|
||||||
|
proxy_user: str = None,
|
||||||
|
proxy_pwd: str = None, proxy_type: str = 'noproxy', urls: str = None,
|
||||||
|
bit_port: str = "54345") -> str:
|
||||||
|
"""
|
||||||
|
创建比特币浏览器
|
||||||
|
:param bit_port: 可选,默认54345
|
||||||
|
:param ua: 可选,默认随机
|
||||||
|
:param proxy_type: 代理类型 (可选) ['noproxy', 'http', 'https', 'socks5', 'ssh']
|
||||||
|
:param urls: 额外打开的url (可选) 多个用,分割
|
||||||
|
:param host: 代理IP地址 (可选)
|
||||||
|
:param port: 代理IP端口 (可选)
|
||||||
|
:param proxy_user: 代理账号 (可选)
|
||||||
|
:param proxy_pwd: 代理密码 (可选)
|
||||||
|
:param remark: 备注 (可选)
|
||||||
|
:param bit_port: 可选,默认54345
|
||||||
|
:return: 返回浏览器ID
|
||||||
|
"""
|
||||||
|
url = f"{self.bit_host}:{bit_port}/browser/update"
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
data = {
|
||||||
|
'name': f'{remark if len(remark) < 40 else remark[:40]}', # 窗口名称
|
||||||
|
'remark': f'{remark}', # 备注
|
||||||
|
'proxyMethod': 2, # 代理方式 2自定义 3 提取IP
|
||||||
|
# 代理类型 ['noproxy', 'http', 'https', 'socks5', 'ssh']
|
||||||
|
'proxyType': f'{proxy_type}',
|
||||||
|
"browserFingerPrint": {"userAgent": ua} # 留空,随机指纹
|
||||||
|
}
|
||||||
|
if host is not None:
|
||||||
|
data['host'] = host
|
||||||
|
if port is not None:
|
||||||
|
data['port'] = port
|
||||||
|
if proxy_user is not None:
|
||||||
|
data['proxyUserName'] = proxy_user
|
||||||
|
if proxy_pwd is not None:
|
||||||
|
data['proxyPassword'] = proxy_pwd
|
||||||
|
if urls is not None:
|
||||||
|
data['url'] = urls # 额外打开的url 多个用,分割
|
||||||
|
res = requests.post(url, json=data, headers=headers).json()
|
||||||
|
if not res.get('success'):
|
||||||
|
raise Exception(res)
|
||||||
|
browser_pk = res['data']['id']
|
||||||
|
return browser_pk
|
||||||
|
|
||||||
|
# 修改比特币浏览器
|
||||||
|
@retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
def bit_browser_update(self, pk: str, remark: str = None, proxyType: str = 'noproxy', host: str = None,
|
||||||
|
port: str = None, proxy_user: str = None, proxy_pwd: str = None, urls: str = None,
|
||||||
|
bit_port: str = "54345") -> bool:
|
||||||
|
"""
|
||||||
|
修改比特币浏览器 传入某个参数则修改某个参数
|
||||||
|
:param proxyType: 代理类型 noproxy|http|https|socks5(默认noproxy)
|
||||||
|
:param pk: # 浏览器ID
|
||||||
|
:param remark: # 备注
|
||||||
|
:param host: # 代理主机
|
||||||
|
:param port: # 代理端口
|
||||||
|
:param proxy_user: # 代理账号
|
||||||
|
:param proxy_pwd: # 代理密码
|
||||||
|
:param urls: # 额外打开的url 多个用,分割
|
||||||
|
:param bit_port: # 可选,默认54345
|
||||||
|
:return: bool
|
||||||
|
"""
|
||||||
|
url = f"{self.bit_host}:{bit_port}/browser/update/partial"
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
data = dict()
|
||||||
|
data['ids'] = [pk]
|
||||||
|
if remark is not None:
|
||||||
|
data['remark'] = remark
|
||||||
|
data['name'] = remark
|
||||||
|
if urls is not None:
|
||||||
|
data['url'] = urls
|
||||||
|
if proxyType != 'noproxy':
|
||||||
|
data['proxyType'] = proxyType
|
||||||
|
if host is not None:
|
||||||
|
data['host'] = host
|
||||||
|
if port is not None:
|
||||||
|
data['port'] = port if isinstance(port, int) else int(port)
|
||||||
|
if proxy_user is not None:
|
||||||
|
data['proxyUserName'] = proxy_user
|
||||||
|
if proxy_pwd is not None:
|
||||||
|
data['proxyPassword'] = proxy_pwd
|
||||||
|
res = requests.post(url, json=data, headers=headers).json()
|
||||||
|
if not res.get('success'):
|
||||||
|
raise Exception(res)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 打开比特币浏览器
|
||||||
|
@retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
def bit_browser_open(self, pk: str, bit_port: str = "54345") -> str:
|
||||||
|
"""
|
||||||
|
打开比特币浏览器
|
||||||
|
:param pk: 浏览器ID
|
||||||
|
:param bit_port: 可选,默认54345
|
||||||
|
:return: 返回浏览器地址
|
||||||
|
"""
|
||||||
|
url = f"{self.bit_host}:{bit_port}/browser/open"
|
||||||
|
data = {"id": f'{pk}'}
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
res = requests.post(url, json=data, headers=headers).json()
|
||||||
|
if not res.get('success'):
|
||||||
|
raise Exception(res)
|
||||||
|
debugger_address = res['data']['http']
|
||||||
|
return debugger_address
|
||||||
|
|
||||||
|
# 关闭比特币浏览器
|
||||||
|
@retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
def bit_browser_close(self, pk: str, bit_port: str = "54345"):
|
||||||
|
"""
|
||||||
|
关闭比特币浏览器 - 执行后需要等待5s
|
||||||
|
:param pk: 浏览器ID
|
||||||
|
:param bit_port: 可选,默认54345
|
||||||
|
:return: 无返回值
|
||||||
|
"""
|
||||||
|
url = f"{self.bit_host}:{bit_port}/browser/close"
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
data = {'id': f'{pk}'}
|
||||||
|
res = requests.post(url, json=data, headers=headers).json()
|
||||||
|
if not res.get('success'):
|
||||||
|
raise Exception(res)
|
||||||
|
# 等待3秒
|
||||||
|
time.sleep(3)
|
||||||
|
bol = self.bit_browser_status(pk)
|
||||||
|
if bol:
|
||||||
|
raise Exception(f'浏览器ID {pk} 未正常关闭, 等待3秒后重试')
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 删除比特币浏览器
|
||||||
|
@retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
def bit_browser_delete(self, pk: str, bit_port: str = "54345"):
|
||||||
|
"""
|
||||||
|
删除比特币浏览器
|
||||||
|
:param pk: 浏览器ID
|
||||||
|
:param bit_port: 可选,默认54345
|
||||||
|
:return: 无返回值
|
||||||
|
"""
|
||||||
|
url = f"{self.bit_host}:{bit_port}/browser/delete"
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
data = {'id': f'{pk}'}
|
||||||
|
res = requests.post(url, json=data, headers=headers).json()
|
||||||
|
if not res.get('success'):
|
||||||
|
raise Exception(res)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 获取所有比特币浏览器
|
||||||
|
@retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
def bit_browser_get(self, page: int = 0, limit: int = 10, group_id: str | None = None,
|
||||||
|
bit_port: str | None = "54345") -> dict:
|
||||||
|
"""
|
||||||
|
获取所有比特币浏览器
|
||||||
|
:param page: 页码
|
||||||
|
:param limit: 每页数量
|
||||||
|
:param group_id: 组ID(可选)
|
||||||
|
:param bit_port: 可选,默认54345
|
||||||
|
:return: {'success': True, 'data': {'page': 1, 'pageSize': 10, 'totalNum': 128, 'list': [{'id': '12a3126accc14c93bd34adcccfc3083c'},{'id':'edc5d61a56214e9f8a8bbf1a2e1b405d'}]}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = f"{self.bit_host}:{bit_port}/browser/list"
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
data = {'page': page, 'pageSize': limit}
|
||||||
|
if group_id is not None:
|
||||||
|
data['groupId'] = group_id
|
||||||
|
res = requests.post(url, json=data, headers=headers).json()
|
||||||
|
if not res.get('success'):
|
||||||
|
raise Exception(res)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 获取比特浏览器窗口详情
|
||||||
|
@retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
def bit_browser_detail(self, pk: str, bit_port: str = "54345") -> dict:
|
||||||
|
"""
|
||||||
|
获取比特浏览器窗口详情
|
||||||
|
:param pk: 浏览器ID
|
||||||
|
:param bit_port: 可选,默认54345
|
||||||
|
:return: {'success': True, 'data': {'id': '12a3126accc14c93bd34adcccfc3083c', 'name': '12a3126accc14c93bd34adcccfc3083c', 'remark': '12a3126accc14c93bd34adcccfc3083c', '
|
||||||
|
"""
|
||||||
|
url = f"{self.bit_host}:{bit_port}/browser/detail"
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
data = {'id': f'{pk}'}
|
||||||
|
res = requests.post(url, json=data, headers=headers).json()
|
||||||
|
if not res.get('success'):
|
||||||
|
raise Exception(res)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 获取比特浏览器的进程id
|
||||||
|
def bit_browser_pid(self, pk: str, bit_port: str = "54345") -> str:
|
||||||
|
"""
|
||||||
|
获取比特浏览器的进程id
|
||||||
|
:param pk: 浏览器ID
|
||||||
|
:param bit_port: 可选,默认54345
|
||||||
|
:return: 返回进程id
|
||||||
|
"""
|
||||||
|
url = f"{self.bit_host}:{bit_port}/browser/pids/alive"
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
data = {
|
||||||
|
"ids": [pk]
|
||||||
|
}
|
||||||
|
res = requests.post(url, json=data, headers=headers).json()
|
||||||
|
if not res.get('success'):
|
||||||
|
raise Exception(res)
|
||||||
|
return res['data'][pk]
|
||||||
|
|
||||||
|
# 获取窗口状态
|
||||||
|
@retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
def bit_browser_status(self, pk: str, bit_port: str = "54345") -> dict:
|
||||||
|
"""
|
||||||
|
获取比特浏览器窗口状态
|
||||||
|
:param pk: 浏览器ID
|
||||||
|
:param bit_port: 可选,默认54345
|
||||||
|
:return: {'success': True, 'data': {'id': '12a3126accc14c93bd34adcccfc3083c', 'name': '12a3126accc14c93bd34adcccfc3083c', 'remark': '12a3126accc14c93bd34adcccfc3083c', '
|
||||||
|
"""
|
||||||
|
url = f"{self.bit_host}:{bit_port}/browser/pids"
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
data = {'ids': [pk]}
|
||||||
|
res = requests.post(url, json=data, headers=headers).json()
|
||||||
|
# print(f'res --> {res}')
|
||||||
|
if not res.get('success'):
|
||||||
|
raise Exception(res)
|
||||||
|
if res.get('data').get(pk) is None:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
bit = BitBrowser()
|
||||||
|
# res = await bit._bit_browser_get()
|
||||||
|
jc = 0
|
||||||
|
while 1:
|
||||||
|
res = await bit._bit_browser_get(
|
||||||
|
page=jc,
|
||||||
|
limit=100,
|
||||||
|
group_id='4028808b9a52223a019a581bbea1275c')
|
||||||
|
li = res["data"]["list"]
|
||||||
|
if len(li) == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
for i in li:
|
||||||
|
id = i["id"]
|
||||||
|
# 读取浏览器详情
|
||||||
|
res = await bit._bit_browser_detail(id)
|
||||||
|
|
||||||
|
# print(f'id -->{id} --> {res}')
|
||||||
|
data = res["data"]
|
||||||
|
ua = data["browserFingerPrint"]["userAgent"]
|
||||||
|
proxy_type = data.get("proxyType")
|
||||||
|
host = data.get("host")
|
||||||
|
port = data.get("port")
|
||||||
|
proxy_account = data.get("proxyUserName")
|
||||||
|
proxy_password = data.get("proxyPassword")
|
||||||
|
print(f'id -->{id}')
|
||||||
|
print(f'ua -->{ua}')
|
||||||
|
print(f'proxy_type -->{proxy_type}')
|
||||||
|
print(f'host -->{host}')
|
||||||
|
print(f'port -->{port}')
|
||||||
|
print(f'proxy_account -->{proxy_account}')
|
||||||
|
print(f'proxy_password -->{proxy_password}')
|
||||||
|
print(f'='*50)
|
||||||
|
jc += 1
|
||||||
|
|
||||||
|
def main2():
|
||||||
|
bit = BitBrowser()
|
||||||
|
browser_id = '5ba9eb974c7c45e2bb086585c75f70e8'
|
||||||
|
# 关闭浏览器
|
||||||
|
# res = bit.bit_browser_close(browser_id)
|
||||||
|
# res = bit.bit_browser_get()
|
||||||
|
# print(res)
|
||||||
|
|
||||||
|
# if __name__ == '__main__':
|
||||||
|
# main2()
|
||||||
|
|
||||||
|
bit_browser = BitBrowser()
|
||||||
851
spider/mail_.py
Normal file
851
spider/mail_.py
Normal file
@@ -0,0 +1,851 @@
|
|||||||
|
import asyncio
|
||||||
|
import imaplib
|
||||||
|
import email
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
from email.header import decode_header
|
||||||
|
from datetime import timezone, timedelta
|
||||||
|
import email.utils
|
||||||
|
import aiohttp
|
||||||
|
import socks
|
||||||
|
import requests
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.header import Header
|
||||||
|
from functools import wraps
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
def retry(max_retries: int = 3, delay: float = 1.0, backoff: float = 1.0):
|
||||||
|
"""
|
||||||
|
通用重试装饰器
|
||||||
|
:param max_retries: 最大重试次数
|
||||||
|
:param delay: 每次重试的初始延迟(秒)
|
||||||
|
:param backoff: 每次重试延迟的递增倍数
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
retries = 0
|
||||||
|
current_delay = delay
|
||||||
|
while retries < max_retries:
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
retries += 1
|
||||||
|
if retries >= max_retries:
|
||||||
|
logger.warning(f"函数 {func.__name__} 在尝试了 {max_retries} 次后失败,错误信息: {e}")
|
||||||
|
return None # 重试次数用尽后返回 None
|
||||||
|
logger.warning(f"正在重试 {func.__name__} {retries + 1}/{max_retries} 因错误: {e}")
|
||||||
|
time.sleep(current_delay)
|
||||||
|
current_delay *= backoff
|
||||||
|
|
||||||
|
return None # 三次重试仍未成功,返回 None
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def async_retry(max_retries: int = 3, delay: float = 1.0, backoff: float = 1.0):
|
||||||
|
"""
|
||||||
|
支持异步函数的通用重试装饰器
|
||||||
|
:param max_retries: 最大重试次数
|
||||||
|
:param delay: 每次重试的初始延迟(秒)
|
||||||
|
:param backoff: 每次重试延迟的递增倍数
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
retries = 0
|
||||||
|
current_delay = delay
|
||||||
|
while retries < max_retries:
|
||||||
|
try:
|
||||||
|
return await func(*args, **kwargs) # 直接执行原始方法
|
||||||
|
except Exception as e:
|
||||||
|
retries += 1
|
||||||
|
if retries >= max_retries:
|
||||||
|
logger.warning(f"函数 {func.__name__} 在尝试了 {max_retries} 次后失败,错误信息: {e}")
|
||||||
|
return None # 重试次数用尽后返回 None
|
||||||
|
logger.warning(f"正在重试 {func.__name__} {retries + 1}/{max_retries} 因错误: {e}")
|
||||||
|
|
||||||
|
await asyncio.sleep(current_delay) # 异步延迟
|
||||||
|
current_delay *= backoff # 根据backoff递增延迟
|
||||||
|
|
||||||
|
return None # 三次重试仍未成功,返回 None
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# 域名管理类 - 高内聚低耦合的域名管理方案
|
||||||
|
class DomainManager:
|
||||||
|
"""
|
||||||
|
域名管理器 - 统一管理所有邮箱域名相关操作
|
||||||
|
实现高内聚低耦合的设计原则
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 域名列表 - 只需要在这里添加新域名
|
||||||
|
self._domains = [
|
||||||
|
"gmail.com",
|
||||||
|
"qianyouduo.com",
|
||||||
|
"rxybb.com",
|
||||||
|
"cqrxy.vip",
|
||||||
|
"0n.lv",
|
||||||
|
"qianyouduo.com",
|
||||||
|
"ziyouzuan.com",
|
||||||
|
"emaing.online",
|
||||||
|
"emaing.fun",
|
||||||
|
"emaing.asia",
|
||||||
|
"isemaing.site",
|
||||||
|
"emaing.cyou",
|
||||||
|
"emaing.site",
|
||||||
|
"emaing.icu",
|
||||||
|
"emaing.store",
|
||||||
|
"emaing.pw",
|
||||||
|
"emaing.xyz",
|
||||||
|
"qydkjgs.asia",
|
||||||
|
"qydkj.homes",
|
||||||
|
"qydkj.baby",
|
||||||
|
"qydkj.cyou",
|
||||||
|
"qydkjgs.autos",
|
||||||
|
"qydkj.autos",
|
||||||
|
"qydkjgs.cyou",
|
||||||
|
"qydkjgs.homes",
|
||||||
|
"qydgs.asia",
|
||||||
|
"qydkj.asia",
|
||||||
|
"qydgs.cyou",
|
||||||
|
"lulanjing.asia",
|
||||||
|
"lisihan.asia",
|
||||||
|
"mmwan.asia",
|
||||||
|
"xyttan.asia",
|
||||||
|
"zpaily.asia",
|
||||||
|
"youxinzhiguo.asia",
|
||||||
|
"huijinfenmu.asia",
|
||||||
|
"linghao.asia",
|
||||||
|
"cqhc.asia",
|
||||||
|
"huacun.asia",
|
||||||
|
"huachen.asia",
|
||||||
|
"yisabeier.asia",
|
||||||
|
"xinxinr.cyou",
|
||||||
|
"lilisi.asia",
|
||||||
|
"xybbwan.cyou",
|
||||||
|
"zhongjing.cyou",
|
||||||
|
"zprxy.cyou",
|
||||||
|
"cqhuacun.cyou",
|
||||||
|
"huazong.icu",
|
||||||
|
"huacun.cyou"
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_domain_by_type(self, mail_type: int) -> str:
|
||||||
|
"""
|
||||||
|
根据邮箱类型获取域名
|
||||||
|
:param mail_type: 邮箱类型编号
|
||||||
|
:return: 对应的域名
|
||||||
|
"""
|
||||||
|
if 0 <= mail_type < len(self._domains):
|
||||||
|
return self._domains[mail_type]
|
||||||
|
return self._domains[1] # 默认返回 qianyouduo.com
|
||||||
|
|
||||||
|
def get_domain_type(self, domain: str) -> int:
|
||||||
|
"""
|
||||||
|
根据域名获取类型编号
|
||||||
|
:param domain: 域名
|
||||||
|
:return: 对应的类型编号,如果不存在返回1
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self._domains.index(domain)
|
||||||
|
except ValueError:
|
||||||
|
return 1 # 默认返回 qianyouduo.com 的类型
|
||||||
|
|
||||||
|
def get_imap_server(self, mail_type: int) -> str:
|
||||||
|
"""
|
||||||
|
根据邮箱类型获取IMAP服务器地址
|
||||||
|
:param mail_type: 邮箱类型编号
|
||||||
|
:return: IMAP服务器地址
|
||||||
|
"""
|
||||||
|
domain = self.get_domain_by_type(mail_type)
|
||||||
|
return f"imap.{domain}"
|
||||||
|
|
||||||
|
def get_imap_server_by_domain(self, domain: str) -> str:
|
||||||
|
"""
|
||||||
|
根据域名获取IMAP服务器地址
|
||||||
|
:param domain: 域名
|
||||||
|
:return: IMAP服务器地址
|
||||||
|
"""
|
||||||
|
return f"imap.{domain}"
|
||||||
|
|
||||||
|
def is_valid_domain(self, domain: str) -> bool:
|
||||||
|
"""
|
||||||
|
检查域名是否在支持列表中
|
||||||
|
:param domain: 域名
|
||||||
|
:return: 是否支持该域名
|
||||||
|
"""
|
||||||
|
return domain in self._domains
|
||||||
|
|
||||||
|
def get_all_domains(self) -> list:
|
||||||
|
"""
|
||||||
|
获取所有支持的域名列表
|
||||||
|
:return: 域名列表的副本
|
||||||
|
"""
|
||||||
|
return self._domains.copy()
|
||||||
|
|
||||||
|
def get_domain_count(self) -> int:
|
||||||
|
"""
|
||||||
|
获取支持的域名总数
|
||||||
|
:return: 域名总数
|
||||||
|
"""
|
||||||
|
return len(self._domains)
|
||||||
|
|
||||||
|
def get_creatable_domains(self) -> list:
|
||||||
|
"""
|
||||||
|
获取可用于创建邮箱的域名列表(排除gmail.com)
|
||||||
|
:return: 可创建邮箱的域名列表
|
||||||
|
"""
|
||||||
|
return [domain for domain in self._domains if domain != "gmail.com"]
|
||||||
|
|
||||||
|
def get_creatable_domain_by_type(self, mail_type: int) -> str:
|
||||||
|
"""
|
||||||
|
根据邮箱类型获取可创建的域名(排除gmail.com)
|
||||||
|
:param mail_type: 邮箱类型编号
|
||||||
|
:return: 对应的域名,如果是gmail.com则返回默认域名
|
||||||
|
"""
|
||||||
|
domain = self.get_domain_by_type(mail_type)
|
||||||
|
if domain == "gmail.com":
|
||||||
|
return self._domains[1] # 返回qianyouduo.com作为默认
|
||||||
|
return domain
|
||||||
|
|
||||||
|
def get_random_creatable_domain(self) -> str:
|
||||||
|
"""
|
||||||
|
随机获取一个可创建邮箱的域名(排除 gmail.com)
|
||||||
|
|
||||||
|
返回值:
|
||||||
|
str: 随机选取的域名
|
||||||
|
"""
|
||||||
|
creatable = self.get_creatable_domains()
|
||||||
|
if not creatable:
|
||||||
|
raise ValueError("无可用域名用于创建邮箱")
|
||||||
|
return random.choice(creatable)
|
||||||
|
|
||||||
|
|
||||||
|
# 邮箱模块
|
||||||
|
class Mail:
|
||||||
|
def __init__(self):
|
||||||
|
self.domain_manager = DomainManager()
|
||||||
|
self.api_host = 'http://111.10.175.206:5020'
|
||||||
|
|
||||||
|
def email_account_read(self, pk: int = None, account: str = None, status: bool = None, host: str = None,
|
||||||
|
proxy_account: str = None,
|
||||||
|
parent_account: str = None, order_by: str = None, level: int = None,
|
||||||
|
update_time_start: str = None, update_time_end: str = None, res_count: bool = False,
|
||||||
|
create_time_start: str = None, create_time_end: str = None, page: int = None,
|
||||||
|
limit: int = None) -> dict:
|
||||||
|
"""
|
||||||
|
读取mail账号
|
||||||
|
:param level: 邮箱等级(可选)
|
||||||
|
:param status: 状态(可选)
|
||||||
|
:param update_time_start: 更新时间起始(可选)
|
||||||
|
:param update_time_end: 更新时间结束(可选)
|
||||||
|
:param res_count: 返回总数 (可选)
|
||||||
|
:param parent_account: 母邮箱账号 (可选)
|
||||||
|
:param pk: 主键 (可选)
|
||||||
|
:param account: 账号 (可选)
|
||||||
|
:param host: 代理 (可选)
|
||||||
|
:param proxy_account: 代理账号 (可选)
|
||||||
|
:param order_by: 排序方式 (可选) id|create_time|update_time 前面加-表示倒序
|
||||||
|
:param create_time_start: 创建起始时间 (可选)
|
||||||
|
:param create_time_end: 创建结束时间 (可选)
|
||||||
|
:param page: 页码 (可选)
|
||||||
|
:param limit: 每页数量 (可选)
|
||||||
|
:return: 返回json 成功字段code=200
|
||||||
|
"""
|
||||||
|
if pk is not None:
|
||||||
|
url = f'{self.api_host}/mail/account/{pk}'
|
||||||
|
return requests.get(url).json()
|
||||||
|
|
||||||
|
url = f'{self.api_host}/mail/account'
|
||||||
|
data = dict()
|
||||||
|
if account is not None:
|
||||||
|
data['account'] = account
|
||||||
|
if status is not None:
|
||||||
|
data['status'] = status
|
||||||
|
if host is not None:
|
||||||
|
data['host'] = host
|
||||||
|
if proxy_account is not None:
|
||||||
|
data['proxy_account'] = proxy_account
|
||||||
|
if parent_account is not None:
|
||||||
|
data['parent_account'] = parent_account
|
||||||
|
if order_by is not None:
|
||||||
|
data['order_by'] = order_by
|
||||||
|
if level is not None:
|
||||||
|
data['level'] = level
|
||||||
|
if create_time_start is not None:
|
||||||
|
data['create_time_start'] = create_time_start
|
||||||
|
if create_time_end is not None:
|
||||||
|
data['create_time_end'] = create_time_end
|
||||||
|
if update_time_start is not None:
|
||||||
|
data['update_time_start'] = update_time_start
|
||||||
|
if update_time_end is not None:
|
||||||
|
data['update_time_end'] = update_time_end
|
||||||
|
if res_count:
|
||||||
|
data['res_count'] = res_count
|
||||||
|
if page is not None:
|
||||||
|
data['page'] = page
|
||||||
|
if limit is not None:
|
||||||
|
data['limit'] = limit
|
||||||
|
res = requests.get(url, params=data).json()
|
||||||
|
if res.get('code') not in [200, 400, 404]:
|
||||||
|
raise Exception(res)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 创建随机邮箱
|
||||||
|
@retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
def email_create_random(self, count: int = 8, pwd: str = 'Zpaily88', mail_type: int | None = None) -> str:
|
||||||
|
"""
|
||||||
|
创建随机邮箱(随机域名,排除 gmail.com)
|
||||||
|
:param count: 邮箱长度(默认8位)
|
||||||
|
:param pwd: 邮箱密码(默认Zpaily88)
|
||||||
|
:param mail_type: 指定邮箱类型编号;为 None 时随机选择可创建域名
|
||||||
|
:return: 邮箱账号
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
|
"Authorization": "Basic YWRtaW5AcWlhbnlvdWR1by5jb206WnBhaWx5ODgh",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Origin": "https://mail.qianyouduo.com",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Referer": "https://mail.qianyouduo.com/admin/api/doc",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||||
|
"accept": "*/*",
|
||||||
|
"sec-ch-ua": "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"",
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": "\"macOS\""
|
||||||
|
}
|
||||||
|
url = "https://mail.qianyouduo.com/admin/api/v1/boxes"
|
||||||
|
name = ''.join(random.choices(string.ascii_letters + string.digits, k=count)).lower()
|
||||||
|
|
||||||
|
# 随机选择可创建域名(排除 gmail.com);如指定类型则按类型选择
|
||||||
|
mail_end = (
|
||||||
|
self.domain_manager.get_creatable_domain_by_type(mail_type)
|
||||||
|
if mail_type is not None
|
||||||
|
else self.domain_manager.get_random_creatable_domain()
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"name": name,
|
||||||
|
"email": f"{name}@{mail_end}",
|
||||||
|
"passwordPlaintext": pwd
|
||||||
|
}
|
||||||
|
response = requests.post(url, headers=headers, json=data)
|
||||||
|
if 'Validation errors: [user] This combination of username and domain is already in database' in response.text:
|
||||||
|
return f'{name}@{mail_end}'
|
||||||
|
if response.status_code != 201:
|
||||||
|
raise Exception(response.status_code)
|
||||||
|
return f"{name}@{mail_end}"
|
||||||
|
|
||||||
|
# 异步创建随机邮箱
|
||||||
|
@async_retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
async def _email_create_random(self, count: int = 8, pwd: str = 'Zpaily88', mail_type: int | None = None) -> str:
|
||||||
|
"""
|
||||||
|
创建随机邮箱(随机域名,排除 gmail.com)
|
||||||
|
:param count: 邮箱长度(默认8位)
|
||||||
|
:param pwd: 邮箱密码(默认Zpaily88)
|
||||||
|
:param mail_type: 指定邮箱类型编号;为 None 时随机选择可创建域名
|
||||||
|
:return:邮箱账号
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
|
"Authorization": "Basic YWRtaW5AcWlhbnlvdWR1by5jb206WnBhaWx5ODgh",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Origin": "https://mail.qianyouduo.com",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Referer": "https://mail.qianyouduo.com/admin/api/doc",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||||
|
"accept": "*/*",
|
||||||
|
"sec-ch-ua": "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"",
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": "\"macOS\""
|
||||||
|
}
|
||||||
|
url = "https://mail.qianyouduo.com/admin/api/v1/boxes"
|
||||||
|
name = ''.join(random.choices(string.ascii_letters + string.digits, k=count)).lower()
|
||||||
|
|
||||||
|
# 随机选择可创建域名(排除 gmail.com);如指定类型则按类型选择
|
||||||
|
mail_end = (
|
||||||
|
self.domain_manager.get_creatable_domain_by_type(mail_type)
|
||||||
|
if mail_type is not None
|
||||||
|
else self.domain_manager.get_random_creatable_domain()
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"name": name,
|
||||||
|
"email": f"{name}@{mail_end}",
|
||||||
|
"passwordPlaintext": pwd
|
||||||
|
}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(url, headers=headers, json=data) as response:
|
||||||
|
status = response.status
|
||||||
|
text = await response.text()
|
||||||
|
if 'Validation errors: [user] This combination of username and domain is already in database' in text:
|
||||||
|
return f"{name}@{mail_end}"
|
||||||
|
if status != 201:
|
||||||
|
raise Exception(status)
|
||||||
|
return f"{name}@{mail_end}"
|
||||||
|
|
||||||
|
# 创建邮箱
|
||||||
|
@retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
def email_create(self, account: str, pwd: str = 'Zpaily88') -> str | None:
|
||||||
|
"""
|
||||||
|
创建邮箱
|
||||||
|
:param account: 邮箱账号
|
||||||
|
:param pwd: 邮箱密码(默认Zpaily88)
|
||||||
|
:return:邮箱账号
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
|
"Authorization": "Basic YWRtaW5AcWlhbnlvdWR1by5jb206WnBhaWx5ODgh",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Origin": "https://mail.qianyouduo.com",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Referer": "https://mail.qianyouduo.com/admin/api/doc",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||||
|
"accept": "*/*",
|
||||||
|
"sec-ch-ua": "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"",
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": "\"macOS\""
|
||||||
|
}
|
||||||
|
url = "https://mail.qianyouduo.com/admin/api/v1/boxes"
|
||||||
|
name = account.split('@')[0]
|
||||||
|
mail_end = account.split('@')[1]
|
||||||
|
|
||||||
|
# 排除gmail.com域名
|
||||||
|
if mail_end == "gmail.com":
|
||||||
|
return None
|
||||||
|
# 验证域名是否支持
|
||||||
|
if not self.domain_manager.is_valid_domain(mail_end):
|
||||||
|
raise ValueError(f"不支持的域名: {mail_end},支持的域名列表: {self.domain_manager.get_all_domains()}")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"name": name,
|
||||||
|
"email": f"{name}@{mail_end}",
|
||||||
|
"passwordPlaintext": pwd
|
||||||
|
}
|
||||||
|
response = requests.post(url, headers=headers, json=data)
|
||||||
|
print(f'创建邮箱响应: {response.status_code}')
|
||||||
|
if response.status_code not in [201, 400]:
|
||||||
|
raise Exception(response.status_code)
|
||||||
|
return f"{name}@{mail_end}"
|
||||||
|
|
||||||
|
# 异步创建邮箱
|
||||||
|
@async_retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
async def _email_create(self, account: str, pwd: str = 'Zpaily88') -> str | None:
|
||||||
|
"""
|
||||||
|
创建邮箱
|
||||||
|
:param account: 邮箱账号
|
||||||
|
:param pwd: 邮箱密码(默认Zpaily88)
|
||||||
|
:return: 邮箱账号
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
|
"Authorization": "Basic YWRtaW5AcWlhbnlvdWR1by5jb206WnBhaWx5ODgh",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Origin": "https://mail.qianyouduo.com",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Referer": "https://mail.qianyouduo.com/admin/api/doc",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||||
|
"accept": "*/*",
|
||||||
|
"sec-ch-ua": "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"",
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": "\"macOS\""
|
||||||
|
}
|
||||||
|
url = "https://mail.qianyouduo.com/admin/api/v1/boxes"
|
||||||
|
name = account.split('@')[0]
|
||||||
|
mail_end = account.split('@')[1]
|
||||||
|
# 排除gmail.com域名
|
||||||
|
if mail_end == "gmail.com":
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 验证域名是否支持
|
||||||
|
if not self.domain_manager.is_valid_domain(mail_end):
|
||||||
|
raise ValueError(f"不支持的域名: {mail_end},支持的域名列表: {self.domain_manager.get_all_domains()}")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"name": name,
|
||||||
|
"email": f"{name}@{mail_end}",
|
||||||
|
"passwordPlaintext": pwd
|
||||||
|
}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(url, headers=headers, json=data) as response:
|
||||||
|
status = response.status
|
||||||
|
if status not in [201, 400]:
|
||||||
|
raise Exception(f'status code: {status}')
|
||||||
|
return f"{name}@{mail_end}"
|
||||||
|
|
||||||
|
# 删除邮箱
|
||||||
|
@retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
def email_delete(self, account: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除邮箱
|
||||||
|
:param account: 邮箱账号
|
||||||
|
:return: True表示删除成功,False表示删除失败
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
|
"Authorization": "Basic YWRtaW5AcWlhbnlvdWR1by5jb206WnBhaWx5ODgh",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Origin": "https://mail.qianyouduo.com",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Referer": "https://mail.qianyouduo.com/admin/api/doc",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||||
|
"accept": "*/*",
|
||||||
|
"sec-ch-ua": "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"",
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": "\"macOS\""
|
||||||
|
}
|
||||||
|
url = f"https://mail.qianyouduo.com/admin/api/v1/boxes/{account}"
|
||||||
|
if '@gmail.com' in account:
|
||||||
|
return False
|
||||||
|
response = requests.delete(url, headers=headers)
|
||||||
|
print(f'删除邮箱响应: --> {response.status_code}')
|
||||||
|
if response.status_code not in [204, 404]:
|
||||||
|
raise Exception(response.status_code)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 异步删除邮箱
|
||||||
|
@async_retry(max_retries=3, delay=1.0, backoff=1.0)
|
||||||
|
async def _email_delete(self, account: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除邮箱
|
||||||
|
:param account: 邮箱账号
|
||||||
|
:return: True表示删除成功,False表示删除失败
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
|
"Authorization": "Basic YWRtaW5AcWlhbnlvdWR1by5jb206WnBhaWx5ODgh",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Origin": "https://mail.qianyouduo.com",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Referer": "https://mail.qianyouduo.com/admin/api/doc",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||||
|
"accept": "*/*",
|
||||||
|
"sec-ch-ua": "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"",
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": "\"macOS\""
|
||||||
|
}
|
||||||
|
url = f"https://mail.qianyouduo.com/admin/api/v1/boxes/{account}"
|
||||||
|
if '@gmail.com' in account:
|
||||||
|
return False
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.delete(url, headers=headers) as response:
|
||||||
|
status = response.status
|
||||||
|
if status not in [204, 404]:
|
||||||
|
raise Exception(f'status code: {status}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 处理邮件正文
|
||||||
|
@staticmethod
|
||||||
|
def extract_body(msg):
|
||||||
|
"""
|
||||||
|
提取邮件正文,优先返回 HTML 文本
|
||||||
|
- 更健壮的字符集解析:优先使用 part 的 charset 信息,失败回退到 utf-8 / latin-1
|
||||||
|
- 仅处理 inline 的 text/html 与 text/plain 内容
|
||||||
|
"""
|
||||||
|
html_text = None
|
||||||
|
plain_text = None
|
||||||
|
|
||||||
|
def _decode_part(part):
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
# 优先从内容中解析 charset
|
||||||
|
charset = (part.get_content_charset() or part.get_param('charset') or 'utf-8')
|
||||||
|
try:
|
||||||
|
return payload.decode(charset, errors='replace')
|
||||||
|
except LookupError:
|
||||||
|
# 未知编码时回退
|
||||||
|
try:
|
||||||
|
return payload.decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
return payload.decode('latin-1', errors='replace')
|
||||||
|
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
content_disposition = part.get_content_disposition()
|
||||||
|
|
||||||
|
if content_type == "text/html" and (not content_disposition or content_disposition == "inline"):
|
||||||
|
html_text = _decode_part(part) or html_text
|
||||||
|
elif content_type == "text/plain" and (not content_disposition or content_disposition == "inline"):
|
||||||
|
plain_text = _decode_part(part) or plain_text
|
||||||
|
else:
|
||||||
|
content_type = msg.get_content_type()
|
||||||
|
if content_type == "text/html":
|
||||||
|
html_text = _decode_part(msg)
|
||||||
|
elif content_type == "text/plain":
|
||||||
|
plain_text = _decode_part(msg)
|
||||||
|
|
||||||
|
# 优先返回 HTML 文本,如果没有 HTML 文本,则返回纯文本
|
||||||
|
return html_text or plain_text or ""
|
||||||
|
|
||||||
|
# 转换邮件日期
|
||||||
|
@staticmethod
|
||||||
|
def convert_to_china_time(date_str):
|
||||||
|
"""
|
||||||
|
将邮件日期转换为10位时间戳(中国时区)
|
||||||
|
- 保留原始邮件的时区信息;若无时区,则按 UTC 处理
|
||||||
|
- 异常时返回当前时间戳,避免解析失败导致崩溃
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
email_date = email.utils.parsedate_to_datetime(date_str)
|
||||||
|
if email_date is None:
|
||||||
|
return int(time.time())
|
||||||
|
if email_date.tzinfo is None:
|
||||||
|
email_date = email_date.replace(tzinfo=timezone.utc)
|
||||||
|
china_time = email_date.astimezone(timezone(timedelta(hours=8)))
|
||||||
|
return int(china_time.timestamp())
|
||||||
|
except Exception:
|
||||||
|
return int(time.time())
|
||||||
|
|
||||||
|
# 获取邮件
|
||||||
|
def email_read(self, user: str, from_: str, limit: int = 1, is_del: bool = False) -> list | None:
|
||||||
|
"""
|
||||||
|
获取最新邮件
|
||||||
|
:param user: 母账号
|
||||||
|
:param from_: 发件人匹配关键字(可为邮箱或显示名,大小写不敏感)
|
||||||
|
:param limit: 获取邮件数量(默认1封)
|
||||||
|
:param is_del: 是否删除整个邮箱账号(非 Gmail 才会执行账号删除)
|
||||||
|
:return: 返回邮件列表,每个元素格式为:
|
||||||
|
{
|
||||||
|
"title": "邮件标题",
|
||||||
|
"from": "发件人",
|
||||||
|
"date": "邮件日期(中国时区时间戳)",
|
||||||
|
"content": "邮件正文",
|
||||||
|
"code": 200
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
user_li = user.split('@')
|
||||||
|
domain = user_li[1]
|
||||||
|
|
||||||
|
# 使用域名管理器获取邮箱类型
|
||||||
|
if not self.domain_manager.is_valid_domain(domain):
|
||||||
|
return None
|
||||||
|
|
||||||
|
mail_type = self.domain_manager.get_domain_type(domain)
|
||||||
|
# 仅对 Gmail 进行点号归一化,其它域名按原样处理
|
||||||
|
local_part = user_li[0]
|
||||||
|
if domain == "gmail.com":
|
||||||
|
local_part = local_part.replace('.', '')
|
||||||
|
user = local_part + '@' + user_li[1]
|
||||||
|
proxy_host = None
|
||||||
|
proxy_port = None
|
||||||
|
proxy_user = None
|
||||||
|
proxy_pwd = None
|
||||||
|
if mail_type == 0:
|
||||||
|
res = self.email_account_read(parent_account=user, status=True, level=0)
|
||||||
|
if res['code'] != 200:
|
||||||
|
return None
|
||||||
|
pwd = res['items'][0]['parent_pwd']
|
||||||
|
proxy_host = res['items'][0]['host']
|
||||||
|
proxy_port = res['items'][0]['port']
|
||||||
|
proxy_user = res['items'][0]['proxy_account']
|
||||||
|
proxy_pwd = res['items'][0]['proxy_pwd']
|
||||||
|
else:
|
||||||
|
pwd = 'Zpaily88'
|
||||||
|
|
||||||
|
items = [] # 存储邮件列表
|
||||||
|
|
||||||
|
# 保存原始socket
|
||||||
|
original_socket = None
|
||||||
|
if proxy_host is not None and proxy_port is not None:
|
||||||
|
original_socket = socket.socket
|
||||||
|
if proxy_user is not None and proxy_pwd is not None:
|
||||||
|
socks.setdefaultproxy(socks.SOCKS5, proxy_host, int(proxy_port), True, proxy_user, proxy_pwd)
|
||||||
|
else:
|
||||||
|
socks.setdefaultproxy(socks.SOCKS5, proxy_host, int(proxy_port), True)
|
||||||
|
socket.socket = socks.socksocket
|
||||||
|
|
||||||
|
imap_server = None
|
||||||
|
had_error = False
|
||||||
|
try:
|
||||||
|
# 在设置代理后创建IMAP连接
|
||||||
|
imap_server = imaplib.IMAP4_SSL(self.domain_manager.get_imap_server(mail_type))
|
||||||
|
if not imap_server:
|
||||||
|
had_error = True
|
||||||
|
else:
|
||||||
|
|
||||||
|
# pwd去除空格
|
||||||
|
pwd = pwd.replace(' ', '')
|
||||||
|
# print(f'pwd: {pwd}')
|
||||||
|
imap_server.login(user, pwd)
|
||||||
|
status, _ = imap_server.select("INBOX")
|
||||||
|
if status != 'OK':
|
||||||
|
had_error = True
|
||||||
|
else:
|
||||||
|
status, email_ids = imap_server.search(None, "ALL")
|
||||||
|
if status != 'OK':
|
||||||
|
had_error = True
|
||||||
|
else:
|
||||||
|
email_id_list = email_ids[0].split()
|
||||||
|
|
||||||
|
# 获取最近limit条邮件ID
|
||||||
|
recent_ids = email_id_list[-20:] # 仍然获取最近20封以确保有足够的邮件可以筛选
|
||||||
|
found_count = 0 # 记录找到的符合条件的邮件数量
|
||||||
|
|
||||||
|
for email_id in recent_ids[::-1]: # 从最新的邮件开始处理
|
||||||
|
if found_count >= limit: # 如果已经找到足够数量的邮件,就退出循环
|
||||||
|
break
|
||||||
|
|
||||||
|
status, msg_data = imap_server.fetch(email_id, "(RFC822)")
|
||||||
|
for response in msg_data:
|
||||||
|
if isinstance(response, tuple):
|
||||||
|
msg = email.message_from_bytes(response[1])
|
||||||
|
# 兼容性发件人匹配:解析地址与显示名,大小写不敏感,支持子串匹配
|
||||||
|
from_field = msg.get("From", "")
|
||||||
|
addresses = email.utils.getaddresses([from_field])
|
||||||
|
needle = (from_ or "").lower()
|
||||||
|
candidates = []
|
||||||
|
for name, addr in addresses:
|
||||||
|
if name:
|
||||||
|
candidates.append(name.lower())
|
||||||
|
if addr:
|
||||||
|
candidates.append(addr.lower())
|
||||||
|
if any(needle in c for c in candidates):
|
||||||
|
# 标题解码,处理无标题或编码缺失的情况
|
||||||
|
raw_subject = msg.get("Subject")
|
||||||
|
subject = ""
|
||||||
|
if raw_subject is not None:
|
||||||
|
dh = decode_header(raw_subject)
|
||||||
|
if dh:
|
||||||
|
s, enc = dh[0]
|
||||||
|
if isinstance(s, bytes):
|
||||||
|
try:
|
||||||
|
subject = s.decode(enc or 'utf-8', errors='replace')
|
||||||
|
except LookupError:
|
||||||
|
subject = s.decode('utf-8', errors='replace')
|
||||||
|
else:
|
||||||
|
subject = s
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"title": subject,
|
||||||
|
"from": msg["From"],
|
||||||
|
"content": self.extract_body(msg),
|
||||||
|
"code": 200
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取并转换邮件时间
|
||||||
|
date_str = msg["Date"]
|
||||||
|
if date_str:
|
||||||
|
item["date"] = self.convert_to_china_time(date_str)
|
||||||
|
|
||||||
|
items.append(item)
|
||||||
|
found_count += 1
|
||||||
|
|
||||||
|
if found_count >= limit: # 如果已经找到足够数量的邮件,就跳出内层循环
|
||||||
|
break
|
||||||
|
|
||||||
|
# 读取完成不再对单封邮件做删除标记与 expunge
|
||||||
|
|
||||||
|
except imaplib.IMAP4.error as e:
|
||||||
|
# items.append({'title': 'error', 'from': 'error', 'content': f'连接邮箱失败: {e}', 'code': 500})
|
||||||
|
had_error = True
|
||||||
|
except Exception as e:
|
||||||
|
# items.append({'title': 'error', 'from': 'error', 'content': f'获取邮件异常: {e}', 'code': 500})
|
||||||
|
had_error = True
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
# 检查连接是否建立
|
||||||
|
if 'imap_server' in locals() and imap_server is not None:
|
||||||
|
try:
|
||||||
|
# 先检查是否处于已选择状态
|
||||||
|
if hasattr(imap_server, 'state') and imap_server.state == 'SELECTED':
|
||||||
|
imap_server.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"关闭IMAP文件夹时发生错误: {e}")
|
||||||
|
try:
|
||||||
|
# 无论如何尝试登出
|
||||||
|
imap_server.logout()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"登出IMAP服务器时发生错误: {e}")
|
||||||
|
# 在Windows上可能需要强制关闭socket
|
||||||
|
try:
|
||||||
|
if hasattr(imap_server, 'sock') and imap_server.sock is not None:
|
||||||
|
imap_server.sock.close()
|
||||||
|
except Exception as sock_err:
|
||||||
|
logger.error(f"强制关闭socket时发生错误: {sock_err}")
|
||||||
|
except Exception as outer_e:
|
||||||
|
logger.error(f"处理IMAP连接关闭时发生错误: {outer_e}")
|
||||||
|
finally:
|
||||||
|
# 重置socket设置(如果使用了代理)
|
||||||
|
if proxy_host is not None and original_socket is not None:
|
||||||
|
socket.socket = original_socket
|
||||||
|
|
||||||
|
# 若成功获取到至少一封匹配邮件且请求删除,则删除整个邮箱账号
|
||||||
|
if is_del and len(items) > 0:
|
||||||
|
try:
|
||||||
|
self.email_delete(user)
|
||||||
|
except Exception as del_err:
|
||||||
|
logger.error(f"删除邮箱账号失败: {del_err}")
|
||||||
|
|
||||||
|
if had_error:
|
||||||
|
return None
|
||||||
|
if len(items) == 0:
|
||||||
|
return None
|
||||||
|
return items # 返回邮件列表
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""
|
||||||
|
使用示例:展示新的域名管理系统的使用方法
|
||||||
|
"""
|
||||||
|
mail = Mail()
|
||||||
|
# mai = '0gz3vvd4@'+'qydgs.asia'
|
||||||
|
# res = mail.email_create(mai)
|
||||||
|
# print(f"创建的邮箱: {res}")
|
||||||
|
random_email = mail.email_create_random()
|
||||||
|
print(f"创建的随机邮箱: {random_email}")
|
||||||
|
|
||||||
|
# 读取邮件
|
||||||
|
# res = mail.email_read('0gz3vvd4@qydgs.asia', '@', 1, is_del=True)
|
||||||
|
# print(f'读取的邮件: {res}')
|
||||||
|
|
||||||
|
# 删除邮箱
|
||||||
|
res = mail.email_delete(random_email)
|
||||||
|
print(f"删除的邮箱: {res}")
|
||||||
|
|
||||||
|
mail_ = Mail()
|
||||||
|
|
||||||
|
# if __name__ == '__main__':
|
||||||
|
# asyncio.run(main())
|
||||||
765
spider/main.py
Normal file
765
spider/main.py
Normal file
@@ -0,0 +1,765 @@
|
|||||||
|
import random
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from DrissionPage import Chromium
|
||||||
|
from loguru import logger
|
||||||
|
from work import generate_child_parent_names
|
||||||
|
from mail_ import mail_
|
||||||
|
from bit_browser import bit_browser
|
||||||
|
from api import api
|
||||||
|
from proxys import proxy_list
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from auto_challenge import ReCaptchaHandler
|
||||||
|
|
||||||
|
|
||||||
|
class Auto:
|
||||||
|
def __init__(self, http: str = None):
|
||||||
|
# self.browser = Chromium(http)
|
||||||
|
self.browser = Chromium()
|
||||||
|
self.tab = self.browser.latest_tab
|
||||||
|
pass
|
||||||
|
|
||||||
|
# cf打码
|
||||||
|
def solve_cloudflare(self, is_ok: bool = False):
|
||||||
|
tab = self.browser.latest_tab
|
||||||
|
for _ in range(5):
|
||||||
|
tab.wait(1)
|
||||||
|
res = tab.ele(
|
||||||
|
't:h1@text()=Sorry, you have been blocked', timeout=1)
|
||||||
|
if res:
|
||||||
|
logger.error("Cloudflare验证失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
shadow1 = tab.ele(
|
||||||
|
'x://*[@name="cf-turnstile-response"]').parent().shadow_root
|
||||||
|
iframe = shadow1.get_frame(1)
|
||||||
|
if iframe:
|
||||||
|
logger.debug("找到Cloudflare iframe")
|
||||||
|
shadow2 = iframe.ele('x:/html/body').shadow_root
|
||||||
|
if shadow2:
|
||||||
|
logger.debug("找到Cloudflare iframe body shadow root")
|
||||||
|
status = shadow2.ele(
|
||||||
|
'x://span[text()="Verifying..."]', timeout=1.5)
|
||||||
|
if status:
|
||||||
|
tab.wait(3)
|
||||||
|
status = shadow2.ele(
|
||||||
|
'x://span[text()="Success!"]', timeout=1.5)
|
||||||
|
if status:
|
||||||
|
logger.debug("Cloudflare验证成功")
|
||||||
|
return True
|
||||||
|
checkbox = shadow2.ele(
|
||||||
|
'x://input[@type="checkbox"]', timeout=1.5)
|
||||||
|
if checkbox:
|
||||||
|
checkbox.click()
|
||||||
|
logger.debug("点击Cloudflare复选框")
|
||||||
|
tab.wait(3)
|
||||||
|
logger.debug("重新获取状态")
|
||||||
|
# return False
|
||||||
|
except Exception as e:
|
||||||
|
# logger.error(f"处理Cloudflare异常: {e}")
|
||||||
|
if is_ok:
|
||||||
|
logger.debug(f"cloudflare处理通过: {e}")
|
||||||
|
return True
|
||||||
|
return self.solve_cloudflare(is_ok=True)
|
||||||
|
tab.wait(1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 谷歌验证码
|
||||||
|
def solve_recaptcha(self):
|
||||||
|
logger.debug("开始解决谷歌验证码")
|
||||||
|
recaptcha_handler = ReCaptchaHandler(self.tab)
|
||||||
|
res = recaptcha_handler.challenge()
|
||||||
|
if res.get("status"):
|
||||||
|
logger.debug("谷歌验证码成功")
|
||||||
|
iframe = self.tab.ele('t:iframe@title=reCAPTCHA')
|
||||||
|
# print(iframe)
|
||||||
|
res = iframe.ele('t:div@class=recaptcha-checkbox-border')
|
||||||
|
if res:
|
||||||
|
logger.debug(f"html: {res.html}")
|
||||||
|
if 'display: none;' in res.html:
|
||||||
|
logger.debug("谷歌验证码成功")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("No element found")
|
||||||
|
return False
|
||||||
|
logger.error("谷歌验证码失败")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 打开URL
|
||||||
|
def open_url(self, url: str):
|
||||||
|
self.tab.get(url)
|
||||||
|
|
||||||
|
def get_tab(self):
|
||||||
|
return self.tab
|
||||||
|
|
||||||
|
# 等待进入首页
|
||||||
|
def wait_home(self):
|
||||||
|
logger.debug("等待进入首页")
|
||||||
|
jc = 0
|
||||||
|
while True:
|
||||||
|
if jc > 3:
|
||||||
|
logger.error("等待进入首页超过5次,未成功")
|
||||||
|
return False
|
||||||
|
self.tab.wait(1)
|
||||||
|
bol = self.tab.ele(
|
||||||
|
't:div@text():YOUTUBE PRIVACY SETTLEMENT', timeout=1)
|
||||||
|
if bol:
|
||||||
|
logger.debug("成功进入首页")
|
||||||
|
return True
|
||||||
|
|
||||||
|
jc += 1
|
||||||
|
|
||||||
|
|
||||||
|
# 随机取城市
|
||||||
|
def get_random_city(self, province: str | None = None):
|
||||||
|
cities = {
|
||||||
|
"Alberta": ["Calgary", "Edmonton"],
|
||||||
|
"British Columbia": ["Vancouver"],
|
||||||
|
# "Manitoba": ["Winnipeg", "Rochester"],
|
||||||
|
# "New Brunswick": ["Fredericton", "Moncton"],
|
||||||
|
# "Newfoundland and Labrador": ["St. John's", "Halifax"],
|
||||||
|
"Nova Scotia": ["Halifax"],
|
||||||
|
"Ontario": ["Toronto"],
|
||||||
|
# "Prince Edward Island": ["Charlottetown", "St. John's"],
|
||||||
|
# "Quebec": ["Quebec City", "Montreal"],
|
||||||
|
# "Saskatchewan": ["Saskatoon", "Regina"],
|
||||||
|
}
|
||||||
|
if province is None:
|
||||||
|
province = random.choice(list(cities.keys()))
|
||||||
|
return province, random.choice(cities.get(province, []))
|
||||||
|
|
||||||
|
def get_province_by_city(self) -> str | None:
|
||||||
|
"""
|
||||||
|
根据城市名称解析对应省份
|
||||||
|
|
||||||
|
参数:
|
||||||
|
city (str): 城市名称,例如 `Calgary`、`Edmonton` 等
|
||||||
|
|
||||||
|
返回值:
|
||||||
|
str | None: 对应的省份名称;未匹配返回 None
|
||||||
|
"""
|
||||||
|
mapping = {
|
||||||
|
"Calgary": "Alberta",
|
||||||
|
"Edmonton": "Alberta",
|
||||||
|
"Vancouver": "British Columbia",
|
||||||
|
"Halifax": "Nova Scotia",
|
||||||
|
"Toronto": "Ontario",
|
||||||
|
"Ottawa": "Ontario",
|
||||||
|
"Mississauga": "Ontario",
|
||||||
|
"Brampton": "Ontario",
|
||||||
|
"Hamilton": "Ontario",
|
||||||
|
"Kitchener": "Ontario",
|
||||||
|
"London": "Ontario",
|
||||||
|
"Markham": "Ontario",
|
||||||
|
"Vaughan": "Ontario",
|
||||||
|
"Windsor": "Ontario",
|
||||||
|
"Oshawa": "Ontario",
|
||||||
|
"Brantford": "Ontario",
|
||||||
|
"Barrie": "Ontario",
|
||||||
|
"Sudbury": "Ontario",
|
||||||
|
"Kingston": "Ontario",
|
||||||
|
"Guelph": "Ontario",
|
||||||
|
"Cambridge": "Ontario",
|
||||||
|
"Sarnia": "Ontario",
|
||||||
|
"Peterborough": "Ontario",
|
||||||
|
"Waterloo": "Ontario",
|
||||||
|
"Belleville": "Ontario",
|
||||||
|
"Brockville": "Ontario",
|
||||||
|
"Burlington": "Ontario",
|
||||||
|
"Cornwall": "Ontario",
|
||||||
|
"Kawartha Lakes": "Ontario",
|
||||||
|
"North Bay": "Ontario",
|
||||||
|
"Orillia": "Ontario",
|
||||||
|
"Pickering": "Ontario",
|
||||||
|
"Sault Ste. Marie": "Ontario",
|
||||||
|
"Stratford": "Ontario",
|
||||||
|
"Durham": "Ontario",
|
||||||
|
"Norfolk County": "Ontario",
|
||||||
|
"Prince Edward County": "Ontario",
|
||||||
|
"Quinte West": "Ontario",
|
||||||
|
"St. Catharines": "Ontario",
|
||||||
|
"Welland": "Ontario",
|
||||||
|
"Thorold": "Ontario",
|
||||||
|
"Niagara Falls": "Ontario",
|
||||||
|
"Pelham": "Ontario",
|
||||||
|
"Port Colborne": "Ontario",
|
||||||
|
}
|
||||||
|
# 随机返回一条 key 和 value
|
||||||
|
return random.choice(list(mapping.items()))
|
||||||
|
|
||||||
|
# 随机实物
|
||||||
|
|
||||||
|
def get_random_food(self, city: str, shop: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
随机选择 1~2 种食物类别,并为每个类别至少选择 1 个具体产品
|
||||||
|
|
||||||
|
参数:
|
||||||
|
shop (str): 商店名称(当前未使用,占位参数)
|
||||||
|
|
||||||
|
返回值:
|
||||||
|
list[str]: 随机选取的产品名称列表
|
||||||
|
"""
|
||||||
|
categories = [
|
||||||
|
[
|
||||||
|
'Wonder Bread White',
|
||||||
|
'Villaggio White Bread',
|
||||||
|
'No Name Sliced White Bread',
|
||||||
|
"President's Choice White Sliced Bread",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Ben's Original Whole Wheat Bread",
|
||||||
|
"POM Whole Wheat Bread",
|
||||||
|
"Silver Hills Bakery Whole Wheat Sliced Bread",
|
||||||
|
"Country Harvest Whole Wheat Bread",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Wonder Bread Hot Dog Buns",
|
||||||
|
"Villaggio Hamburger Buns",
|
||||||
|
"Dempster's Dinner Rolls",
|
||||||
|
"No Frills Hot Dog Buns",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Stonemill Bakehouse Bagels",
|
||||||
|
"Wonder Bagels",
|
||||||
|
"Montreal Bagels (pre-packaged, e.g., St. Lawrence brand)",
|
||||||
|
"President's Choice Bagels",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Silver Hills Multi-Grain Sliced Bread",
|
||||||
|
"POM Multi-Grain Bread",
|
||||||
|
"Country Harvest Multi-Grain Loaf",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"President's Choice French Stick",
|
||||||
|
"Dempster's Italian Style Bread",
|
||||||
|
"Wonder Italian Bread",
|
||||||
|
"Villaggio Country Style Loaf",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
# 随机选择 1~2 个类别(不重复)
|
||||||
|
category_count = random.randint(1, 2)
|
||||||
|
chosen_categories = random.sample(categories, k=category_count)
|
||||||
|
|
||||||
|
# 每个类别至少选择 1 个产品,最多选择 3 个以避免过多
|
||||||
|
selected_products: list[str] = []
|
||||||
|
for cat in chosen_categories:
|
||||||
|
max_pick = min(3, len(cat))
|
||||||
|
pick_count = random.randint(1, max_pick)
|
||||||
|
selected_products.extend(random.sample(cat, k=pick_count))
|
||||||
|
logger.debug(f"随机选择的产品: {selected_products}")
|
||||||
|
text = f'{shop}, {city} buy: '
|
||||||
|
for p in selected_products:
|
||||||
|
text += f'{p} * {random.randint(1, 3)}, '
|
||||||
|
text = text[:-2]
|
||||||
|
text = text + '.'
|
||||||
|
logger.debug(f'随机选择的产品文本: {text}')
|
||||||
|
return text
|
||||||
|
|
||||||
|
# 填写问卷
|
||||||
|
def fill_questionnaire(self):
|
||||||
|
"""
|
||||||
|
完成问卷填写
|
||||||
|
|
||||||
|
参数:
|
||||||
|
city (str): 线程启动时传入的城市名称,用于匹配省份并填写数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
info = generate_child_parent_names()
|
||||||
|
child_full_name = info['child_full_name']
|
||||||
|
parent_full_name = info['parent_full_name']
|
||||||
|
child_birthday = info['child_birthday']
|
||||||
|
# 2023-04-01转为MM/DD/YYYY
|
||||||
|
child_birthday = datetime.strptime(child_birthday, '%Y-%m-%d').strftime('%m/%d/%Y')
|
||||||
|
address_str = info['child_address_str']
|
||||||
|
city_name = info['child_city_name']
|
||||||
|
postcode = info['child_postcode']
|
||||||
|
parent_phone = info['parent_phone']
|
||||||
|
province = info['parent_state']
|
||||||
|
# email = mail_.email_create_random()
|
||||||
|
email = 'zhiyu@qq.com'
|
||||||
|
logger.debug(f"child_full_name --> {child_full_name}")
|
||||||
|
logger.debug(f"parent_full_name --> {parent_full_name}")
|
||||||
|
logger.debug(f"child_birthday --> {child_birthday}")
|
||||||
|
logger.debug(f"address_str --> {address_str}")
|
||||||
|
logger.debug(f"city_name --> {city_name}")
|
||||||
|
logger.debug(f"postcode --> {postcode}")
|
||||||
|
logger.debug(f"parent_phone --> {parent_phone}")
|
||||||
|
logger.debug(f"province --> {province}")
|
||||||
|
logger.debug(f"email --> {email}")
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@id=name1').input(child_full_name)
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@id=name2').input(parent_full_name)
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@id=dateOfBirth').input(child_birthday)
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@id=street1').input(address_str)
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@id=city').input(city_name)
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele(
|
||||||
|
't:select@formcontrolname=state').ele(f't:option@text():{province}').click()
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@id=zip').input(postcode)
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@id=phone1').input(parent_phone)
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@id=emailAddress').input(email)
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@id=confirmEmailemail').input(email)
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@@formcontrolname=resideInUS@@id=Yes').click()
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@@formcontrolname=watchedDuringPeriod@@id=Yes').click()
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@id=signatureMinor').input(child_full_name)
|
||||||
|
self.tab.wait(0.1)
|
||||||
|
self.tab.ele('t:input@id=signatureParentGuardian').input(parent_full_name)
|
||||||
|
self.solve_recaptcha()
|
||||||
|
|
||||||
|
return self.submit_file(
|
||||||
|
child_full_name=child_full_name,
|
||||||
|
parent_full_name=parent_full_name,
|
||||||
|
child_birthday=child_birthday,
|
||||||
|
address_str=address_str,
|
||||||
|
city_name=city_name,
|
||||||
|
parent_phone=parent_phone,
|
||||||
|
postcode=postcode,
|
||||||
|
province=province,
|
||||||
|
email=email,
|
||||||
|
text=""
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"填写问卷失败: {e}")
|
||||||
|
|
||||||
|
# 提交问卷
|
||||||
|
def submit_file(self, child_full_name: str, parent_full_name: str, child_birthday: str, address_str: str, city_name: str, parent_phone: str, postcode: str, province: str, email: str, text: str):
|
||||||
|
"""
|
||||||
|
提交问卷后的数据保存到后端服务(孩子与家长字段)
|
||||||
|
|
||||||
|
参数:
|
||||||
|
child_full_name (str): 孩子全名
|
||||||
|
parent_full_name (str): 家长全名
|
||||||
|
child_birthday (str): 孩子生日(字符串,已为 MM/DD/YYYY)
|
||||||
|
address_str (str): 街道地址
|
||||||
|
city_name (str): 城市
|
||||||
|
parent_phone (str): 家长电话
|
||||||
|
postcode (str): 邮编
|
||||||
|
province (str): 省/州全称
|
||||||
|
email (str): 邮箱
|
||||||
|
text (str): 文本内容(如反馈地址)
|
||||||
|
"""
|
||||||
|
jc = 0
|
||||||
|
while True:
|
||||||
|
if jc >= 3:
|
||||||
|
logger.error("提交问卷失败")
|
||||||
|
return False
|
||||||
|
res = self.solve_recaptcha()
|
||||||
|
if not res:
|
||||||
|
jc += 1
|
||||||
|
continue
|
||||||
|
res = self.tab.ele('t:button@text():SUBMIT')
|
||||||
|
if res:
|
||||||
|
logger.debug(f"点击Submit按钮")
|
||||||
|
res.click()
|
||||||
|
self.tab.wait(3)
|
||||||
|
res = self.tab.ele(
|
||||||
|
't:h2@text()=THANK YOU FOR SUBMITTING YOUR INFORMATION', timeout=1)
|
||||||
|
if res:
|
||||||
|
logger.info("提交问卷成功")
|
||||||
|
logger.info(f"反馈地址: {text}")
|
||||||
|
|
||||||
|
res = self.tab.ele('t:b')
|
||||||
|
if res:
|
||||||
|
logger.info(f"反馈地址: {res.text}")
|
||||||
|
text = res.text
|
||||||
|
status = True
|
||||||
|
|
||||||
|
else:
|
||||||
|
status=False
|
||||||
|
|
||||||
|
api.create_info(
|
||||||
|
child_full_name=child_full_name,
|
||||||
|
parent_full_name=parent_full_name,
|
||||||
|
child_birthday=child_birthday,
|
||||||
|
address_str=address_str,
|
||||||
|
city_name=city_name,
|
||||||
|
parent_phone=parent_phone,
|
||||||
|
postcode=postcode,
|
||||||
|
province=province,
|
||||||
|
email=email,
|
||||||
|
text=text,
|
||||||
|
status=status
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
bol = self.tab.ele(
|
||||||
|
't:div@text():ERR_TIMED_OUT', timeout=1)
|
||||||
|
if bol:
|
||||||
|
logger.debug("刷新网页")
|
||||||
|
self.tab.refresh()
|
||||||
|
self.tab.wait(1.5)
|
||||||
|
bol = self.tab.ele(
|
||||||
|
't:div@text():ERR_SSL_PROTOCOL_ERROR', timeout=1)
|
||||||
|
if bol:
|
||||||
|
logger.debug("刷新网页")
|
||||||
|
self.tab.refresh()
|
||||||
|
self.tab.wait(1.5)
|
||||||
|
bol = self.tab.ele(
|
||||||
|
't:div@text():ERR_SOCKS_CONNECTION_FAILED', timeout=1)
|
||||||
|
if bol:
|
||||||
|
logger.debug("刷新网页")
|
||||||
|
self.tab.refresh()
|
||||||
|
self.tab.wait(1.5)
|
||||||
|
jc += 1
|
||||||
|
|
||||||
|
|
||||||
|
def parse_proxy(proxy: str) -> tuple[str, int, str, str] | None:
|
||||||
|
"""
|
||||||
|
解析代理字符串为四元组 `(host, port, user, pwd)`
|
||||||
|
|
||||||
|
参数:
|
||||||
|
proxy: 形如 `host:port:user:pwd`
|
||||||
|
|
||||||
|
返回值:
|
||||||
|
(host, port, user, pwd) 或 None(格式错误)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
host, port, user, pwd = proxy.split(":", 3)
|
||||||
|
return host, int(port), user, pwd
|
||||||
|
except Exception:
|
||||||
|
logger.error(f"代理格式错误: {proxy}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_fingerprint_browser(proxy: str) -> tuple[str, str] | None:
|
||||||
|
"""
|
||||||
|
创建指纹浏览器并打开窗口,返回 `(browser_id, debugger_http)`
|
||||||
|
|
||||||
|
参数:
|
||||||
|
proxy: 代理字符串
|
||||||
|
|
||||||
|
返回值:
|
||||||
|
(browser_id, http) 或 None(失败)
|
||||||
|
"""
|
||||||
|
info = parse_proxy(proxy)
|
||||||
|
if info is None:
|
||||||
|
return None
|
||||||
|
host, port, user, pwd = info
|
||||||
|
try:
|
||||||
|
browser_id = bit_browser.bit_browser_create(
|
||||||
|
remark=f"{user}",
|
||||||
|
proxy_type="socks5",
|
||||||
|
host=host,
|
||||||
|
port=str(port),
|
||||||
|
proxy_user=user,
|
||||||
|
proxy_pwd=pwd,
|
||||||
|
)
|
||||||
|
if not browser_id:
|
||||||
|
return None
|
||||||
|
logger.info(f"创建指纹浏览器成功: {browser_id}")
|
||||||
|
time.sleep(1)
|
||||||
|
http = bit_browser.bit_browser_open(browser_id)
|
||||||
|
if not http:
|
||||||
|
return None
|
||||||
|
logger.info(f"打开指纹浏览器成功: {browser_id}")
|
||||||
|
return browser_id, http
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建指纹浏览器失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def close_and_delete_browser(browser_id: str) -> None:
|
||||||
|
"""
|
||||||
|
关闭并删除指定指纹浏览器
|
||||||
|
|
||||||
|
参数:
|
||||||
|
browser_id: 指纹浏览器ID
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
bit_browser.bit_browser_close(browser_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"关闭浏览器失败或已关闭: {browser_id} - {e}")
|
||||||
|
time.sleep(1)
|
||||||
|
try:
|
||||||
|
bit_browser.bit_browser_delete(browser_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"删除浏览器失败或已删除: {browser_id} - {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_task_with_proxy(proxy: str, stop_event: threading.Event) -> None:
|
||||||
|
"""
|
||||||
|
使用代理创建指纹浏览器、执行自动化,并在结束后清理
|
||||||
|
|
||||||
|
参数:
|
||||||
|
proxy: 代理字符串
|
||||||
|
"""
|
||||||
|
browser_id: str | None = None
|
||||||
|
try:
|
||||||
|
created = create_fingerprint_browser(proxy)
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
browser_id, http = created
|
||||||
|
if stop_event.is_set():
|
||||||
|
return
|
||||||
|
auto = Auto(http=http)
|
||||||
|
auto.open_url('https://www.claimform.youtubeprivacysettlement.com')
|
||||||
|
if stop_event.is_set():
|
||||||
|
return
|
||||||
|
if not auto.wait_home():
|
||||||
|
return
|
||||||
|
if stop_event.is_set():
|
||||||
|
return
|
||||||
|
if not auto.click_continue():
|
||||||
|
return
|
||||||
|
if stop_event.is_set():
|
||||||
|
return
|
||||||
|
auto.fill_questionnaire()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"执行任务异常: {e}")
|
||||||
|
finally:
|
||||||
|
if browser_id:
|
||||||
|
try:
|
||||||
|
close_and_delete_browser(browser_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def proxy_loop(proxy: str, stop_event: threading.Event) -> None:
|
||||||
|
"""
|
||||||
|
为单个代理保持持续运行:任务结束后立即重建并再次执行
|
||||||
|
|
||||||
|
参数:
|
||||||
|
proxy: 代理字符串
|
||||||
|
stop_event: 停止事件,用于外部触发退出循环
|
||||||
|
"""
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
if is_forbidden_time():
|
||||||
|
if stop_event.wait(timeout=60):
|
||||||
|
break
|
||||||
|
cleanup_all_browsers()
|
||||||
|
secs = seconds_until(20, 0)
|
||||||
|
if stop_event.wait(timeout=secs):
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
run_task_with_proxy(proxy, stop_event)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"代理循环异常: {proxy} - {e}")
|
||||||
|
if stop_event.is_set():
|
||||||
|
break
|
||||||
|
if stop_event.wait(timeout=0.1):
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def is_forbidden_time() -> bool:
|
||||||
|
"""
|
||||||
|
判断当前是否处于禁跑时段(每日 18:30 ~ 20:00,本地时间)
|
||||||
|
|
||||||
|
返回值:
|
||||||
|
bool: True 表示处于禁跑时段
|
||||||
|
"""
|
||||||
|
# 去除晚上停止功能
|
||||||
|
return False
|
||||||
|
# 禁跑时段为 18:30 ~ 20:00
|
||||||
|
now = datetime.now()
|
||||||
|
start = now.replace(hour=18, minute=30, second=0, microsecond=0)
|
||||||
|
end = now.replace(hour=20, minute=0, second=0, microsecond=0)
|
||||||
|
return start <= now < end
|
||||||
|
|
||||||
|
def wait_until_out_of_forbidden(interval_sec: float = 5.0, stop_event: threading.Event | None = None) -> None:
|
||||||
|
"""
|
||||||
|
在禁跑时段内循环等待,直到禁跑时段结束
|
||||||
|
|
||||||
|
参数:
|
||||||
|
interval_sec: 轮询间隔秒数
|
||||||
|
stop_event: 可选停止事件,若设置则在等待期间可提前结束
|
||||||
|
"""
|
||||||
|
while is_forbidden_time():
|
||||||
|
if stop_event is not None and stop_event.wait(timeout=interval_sec):
|
||||||
|
break
|
||||||
|
time.sleep(interval_sec)
|
||||||
|
|
||||||
|
|
||||||
|
def seconds_until(hour: int, minute: int) -> float:
|
||||||
|
"""
|
||||||
|
计算到今天指定时间点的剩余秒数
|
||||||
|
|
||||||
|
参数:
|
||||||
|
hour: 目标小时(24小时制)
|
||||||
|
minute: 目标分钟
|
||||||
|
|
||||||
|
返回值:
|
||||||
|
float: 剩余秒数,若目标时间已过则为 0
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||||
|
if target <= now:
|
||||||
|
return 0.0
|
||||||
|
return (target - now).total_seconds()
|
||||||
|
|
||||||
|
|
||||||
|
def count_fingerprint_browsers() -> int:
|
||||||
|
"""
|
||||||
|
统计当前指纹浏览器数量
|
||||||
|
|
||||||
|
返回值:
|
||||||
|
int: 当前总数量
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
res = bit_browser.bit_browser_get(0, 100)
|
||||||
|
data = res.get("data", {}) if isinstance(res, dict) else {}
|
||||||
|
total = data.get("totalNum")
|
||||||
|
lst = data.get("list", [])
|
||||||
|
if isinstance(total, int) and total >= 0:
|
||||||
|
return total
|
||||||
|
return len(lst)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"统计指纹浏览器数量失败: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_all_browsers() -> None:
|
||||||
|
"""
|
||||||
|
关闭并删除所有指纹浏览器
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
res = bit_browser.bit_browser_get(0, 100)
|
||||||
|
data = res.get("data", {}) if isinstance(res, dict) else {}
|
||||||
|
lst = data.get("list", [])
|
||||||
|
ids = [i.get("id") for i in lst if i.get("id")]
|
||||||
|
for bid in ids:
|
||||||
|
close_and_delete_browser(bid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"清理所有指纹浏览器失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def delete_excess_browsers(limit: int) -> None:
|
||||||
|
"""
|
||||||
|
删除超出上限的指纹浏览器,从列表末尾开始删除
|
||||||
|
|
||||||
|
参数:
|
||||||
|
limit: 允许的最大浏览器数量
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
res = bit_browser.bit_browser_get(0, 100)
|
||||||
|
data = res.get("data", {}) if isinstance(res, dict) else {}
|
||||||
|
lst = data.get("list", [])
|
||||||
|
ids = [i.get("id") for i in lst if i.get("id")]
|
||||||
|
count = len(ids)
|
||||||
|
if count <= limit:
|
||||||
|
return
|
||||||
|
excess = count - limit
|
||||||
|
to_delete = ids[-excess:]
|
||||||
|
for bid in reversed(to_delete):
|
||||||
|
close_and_delete_browser(bid)
|
||||||
|
logger.info(f"已删除超出数量 {excess},当前限制为 {limit}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"删除超额浏览器失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_browsers_and_restart(limit: int, stop_event: threading.Event, restart_event: threading.Event) -> None:
|
||||||
|
"""
|
||||||
|
每 3 秒检测指纹浏览器数量,超过 `limit` 则从末尾删除超出部分
|
||||||
|
|
||||||
|
参数:
|
||||||
|
limit: 允许的最大浏览器数量(通常为代理数量)
|
||||||
|
restart_event: 触发重启的事件(当前策略不使用)
|
||||||
|
"""
|
||||||
|
while not stop_event.is_set():
|
||||||
|
time.sleep(3)
|
||||||
|
count = count_fingerprint_browsers()
|
||||||
|
if count > limit:
|
||||||
|
logger.warning(f"指纹浏览器数量 {count} 超过限制 {limit},开始删除超出部分")
|
||||||
|
delete_excess_browsers(limit)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
多线程并发管理:按代理数量并发创建指纹浏览器并执行任务;每 3 秒监控数量,超限则从末尾删除多余浏览器。
|
||||||
|
"""
|
||||||
|
proxies = list(proxy_list)
|
||||||
|
while True:
|
||||||
|
stop_event = threading.Event()
|
||||||
|
restart_event = threading.Event()
|
||||||
|
|
||||||
|
if is_forbidden_time():
|
||||||
|
if stop_event.wait(timeout=60):
|
||||||
|
continue
|
||||||
|
cleanup_all_browsers()
|
||||||
|
logger.info("处于禁跑时段,等待至禁跑结束")
|
||||||
|
wait_until_out_of_forbidden()
|
||||||
|
continue
|
||||||
|
|
||||||
|
executor = ThreadPoolExecutor(max_workers=len(proxies))
|
||||||
|
try:
|
||||||
|
futures_map = {executor.submit(proxy_loop, p, stop_event): p for p in proxies}
|
||||||
|
|
||||||
|
monitor_thread = threading.Thread(
|
||||||
|
target=monitor_browsers_and_restart,
|
||||||
|
args=(len(proxies), stop_event, restart_event),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
monitor_thread.start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if restart_event.is_set():
|
||||||
|
stop_event.set()
|
||||||
|
try:
|
||||||
|
executor.shutdown(wait=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
if is_forbidden_time():
|
||||||
|
logger.info("进入禁跑时段,停止当前批次,等待1分钟后清理指纹浏览器")
|
||||||
|
stop_event.set()
|
||||||
|
try:
|
||||||
|
executor.shutdown(wait=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(60)
|
||||||
|
cleanup_all_browsers()
|
||||||
|
wait_until_out_of_forbidden()
|
||||||
|
break
|
||||||
|
for f, proxy in list(futures_map.items()):
|
||||||
|
if f.done() and not stop_event.is_set() and not restart_event.is_set():
|
||||||
|
try:
|
||||||
|
_ = f.exception()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
new_future = executor.submit(proxy_loop, proxy, stop_event)
|
||||||
|
del futures_map[f]
|
||||||
|
futures_map[new_future] = proxy
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"重启代理线程失败: {proxy} - {e}")
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
monitor_thread.join(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
executor.shutdown(wait=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
def main2():
|
||||||
|
auto = Auto()
|
||||||
|
auto.open_url('https://www.claimform.youtubeprivacysettlement.com')
|
||||||
|
bol = auto.wait_home()
|
||||||
|
if not bol:
|
||||||
|
return
|
||||||
|
auto.fill_questionnaire()
|
||||||
|
# auto.solve_recaptcha()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main2()
|
||||||
95
spider/proxys.py
Normal file
95
spider/proxys.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
work = [
|
||||||
|
"us.novproxy.io:1000:qyd00056-region-CA:qyd00056",
|
||||||
|
"us.novproxy.io:1000:qyd00054-region-US:qyd00054",
|
||||||
|
"us.novproxy.io:1000:qyd00053-region-CA:qyd00053",
|
||||||
|
"us.novproxy.io:1000:qyd00052-region-US:qyd00052",
|
||||||
|
]
|
||||||
|
|
||||||
|
ca1 = [
|
||||||
|
"us.novproxy.io:1000:qyd00051-region-CA:qyd00051",
|
||||||
|
"us.novproxy.io:1000:qyd00050-region-US:qyd00050",
|
||||||
|
"us.novproxy.io:1000:qyd00049-region-CA:qyd00049",
|
||||||
|
"us.novproxy.io:1000:qyd00048-region-US:qyd00048",
|
||||||
|
"us.novproxy.io:1000:qyd00047-region-CA:qyd00047",
|
||||||
|
]
|
||||||
|
ca2 = [
|
||||||
|
"us.novproxy.io:1000:qyd00046-region-US:qyd00046",
|
||||||
|
"us.novproxy.io:1000:qyd00045-region-CA:qyd00045",
|
||||||
|
"us.novproxy.io:1000:qyd00044-region-US:qyd00044",
|
||||||
|
"us.novproxy.io:1000:qyd00043-region-CA:qyd00043",
|
||||||
|
"us.novproxy.io:1000:qyd00042-region-US:qyd00042",
|
||||||
|
]
|
||||||
|
|
||||||
|
ca3 = [
|
||||||
|
"us.novproxy.io:1000:qyd00041-region-CA:qyd00041",
|
||||||
|
"us.novproxy.io:1000:qyd00040-region-CA:qyd00040",
|
||||||
|
"us.novproxy.io:1000:qyd00039-region-US:qyd00039",
|
||||||
|
"us.novproxy.io:1000:qyd00038-region-CA:qyd00038",
|
||||||
|
"us.novproxy.io:1000:qyd00037-region-US:qyd00037",
|
||||||
|
]
|
||||||
|
|
||||||
|
cwd = [
|
||||||
|
"us.novproxy.io:1000:qyd00036-region-CA:qyd00036",
|
||||||
|
"us.novproxy.io:1000:qyd00035-region-US:qyd00035",
|
||||||
|
"us.novproxy.io:1000:qyd00034-region-CA:qyd00034",
|
||||||
|
"us.novproxy.io:1000:qyd00033-region-US:qyd00033",
|
||||||
|
]
|
||||||
|
|
||||||
|
wt = [
|
||||||
|
"us.novproxy.io:1000:qyd00032-region-CA:qyd00032",
|
||||||
|
"us.novproxy.io:1000:qyd00031-region-US:qyd00031",
|
||||||
|
"us.novproxy.io:1000:qyd00030-region-CA:qyd00030",
|
||||||
|
"us.novproxy.io:1000:qyd00029-region-US:qyd00029",
|
||||||
|
]
|
||||||
|
|
||||||
|
hc = [
|
||||||
|
"us.novproxy.io:1000:qyd00028-region-CA:qyd00028",
|
||||||
|
"us.novproxy.io:1000:qyd00027-region-US:qyd00027",
|
||||||
|
"us.novproxy.io:1000:qyd00026-region-CA:qyd00026",
|
||||||
|
"us.novproxy.io:1000:qyd00025-region-US:qyd00025",
|
||||||
|
]
|
||||||
|
|
||||||
|
zlj = [
|
||||||
|
"us.novproxy.io:1000:qyd00024-region-CA:qyd00024",
|
||||||
|
"us.novproxy.io:1000:qyd00023-region-US:qyd00023",
|
||||||
|
"us.novproxy.io:1000:qyd00022-region-CA:qyd00022",
|
||||||
|
"us.novproxy.io:1000:qyd00021-region-US:qyd00021",
|
||||||
|
]
|
||||||
|
|
||||||
|
wzq = [
|
||||||
|
"us.novproxy.io:1000:qyd00020-region-CA:qyd00020",
|
||||||
|
"us.novproxy.io:1000:qyd00019-region-US:qyd00019",
|
||||||
|
"us.novproxy.io:1000:qyd00018-region-CA:qyd00018",
|
||||||
|
"us.novproxy.io:1000:qyd00017-region-US:qyd00017",
|
||||||
|
]
|
||||||
|
|
||||||
|
xy = [
|
||||||
|
"us.novproxy.io:1000:qyd00016-region-CA:qyd00016",
|
||||||
|
"us.novproxy.io:1000:qyd00015-region-US:qyd00015",
|
||||||
|
"us.novproxy.io:1000:qyd00014-region-CA:qyd00014",
|
||||||
|
"us.novproxy.io:1000:qyd00013-region-US:qyd00013",
|
||||||
|
]
|
||||||
|
|
||||||
|
yll = [
|
||||||
|
"us.novproxy.io:1000:qyd00012-region-CA:qyd00012",
|
||||||
|
"us.novproxy.io:1000:qyd00011-region-US:qyd00011",
|
||||||
|
"us.novproxy.io:1000:qyd00010-region-CA:qyd00010",
|
||||||
|
"us.novproxy.io:1000:qyd00009-region-US:qyd00009",
|
||||||
|
]
|
||||||
|
|
||||||
|
szt = [
|
||||||
|
"us.novproxy.io:1000:qyd00008-region-CA:qyd00008",
|
||||||
|
"us.novproxy.io:1000:qyd00007-region-US:qyd00007",
|
||||||
|
"us.novproxy.io:1000:qyd00006-region-CA:qyd00006",
|
||||||
|
"us.novproxy.io:1000:qyd00005-region-US:qyd00005",
|
||||||
|
]
|
||||||
|
|
||||||
|
hz = [
|
||||||
|
"us.novproxy.io:1000:qyd00004-region-CA:qyd00004",
|
||||||
|
"us.novproxy.io:1000:qyd00003-region-US:qyd00003",
|
||||||
|
"us.novproxy.io:1000:qyd00002-region-CA:qyd00002",
|
||||||
|
"us.novproxy.io:1000:qyd00001-region-US:qyd00001",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
proxy_list = work
|
||||||
31
spider/requirements.txt
Normal file
31
spider/requirements.txt
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
aiohttp
|
||||||
|
requests
|
||||||
|
curl_cffi
|
||||||
|
aiohttp-socks
|
||||||
|
requests[socks]
|
||||||
|
fake_useragent
|
||||||
|
apscheduler
|
||||||
|
aiofiles
|
||||||
|
loguru
|
||||||
|
portalocker
|
||||||
|
aiomultiprocess
|
||||||
|
faker
|
||||||
|
eth_account
|
||||||
|
eth_utils
|
||||||
|
solders
|
||||||
|
toncli
|
||||||
|
ecdsa
|
||||||
|
base58
|
||||||
|
ddddocr
|
||||||
|
aiohttp_socks
|
||||||
|
websockets
|
||||||
|
psutil
|
||||||
|
socks
|
||||||
|
drissionpage
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
pydantic
|
||||||
|
ultralytics
|
||||||
|
opencv-python-headless
|
||||||
|
torch
|
||||||
|
pillow
|
||||||
22
spider/test.py
Normal file
22
spider/test.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from DrissionPage import Chromium
|
||||||
|
from loguru import logger
|
||||||
|
from bit_browser import bit_browser
|
||||||
|
# http = bit_browser.bit_browser_open('871851b9835d42b3911f39162b3427d5')
|
||||||
|
# print(http)
|
||||||
|
browser = Chromium('127.0.0.1:65480')
|
||||||
|
tab = browser.latest_tab
|
||||||
|
# tab.get('bitbrowser://settings/clearBrowserData')
|
||||||
|
res = tab.ele('t:settings-ui',timeout=3).sr('t:settings-main').sr('t:settings-basic-page').sr('t:settings-privacy-page').sr('t:settings-clear-browsing-data-dialog').sr('t:cr-dialog')
|
||||||
|
res = res.ele('t:cr-page-selector@id=pages')
|
||||||
|
res = res.ele('t:settings-dropdown-menu@id=clearFromBasic').shadow_root
|
||||||
|
res.ele('t:select@id=dropdownMenu').ele('t:option@value=4').click()
|
||||||
|
# res = tab.ele('t:settings-dropdown-menu@id=clearFromBasic',timeout=3)
|
||||||
|
print(res)
|
||||||
|
if res:
|
||||||
|
logger.info(f"html: {res.html}")
|
||||||
|
# res = tab.ele('t:h2@text()=THANK YOU FOR SUBMITTING YOUR INFORMATION', timeout=3)
|
||||||
|
# if res:
|
||||||
|
# logger.info("提交问卷成功")
|
||||||
|
# res = tab.ele('t:b')
|
||||||
|
# if res:
|
||||||
|
# logger.info(f"反馈地址: {res.text}")
|
||||||
1051
spider/work.py
Normal file
1051
spider/work.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user