mirror of
https://github.com/A-Minos/nonebot-plugin-tetris-stats.git
synced 2026-03-05 05:36:54 +08:00
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cb428ed71 | |||
|
|
ec392ee384 | ||
|
|
d037cf6d44 | ||
|
|
6964e9b655 | ||
|
|
7a032bf947 | ||
|
|
9a91e5ef5b | ||
|
|
5b58697fce | ||
|
|
b14cebe832 | ||
|
|
4306195ee5 | ||
|
|
ac9c6e79d9 | ||
|
|
ed035c65c1 | ||
| dc8bc9b306 | |||
| 454dd57007 | |||
| b396a6d450 | |||
| 7f584a46eb | |||
| 27518c0408 | |||
|
|
d2a3801dac | ||
| 563564ac8d | |||
| 87c87ad231 | |||
| 30515d1907 | |||
|
|
bd0a8ea447 | ||
|
|
1db1e6dbba | ||
|
|
9040aa9fba | ||
|
|
3a5f1eb266 | ||
|
|
43e927430a | ||
| e1b0918a52 | |||
| c86b2eb31b | |||
|
|
47b3f3e881 | ||
|
|
7caee587b4 | ||
|
|
28ae564e59 | ||
|
|
90dee8402d | ||
|
|
8b560e55cb | ||
|
|
3080531503 | ||
|
|
fae0088533 | ||
|
|
db9286a369 | ||
|
|
420fb29318 | ||
|
|
433a6edd3b | ||
|
|
fa81231f78 | ||
|
|
c474cf0af2 | ||
|
|
e38eb5cdff | ||
|
|
7bacf89840 | ||
|
|
4622e90995 | ||
|
|
fa8c2b11e6 | ||
|
|
2123b747af | ||
|
|
e65233d09f | ||
|
|
7e81bf6b8b | ||
|
|
c4614aa006 | ||
|
|
79a657b9f5 | ||
|
|
0164f29c1e | ||
|
|
8db56366df | ||
|
|
de0a1e4c73 | ||
|
|
3670ce7221 | ||
|
|
101ed737ab | ||
|
|
1611bf47fa | ||
|
|
e084cdb145 | ||
|
|
27258ab744 | ||
|
|
07324825e6 | ||
|
|
472becdfe0 | ||
|
|
bc87e4b16d | ||
|
|
28e2a46303 | ||
| 1324015d58 | |||
| e6eae023e7 | |||
| 67cfb07246 | |||
| 12145a614f | |||
| 0b07882a16 | |||
|
|
9073bf5d0b | ||
|
|
f4dd5fe76f | ||
|
|
1f44fc9884 | ||
|
|
44dee7f200 | ||
|
|
dc5ade6ffc | ||
|
|
05ce329976 | ||
|
|
43cabf2135 | ||
|
|
6767136850 | ||
|
|
65999b4625 | ||
|
|
9fde62ac9e | ||
|
|
c74d8b70aa | ||
|
|
0e29b38f9d | ||
|
|
d040c7dca2 | ||
|
|
68ace3a715 | ||
|
|
e63ac69e0f | ||
| 4afda62782 | |||
|
|
abf4410a00 | ||
| 88c2915251 | |||
| 546369241a | |||
| d59bccbd4d | |||
| 75a6989a7f | |||
| ad635bd37d | |||
|
|
b6d63c9e7f | ||
| 805da8cd36 | |||
| 4a13d7807a | |||
| 7bbdeacc5e | |||
|
|
782792e455 | ||
|
|
bd10549b4c | ||
|
|
035e6d4782 | ||
| 003e6619d8 | |||
| c0fa92df30 | |||
| 7cdb0f3547 | |||
| b773fb44a1 | |||
| c75c6b73bd | |||
| 67782c3156 | |||
| 1e02858913 | |||
| 60605d0dca | |||
| 0d589450bd | |||
|
|
2f144acf0c | ||
| 87e6a544a2 | |||
| 74db1931fd | |||
| 1ca6d1f86a | |||
|
|
7361789245 | ||
| fe69d8d2fe | |||
| 2737119865 | |||
| 34a654b5df | |||
| f9f39618a1 | |||
| 81a3c9cb79 | |||
| 4a15c45e0a | |||
| e90ad53ee6 | |||
| 0c968be163 | |||
| bfadac4f79 | |||
| 89f09cd66c | |||
| 777703362e | |||
| ea5308877c | |||
| 3cc93925a6 | |||
| e0bd0a9252 | |||
| d31ce48a18 |
13
.github/workflows/Release.yml
vendored
13
.github/workflows/Release.yml
vendored
@@ -14,22 +14,23 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install poetry
|
||||
run: pipx install poetry
|
||||
shell: bash
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: "poetry"
|
||||
|
||||
- name: Install Poetry
|
||||
- run: poetry install
|
||||
shell: bash
|
||||
run: |
|
||||
pip install poetry
|
||||
poetry install
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(poetry version -s)" >> $GITHUB_OUTPUT
|
||||
echo "TAG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
echo "TAG_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check Version
|
||||
@@ -43,6 +44,6 @@ jobs:
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
- name: Publish Package to GitHub Release
|
||||
run: gh release upload --clobber ${{ steps.version.outputs.TAG_NAME }} dist/*.tar.gz dist/*.whl
|
||||
run: gh release create ${{ steps.version.outputs.TAG_NAME }} dist/*.tar.gz dist/*.whl -t "🔖 ${{ steps.version.outputs.TAG_NAME }}" --generate-notes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,6 +5,6 @@ Untitled*
|
||||
*copy*
|
||||
.vscode
|
||||
*dev*
|
||||
*cache*
|
||||
*_cache*
|
||||
*backup*
|
||||
*.pyc
|
||||
|
||||
@@ -11,4 +11,4 @@ CACHE_PATH: Path = get_cache_dir('nonebot_plugin_tetris_stats')
|
||||
class Config(BaseModel):
|
||||
"""配置类"""
|
||||
|
||||
db_url: str = 'sqlite://data/nonebot_plugin_tetris_stats/data.db'
|
||||
tetris_req_timeout: float = 30.0
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Rename field
|
||||
|
||||
迁移 ID: 09d4bb60160d
|
||||
父迁移: b9d65badc713
|
||||
创建时间: 2024-04-23 23:42:04.541672
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '09d4bb60160d'
|
||||
down_revision: str | Sequence[str] | None = 'b9d65badc713'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_iorank', schema=None) as batch_op:
|
||||
batch_op.alter_column('create_time', new_column_name='update_time', existing_type=sa.DateTime())
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_iorank_create_time')
|
||||
op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_iorank_update_time'),
|
||||
'nonebot_plugin_tetris_stats_iorank',
|
||||
['update_time'],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_iorank', schema=None) as batch_op:
|
||||
batch_op.alter_column('update_time', new_column_name='create_time')
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_iorank_update_time'))
|
||||
op.create_index(
|
||||
'ix_nonebot_plugin_tetris_stats_iorank_create_time',
|
||||
'nonebot_plugin_tetris_stats_iorank',
|
||||
['create_time'],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add field
|
||||
|
||||
迁移 ID: 0d50142b780f
|
||||
父迁移: 09d4bb60160d
|
||||
创建时间: 2024-04-24 14:55:08.064098
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0d50142b780f'
|
||||
down_revision: str | Sequence[str] | None = '09d4bb60160d'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_iorank', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('file_hash', sa.String(length=128), nullable=True))
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_iorank_file_hash'), ['file_hash'], unique=False
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_iorank', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_iorank_file_hash'))
|
||||
batch_op.drop_column('file_hash')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Add redundant platform field
|
||||
|
||||
迁移 ID: 6c3206f90cc3
|
||||
父迁移: 9f6582279ce2
|
||||
创建时间: 2023-11-26 20:15:56.033892
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session
|
||||
from ujson import dumps, loads
|
||||
|
||||
revision: str = '6c3206f90cc3'
|
||||
down_revision: str | Sequence[str] | None = '9f6582279ce2'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
|
||||
Base = automap_base() # noqa: N806
|
||||
connection = op.get_bind()
|
||||
Base.prepare(autoload_with=connection)
|
||||
|
||||
HistoricalData = Base.classes.nonebot_plugin_tetris_stats_historicaldata # noqa: N806
|
||||
|
||||
with Session(connection) as session:
|
||||
for row in session.query(HistoricalData):
|
||||
platform = row.game_platform
|
||||
game_user = loads(row.game_user)
|
||||
processed_data = loads(row.processed_data)
|
||||
game_user['platform'] = platform
|
||||
processed_data['platform'] = platform
|
||||
row.game_user = dumps(game_user)
|
||||
row.processed_data = dumps(processed_data)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
|
||||
|
||||
def downgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
|
||||
Base = automap_base() # noqa: N806
|
||||
connection = op.get_bind()
|
||||
Base.prepare(autoload_with=connection)
|
||||
|
||||
HistoricalData = Base.classes.nonebot_plugin_tetris_stats_historicaldata # noqa: N806
|
||||
|
||||
with Session(connection) as session:
|
||||
for row in session.query(HistoricalData):
|
||||
game_user = loads(row.game_user)
|
||||
processed_data = loads(row.processed_data)
|
||||
game_user.pop('platform', None)
|
||||
processed_data.pop('platform', None)
|
||||
row.game_user = dumps(game_user)
|
||||
row.processed_data = dumps(processed_data)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
@@ -64,7 +64,7 @@ def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
try:
|
||||
db_path = Path(config.db_path)
|
||||
db_path = Path(config.db_url)
|
||||
except AttributeError:
|
||||
db_path = Path('data/nonebot_plugin_tetris_stats/data.db')
|
||||
if db_path.exists() is False:
|
||||
@@ -84,7 +84,7 @@ def upgrade(name: str = '') -> None:
|
||||
raise RuntimeError('nonebot_plugin_tetris_stats: 请先安装 0.4.4 版本完成迁移之后再升级')
|
||||
logger.info('nonebot_plugin_tetris_stats: 发现来自老版本的数据, 正在迁移...')
|
||||
migrate_old_data(connection)
|
||||
db_path.unlink()
|
||||
db_path.unlink()
|
||||
|
||||
|
||||
def downgrade(name: str = '') -> None:
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Recreate HistoricalData
|
||||
|
||||
迁移 ID: 9f6582279ce2
|
||||
父迁移: 9cd1647db502
|
||||
创建时间: 2023-11-21 08:35:50.393246
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import sqlite
|
||||
|
||||
from nonebot_plugin_tetris_stats.db.models import PydanticType
|
||||
|
||||
revision: str = '9f6582279ce2'
|
||||
down_revision: str | Sequence[str] | None = '9cd1647db502'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_command_type')
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_game_platform')
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_source_account')
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_source_type')
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_historicaldata')
|
||||
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_historicaldata',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('trigger_time', sa.DateTime(), nullable=False),
|
||||
sa.Column('bot_platform', sa.String(length=32), nullable=True),
|
||||
sa.Column('bot_account', sa.String(), nullable=True),
|
||||
sa.Column('source_type', sa.String(length=32), nullable=True),
|
||||
sa.Column('source_account', sa.String(), nullable=True),
|
||||
sa.Column('message', sa.PickleType(), nullable=True),
|
||||
sa.Column('game_platform', sa.String(length=32), nullable=False),
|
||||
sa.Column('command_type', sa.String(length=16), nullable=False),
|
||||
sa.Column('command_args', sa.JSON(), nullable=False),
|
||||
sa.Column('game_user', PydanticType(list), nullable=False),
|
||||
sa.Column('processed_data', PydanticType(list), nullable=False),
|
||||
sa.Column('finish_time', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_historicaldata')),
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_command_type'), ['command_type'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_game_platform'), ['game_platform'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_source_account'), ['source_account'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_source_type'), ['source_type'], unique=False
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_source_type'))
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_source_account'))
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_game_platform'))
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_command_type'))
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_historicaldata')
|
||||
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_historicaldata',
|
||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||
sa.Column('trigger_time', sa.DATETIME(), nullable=False),
|
||||
sa.Column('bot_platform', sa.VARCHAR(length=32), nullable=True),
|
||||
sa.Column('bot_account', sa.VARCHAR(), nullable=True),
|
||||
sa.Column('source_type', sa.VARCHAR(length=32), nullable=True),
|
||||
sa.Column('source_account', sa.VARCHAR(), nullable=True),
|
||||
sa.Column('message', sa.BLOB(), nullable=True),
|
||||
sa.Column('game_platform', sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column('command_type', sa.VARCHAR(length=16), nullable=False),
|
||||
sa.Column('command_args', sqlite.JSON(), nullable=False),
|
||||
sa.Column('game_user', sa.BLOB(), nullable=False),
|
||||
sa.Column('processed_data', sa.BLOB(), nullable=False),
|
||||
sa.Column('finish_time', sa.DATETIME(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name='pk_nonebot_plugin_tetris_stats_historicaldata'),
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
'ix_nonebot_plugin_tetris_stats_historicaldata_source_type', ['source_type'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
'ix_nonebot_plugin_tetris_stats_historicaldata_source_account', ['source_account'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
'ix_nonebot_plugin_tetris_stats_historicaldata_game_platform', ['game_platform'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
'ix_nonebot_plugin_tetris_stats_historicaldata_command_type', ['command_type'], unique=False
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Del old TOS bind data
|
||||
|
||||
迁移 ID: b9d65badc713
|
||||
父迁移: 6c3206f90cc3
|
||||
创建时间: 2023-12-30 00:27:40.991704
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
revision: str = 'b9d65badc713'
|
||||
down_revision: str | Sequence[str] | None = '6c3206f90cc3'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
|
||||
Base = automap_base() # noqa: N806
|
||||
connection = op.get_bind()
|
||||
Base.prepare(autoload_with=connection)
|
||||
|
||||
Bind = Base.classes.nonebot_plugin_tetris_stats_bind # noqa: N806
|
||||
with Session(connection) as session:
|
||||
session.query(Bind).filter(Bind.game_platform == 'TOS').delete()
|
||||
session.commit()
|
||||
|
||||
|
||||
def downgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
@@ -1,14 +1,41 @@
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from nonebot.adapters import Message
|
||||
from nonebot_plugin_orm import Model
|
||||
from sqlalchemy import JSON, DateTime, PickleType, String
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from sqlalchemy import JSON, DateTime, Dialect, PickleType, String, TypeDecorator
|
||||
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
|
||||
|
||||
from ..game_data_processor import ProcessedData, User
|
||||
from ..game_data_processor.schemas import BaseProcessedData, BaseUser
|
||||
from ..utils.typing import CommandType, GameType
|
||||
|
||||
|
||||
class PydanticType(TypeDecorator):
|
||||
impl = JSON
|
||||
|
||||
def __init__(self, get_model: Callable[[], Sequence[type[BaseModel]]], *args: Any, **kwargs: Any): # noqa: ANN401
|
||||
self.get_model = get_model
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def process_bind_param(self, value: Any | None, dialect: Dialect) -> str: # noqa: ANN401
|
||||
# 将 Pydantic 模型实例转换为 JSON
|
||||
if isinstance(value, tuple(self.get_model())):
|
||||
return value.json() # type: ignore[union-attr]
|
||||
raise TypeError
|
||||
|
||||
def process_result_value(self, value: Any | None, dialect: Dialect) -> BaseModel: # noqa: ANN401
|
||||
# 将 JSON 转换回 Pydantic 模型实例
|
||||
if isinstance(value, str | bytes):
|
||||
for i in self.get_model():
|
||||
try:
|
||||
return i.model_validate_json(value)
|
||||
except ValidationError: # noqa: PERF203
|
||||
...
|
||||
raise TypeError
|
||||
|
||||
|
||||
class Bind(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
chat_platform: Mapped[str] = mapped_column(String(32), index=True)
|
||||
@@ -28,6 +55,8 @@ class HistoricalData(MappedAsDataclass, Model):
|
||||
game_platform: Mapped[GameType] = mapped_column(String(32), index=True, init=False)
|
||||
command_type: Mapped[CommandType] = mapped_column(String(16), index=True, init=False)
|
||||
command_args: Mapped[list[str]] = mapped_column(JSON, init=False)
|
||||
game_user: Mapped[User] = mapped_column(PickleType, init=False)
|
||||
processed_data: Mapped[ProcessedData] = mapped_column(PickleType, init=False)
|
||||
game_user: Mapped[BaseUser] = mapped_column(PydanticType(get_model=BaseUser.__subclasses__), init=False)
|
||||
processed_data: Mapped[BaseProcessedData] = mapped_column(
|
||||
PydanticType(get_model=BaseProcessedData.__subclasses__), init=False
|
||||
)
|
||||
finish_time: Mapped[datetime] = mapped_column(DateTime, init=False)
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import AlcMatches, AlconnaMatcher
|
||||
|
||||
from ..utils.exception import MessageFormatError
|
||||
from ..utils.recorder import Recorder
|
||||
from ..utils.typing import CommandType, GameType
|
||||
from .schemas import BaseProcessedData as ProcessedData
|
||||
from .schemas import BaseRawResponse as RawResponse
|
||||
from .schemas import BaseUser as User
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""游戏用户"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RawResponse:
|
||||
"""原始请求数据"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessedData:
|
||||
"""处理/验证后的数据"""
|
||||
|
||||
|
||||
from ..utils.recorder import Recorder # noqa: E402 避免循环导入
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
class Processor(ABC):
|
||||
@@ -65,6 +57,9 @@ class Processor(ABC):
|
||||
|
||||
def __del__(self) -> None:
|
||||
finish_time = datetime.now(tz=UTC)
|
||||
if Recorder.is_error_event(self.event_id):
|
||||
Recorder.del_error_event(self.event_id)
|
||||
return
|
||||
historical_data = Recorder.get_historical_data(self.event_id)
|
||||
historical_data.game_platform = self.game_platform
|
||||
historical_data.command_type = self.command_type
|
||||
@@ -75,6 +70,24 @@ class Processor(ABC):
|
||||
Recorder.update_historical_data(self.event_id, historical_data)
|
||||
|
||||
|
||||
def add_default_handlers(matcher: type[AlconnaMatcher]) -> None:
|
||||
@matcher.handle()
|
||||
async def _(matcher: Matcher, account: MessageFormatError):
|
||||
await matcher.finish(str(account))
|
||||
|
||||
@matcher.handle()
|
||||
async def _(matcher: Matcher, matches: AlcMatches):
|
||||
if matches.head_matched and matches.options != {} or matches.main_args == {}:
|
||||
await matcher.finish(
|
||||
(f'{matches.error_info!r}\n' if matches.error_info is not None else '')
|
||||
+ f'输入"{matches.header_result} --help"查看帮助'
|
||||
)
|
||||
|
||||
@matcher.handle()
|
||||
async def _(matcher: Matcher, other: Any): # noqa: ANN401
|
||||
await matcher.finish()
|
||||
|
||||
|
||||
from . import ( # noqa: F401, E402
|
||||
io_data_processor,
|
||||
top_data_processor,
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from arclet.alconna import Alconna, Arg, ArgFlag, Args, CommandMeta, Option
|
||||
from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option
|
||||
from nonebot.adapters import Bot, Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import At, on_alconna
|
||||
from nonebot_plugin_orm import get_session
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ...db import query_bind_info
|
||||
from ...utils.exception import MessageFormatError, NeedCatchError
|
||||
from ...utils.exception import HandleNotFinishedError, NeedCatchError
|
||||
from ...utils.metrics import get_metrics
|
||||
from ...utils.platform import get_platform
|
||||
from ...utils.typing import Me
|
||||
from .. import add_default_handlers
|
||||
from ..constant import BIND_COMMAND, QUERY_COMMAND
|
||||
from .constant import GAME_TYPE
|
||||
from .model import IORank
|
||||
from .processor import Processor, User, check_rank_data, identify_user_info
|
||||
from .processor import Processor, User, identify_user_info
|
||||
from .typing import Rank
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
alc = on_alconna(
|
||||
Alconna(
|
||||
'io',
|
||||
@@ -65,6 +69,7 @@ alc = on_alconna(
|
||||
dest='rank',
|
||||
help_text='查询 IO 段位信息',
|
||||
),
|
||||
Arg('other', AllParam, flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL]),
|
||||
meta=CommandMeta(
|
||||
description='查询 TETR.IO 的信息',
|
||||
example='io绑定scdhh\nio查我\niorankx',
|
||||
@@ -90,7 +95,8 @@ async def _(bot: Bot, event: Event, matcher: Matcher, account: User):
|
||||
try:
|
||||
await matcher.finish(await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id()))
|
||||
except NeedCatchError as e:
|
||||
await matcher.finish(str(e))
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('query')
|
||||
@@ -113,7 +119,8 @@ async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me):
|
||||
try:
|
||||
await matcher.finish(message + await proc.handle_query())
|
||||
except NeedCatchError as e:
|
||||
await matcher.finish(str(e))
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('query')
|
||||
@@ -126,25 +133,37 @@ async def _(event: Event, matcher: Matcher, account: User):
|
||||
try:
|
||||
await matcher.finish(await proc.handle_query())
|
||||
except NeedCatchError as e:
|
||||
await matcher.finish(str(e))
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('rank')
|
||||
async def _(event: Event, matcher: Matcher, rank: Rank):
|
||||
async def _(matcher: Matcher, rank: Rank):
|
||||
if rank == 'z':
|
||||
await matcher.finish('暂不支持查询未知段位')
|
||||
try:
|
||||
await check_rank_data()
|
||||
except NeedCatchError as e:
|
||||
await matcher.finish(str(f'段位信息获取失败\n{e}'))
|
||||
async with get_session() as session:
|
||||
data = (
|
||||
await session.scalars(select(IORank).where(IORank.rank == rank).order_by(IORank.id.desc()).limit(5))
|
||||
).all()
|
||||
latest_data = data[0]
|
||||
message = f'{rank.upper()} 段 分数线 {latest_data.tr_line:.2f} TR, {latest_data.player_count} 名玩家\n'
|
||||
if len(data) > 1:
|
||||
message += f'对比 {(latest_data.create_time-data[-1].create_time).total_seconds()/3600:.2f} 小时前趋势: {f"↑{difference:.2f}" if (difference:=latest_data.tr_line-data[-1].tr_line) > 0 else f"↓{-difference:.2f}" if difference < 0 else "→"}'
|
||||
latest_data = (
|
||||
await session.scalars(select(IORank).where(IORank.rank == rank).order_by(IORank.id.desc()).limit(1))
|
||||
).one()
|
||||
compare_data = (
|
||||
await session.scalars(
|
||||
select(IORank)
|
||||
.where(IORank.rank == rank)
|
||||
.order_by(
|
||||
func.abs(
|
||||
func.julianday(IORank.update_time)
|
||||
- func.julianday(latest_data.update_time - timedelta(hours=24))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).one()
|
||||
message = ''
|
||||
if (datetime.now(UTC) - latest_data.update_time.replace(tzinfo=UTC)) > timedelta(hours=7):
|
||||
message += 'Warning: 数据超过7小时未更新, 请联系Bot主人查看后台\n'
|
||||
message += f'{rank.upper()} 段 分数线 {latest_data.tr_line:.2f} TR, {latest_data.player_count} 名玩家\n'
|
||||
if compare_data.id != latest_data.id:
|
||||
message += f'对比 {(latest_data.update_time-compare_data.update_time).total_seconds()/3600:.2f} 小时前趋势: {f"↑{difference:.2f}" if (difference:=latest_data.tr_line-compare_data.tr_line) > 0 else f"↓{-difference:.2f}" if difference < 0 else "→"}'
|
||||
else:
|
||||
message += '暂无对比数据'
|
||||
avg = get_metrics(pps=latest_data.avg_pps, apm=latest_data.avg_apm, vs=latest_data.avg_vs)
|
||||
@@ -169,11 +188,9 @@ async def _(event: Event, matcher: Matcher, rank: Rank):
|
||||
f'APM: {latest_data.high_apm[1]} By: {latest_data.high_apm[0]["name"].upper()}\n'
|
||||
f'ADPM: {max_vs.adpm} ( {max_vs.vs}vs ) By: {latest_data.high_vs[0]["name"].upper()}\n'
|
||||
'\n'
|
||||
f'数据更新时间: {(latest_data.create_time+timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S")}'
|
||||
f'数据更新时间: {latest_data.update_time.replace(tzinfo=UTC).astimezone(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")}'
|
||||
)
|
||||
await matcher.finish(message)
|
||||
|
||||
|
||||
@alc.handle()
|
||||
async def _(matcher: Matcher, account: MessageFormatError):
|
||||
await matcher.finish(str(account))
|
||||
add_default_handlers(alc)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiocache import Cache as ACache # type: ignore[import-untyped]
|
||||
from nonebot.compat import type_validate_json
|
||||
from nonebot.log import logger
|
||||
|
||||
from ...utils.request import Request
|
||||
from .schemas.base import FailedModel, SuccessModel
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
class Cache:
|
||||
cache = ACache(ACache.MEMORY)
|
||||
|
||||
@classmethod
|
||||
async def get(cls, url: str) -> bytes:
|
||||
cached_data = await cls.cache.get(url)
|
||||
if cached_data is None:
|
||||
response_data = await Request.request(url)
|
||||
parsed_data: SuccessModel | FailedModel = type_validate_json(SuccessModel | FailedModel, response_data) # type: ignore[arg-type]
|
||||
if isinstance(parsed_data, SuccessModel):
|
||||
await cls.cache.add(
|
||||
url,
|
||||
response_data,
|
||||
(parsed_data.cache.cached_until - datetime.now(UTC)).total_seconds(),
|
||||
)
|
||||
return response_data
|
||||
logger.debug(f'{url}: Cache hit!')
|
||||
return cached_data
|
||||
@@ -1,7 +1,8 @@
|
||||
from ...utils.typing import GameType
|
||||
from typing import Literal
|
||||
|
||||
from .typing import Rank
|
||||
|
||||
GAME_TYPE: GameType = 'IO'
|
||||
GAME_TYPE: Literal['IO'] = 'IO'
|
||||
BASE_URL = 'https://ch.tetr.io/api/'
|
||||
RANK_PERCENTILE: dict[Rank, float] = {
|
||||
'x': 1,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from nonebot_plugin_orm import Model
|
||||
from sqlalchemy import JSON, DateTime, String
|
||||
@@ -6,6 +6,8 @@ from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
|
||||
|
||||
from .typing import Rank
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
class IORank(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
@@ -21,9 +23,8 @@ class IORank(MappedAsDataclass, Model):
|
||||
high_pps: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
|
||||
high_apm: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
|
||||
high_vs: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
update_time: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=lambda: datetime.now(tz=UTC),
|
||||
index=True,
|
||||
init=False,
|
||||
)
|
||||
file_hash: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
|
||||
@@ -1,63 +1,48 @@
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from hashlib import sha512
|
||||
from math import floor
|
||||
from re import match
|
||||
from statistics import mean
|
||||
from typing import Literal
|
||||
|
||||
from aiofiles import open
|
||||
from nonebot import get_driver
|
||||
from nonebot.compat import type_validate_json
|
||||
from nonebot.utils import run_sync
|
||||
from nonebot_plugin_apscheduler import scheduler # type: ignore[import-untyped]
|
||||
from nonebot_plugin_localstore import get_data_file # type: ignore[import-untyped]
|
||||
from nonebot_plugin_orm import get_session
|
||||
from pydantic import parse_raw_as
|
||||
from sqlalchemy import select
|
||||
from zstandard import ZstdCompressor
|
||||
|
||||
from ...db import create_or_update_bind
|
||||
from ...utils.exception import MessageFormatError, RequestError, WhatTheFuckError
|
||||
from ...utils.request import Request, splice_url
|
||||
from ...utils.typing import GameType
|
||||
from .. import ProcessedData as ProcessedDataMeta
|
||||
from ...utils.request import splice_url
|
||||
from ...utils.retry import retry
|
||||
from .. import Processor as ProcessorMeta
|
||||
from .. import RawResponse as RawResponseMeta
|
||||
from .. import User as UserMeta
|
||||
from .cache import Cache
|
||||
from .constant import BASE_URL, GAME_TYPE, RANK_PERCENTILE
|
||||
from .model import IORank
|
||||
from .schemas.league_all import FailedModel as LeagueAllFailed
|
||||
from .schemas.league_all import LeagueAll
|
||||
from .schemas.league_all import ValidUser as LeagueAllUser
|
||||
from .schemas.response import ProcessedData, RawResponse
|
||||
from .schemas.user import User
|
||||
from .schemas.user_info import FailedModel as InfoFailed
|
||||
from .schemas.user_info import (
|
||||
NeverPlayedLeague,
|
||||
NeverRatedLeague,
|
||||
UserInfo,
|
||||
)
|
||||
from .schemas.user_info import NeverPlayedLeague, NeverRatedLeague, UserInfo
|
||||
from .schemas.user_info import SuccessModel as InfoSuccess
|
||||
from .schemas.user_records import FailedModel as RecordsFailed
|
||||
from .schemas.user_records import SoloRecord, UserRecords
|
||||
from .schemas.user_records import SuccessModel as RecordsSuccess
|
||||
from .typing import Rank
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
|
||||
@dataclass
|
||||
class User(UserMeta):
|
||||
ID: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RawResponse(RawResponseMeta):
|
||||
user_info: bytes | None = None
|
||||
user_records: bytes | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessedData(ProcessedDataMeta):
|
||||
user_info: InfoSuccess | None = None
|
||||
user_records: RecordsSuccess | None = None
|
||||
|
||||
|
||||
def identify_user_info(info: str) -> User | MessageFormatError:
|
||||
if match(r'^[a-f0-9]{24}$', info):
|
||||
return User(ID=info)
|
||||
@@ -77,7 +62,7 @@ class Processor(ProcessorMeta):
|
||||
self.processed_data = ProcessedData()
|
||||
|
||||
@property
|
||||
def game_platform(self) -> GameType:
|
||||
def game_platform(self) -> Literal['IO']:
|
||||
return GAME_TYPE
|
||||
|
||||
async def handle_bind(self, platform: str, account: str) -> str:
|
||||
@@ -113,10 +98,10 @@ class Processor(ProcessorMeta):
|
||||
async def get_user_info(self) -> InfoSuccess:
|
||||
"""获取用户数据"""
|
||||
if self.processed_data.user_info is None:
|
||||
self.raw_response.user_info = await Request.request(
|
||||
self.raw_response.user_info = await Cache.get(
|
||||
splice_url([BASE_URL, 'users/', f'{self.user.ID or self.user.name}'])
|
||||
)
|
||||
user_info: UserInfo = parse_raw_as(UserInfo, self.raw_response.user_info) # type: ignore[arg-type]
|
||||
user_info: UserInfo = type_validate_json(UserInfo, self.raw_response.user_info) # type: ignore[arg-type]
|
||||
if isinstance(user_info, InfoFailed):
|
||||
raise RequestError(f'用户信息请求错误:\n{user_info.error}')
|
||||
self.processed_data.user_info = user_info
|
||||
@@ -125,20 +110,10 @@ class Processor(ProcessorMeta):
|
||||
async def get_user_records(self) -> RecordsSuccess:
|
||||
"""获取Solo数据"""
|
||||
if self.processed_data.user_records is None:
|
||||
self.raw_response.user_records = await Request.request(
|
||||
splice_url(
|
||||
[
|
||||
BASE_URL,
|
||||
'users/',
|
||||
f'{self.user.ID or self.user.name}/',
|
||||
'records',
|
||||
]
|
||||
)
|
||||
)
|
||||
user_records: UserRecords = parse_raw_as(
|
||||
UserRecords, # type: ignore[arg-type]
|
||||
self.raw_response.user_records,
|
||||
self.raw_response.user_records = await Cache.get(
|
||||
splice_url([BASE_URL, 'users/', f'{self.user.ID or self.user.name}/', 'records'])
|
||||
)
|
||||
user_records: UserRecords = type_validate_json(UserRecords, self.raw_response.user_records) # type: ignore[arg-type]
|
||||
if isinstance(user_records, RecordsFailed):
|
||||
raise RequestError(f'用户Solo数据请求错误:\n{user_records.error}')
|
||||
self.processed_data.user_records = user_records
|
||||
@@ -155,12 +130,13 @@ class Processor(ProcessorMeta):
|
||||
else:
|
||||
if isinstance(league, NeverRatedLeague):
|
||||
ret_message += f'用户 {user_name} 暂未完成定级赛, 最近十场的数据:'
|
||||
elif league.rank == 'z':
|
||||
ret_message += f'用户 {user_name} 暂无段位, {round(league.rating,2)} TR'
|
||||
else:
|
||||
ret_message += (
|
||||
f'{league.rank.upper()} 段用户 {user_name} {round(league.rating,2)} TR (#{league.standing})'
|
||||
)
|
||||
if league.rank == 'z':
|
||||
ret_message += f'用户 {user_name} 暂无段位, {round(league.rating,2)} TR'
|
||||
else:
|
||||
ret_message += (
|
||||
f'{league.rank.upper()} 段用户 {user_name} {round(league.rating,2)} TR (#{league.standing})'
|
||||
)
|
||||
ret_message += f', 段位分 {round(league.glicko,2)}±{round(league.rd,2)}, 最近十场的数据:'
|
||||
lpm = league.pps * 24
|
||||
ret_message += f"\nL'PM: {round(lpm, 2)} ( {league.pps} pps )"
|
||||
@@ -185,13 +161,14 @@ class Processor(ProcessorMeta):
|
||||
|
||||
|
||||
@scheduler.scheduled_job('cron', hour='0,6,12,18', minute=0)
|
||||
@retry(exception_type=RequestError, delay=timedelta(minutes=15))
|
||||
async def get_io_rank_data() -> None:
|
||||
league_all: LeagueAll = parse_raw_as(
|
||||
league_all: LeagueAll = type_validate_json(
|
||||
LeagueAll, # type: ignore[arg-type]
|
||||
await Request.request(splice_url([BASE_URL, 'users/lists/league/all'])),
|
||||
(data := await Cache.get(splice_url([BASE_URL, 'users/lists/league/all']))),
|
||||
)
|
||||
if isinstance(league_all, LeagueAllFailed):
|
||||
raise RequestError(f'用户Solo数据请求错误:\n{league_all.error}')
|
||||
raise RequestError(f'排行榜数据请求错误:\n{league_all.error}')
|
||||
|
||||
def pps(user: LeagueAllUser) -> float:
|
||||
return user.league.pps
|
||||
@@ -214,7 +191,11 @@ async def get_io_rank_data() -> None:
|
||||
sort: Callable[[list[LeagueAllUser], Callable[[LeagueAllUser], float]], LeagueAllUser],
|
||||
) -> tuple[dict[str, str], float]:
|
||||
user = sort(users, field)
|
||||
return asdict(User(ID=user.id, name=user.username)), field(user)
|
||||
return User(ID=user.id, name=user.username).dict(), field(user)
|
||||
|
||||
data_hash: str | None = await run_sync((await run_sync(sha512)(data)).hexdigest)()
|
||||
async with open(get_data_file('nonebot_plugin_tetris_stats', f'{data_hash}.json.zst'), mode='wb') as file:
|
||||
await file.write(await run_sync(ZstdCompressor(level=12, threads=-1).compress)(data))
|
||||
|
||||
users = [i for i in league_all.data.users if isinstance(i, LeagueAllUser)]
|
||||
rank_to_users: defaultdict[Rank, list[LeagueAllUser]] = defaultdict(list)
|
||||
@@ -239,6 +220,8 @@ async def get_io_rank_data() -> None:
|
||||
high_pps=(build_extremes_data(rank_users, pps, _max)),
|
||||
high_apm=(build_extremes_data(rank_users, apm, _max)),
|
||||
high_vs=(build_extremes_data(rank_users, vs, _max)),
|
||||
update_time=league_all.cache.cached_at,
|
||||
file_hash=data_hash,
|
||||
)
|
||||
)
|
||||
async with get_session() as session:
|
||||
@@ -247,8 +230,8 @@ async def get_io_rank_data() -> None:
|
||||
|
||||
|
||||
@driver.on_startup
|
||||
async def check_rank_data() -> None:
|
||||
async def _() -> None:
|
||||
async with get_session() as session:
|
||||
latest_time = await session.scalar(select(IORank.create_time).order_by(IORank.id.desc()).limit(1))
|
||||
if latest_time is None or datetime.now(tz=UTC) - latest_time.replace(tzinfo=UTC) > timedelta(hours=6):
|
||||
await get_io_rank_data()
|
||||
latest_time = await session.scalar(select(IORank.update_time).order_by(IORank.id.desc()).limit(1))
|
||||
if latest_time is None or datetime.now(tz=UTC) - latest_time.replace(tzinfo=UTC) > timedelta(hours=6):
|
||||
await get_io_rank_data()
|
||||
|
||||
@@ -28,20 +28,20 @@ class SuccessModel(BaseSuccessModel):
|
||||
league: League
|
||||
supporter: bool
|
||||
verified: bool
|
||||
country: str | None
|
||||
country: str | None = None
|
||||
|
||||
class InvalidUser(BaseModel):
|
||||
class League(BaseModel):
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
rating: float
|
||||
glicko: float | None
|
||||
rd: float | None
|
||||
glicko: float | None = None
|
||||
rd: float | None = None
|
||||
rank: Rank
|
||||
bestrank: Rank
|
||||
apm: float | None
|
||||
pps: float | None
|
||||
vs: float | None
|
||||
apm: float | None = None
|
||||
pps: float | None = None
|
||||
vs: float | None = None
|
||||
decaying: bool
|
||||
|
||||
id: str = Field(..., alias='_id')
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from typing import Literal
|
||||
|
||||
from ... import ProcessedData as ProcessedDataMeta
|
||||
from ... import RawResponse as RawResponseMeta
|
||||
from ..constant import GAME_TYPE
|
||||
from .user_info import SuccessModel as InfoSuccess
|
||||
from .user_records import SuccessModel as RecordsSuccess
|
||||
|
||||
|
||||
class RawResponse(RawResponseMeta):
|
||||
platform: Literal['IO'] = GAME_TYPE
|
||||
|
||||
user_info: bytes | None = None
|
||||
user_records: bytes | None = None
|
||||
|
||||
|
||||
class ProcessedData(ProcessedDataMeta):
|
||||
platform: Literal['IO'] = GAME_TYPE
|
||||
|
||||
user_info: InfoSuccess | None = None
|
||||
user_records: RecordsSuccess | None = None
|
||||
@@ -0,0 +1,17 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...schemas import BaseUser
|
||||
from ..constant import GAME_TYPE
|
||||
|
||||
|
||||
class User(BaseUser):
|
||||
platform: Literal['IO'] = GAME_TYPE
|
||||
|
||||
ID: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
@property
|
||||
def unique_identifier(self) -> str:
|
||||
if self.ID is None:
|
||||
raise ValueError('不完整的User!')
|
||||
return self.ID
|
||||
@@ -14,7 +14,7 @@ class SuccessModel(BaseSuccessModel):
|
||||
class Badge(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
ts: datetime | None
|
||||
ts: datetime | None = None
|
||||
|
||||
class NeverPlayedLeague(BaseModel):
|
||||
gamesplayed: Literal[0]
|
||||
@@ -29,9 +29,9 @@ class SuccessModel(BaseSuccessModel):
|
||||
prev_at: Literal[-1]
|
||||
percentile: Literal[-1]
|
||||
percentile_rank: Literal['z']
|
||||
apm: None
|
||||
pps: None
|
||||
vs: None
|
||||
apm: None = Field(None)
|
||||
pps: None = Field(None)
|
||||
vs: None = Field(None)
|
||||
decaying: bool
|
||||
|
||||
class NeverRatedLeague(BaseModel):
|
||||
@@ -60,8 +60,8 @@ class SuccessModel(BaseSuccessModel):
|
||||
bestrank: Rank
|
||||
standing: int
|
||||
standing_local: int
|
||||
next_rank: Rank | None
|
||||
prev_rank: Rank | None
|
||||
next_rank: Rank | None = None
|
||||
prev_rank: Rank | None = None
|
||||
next_at: int
|
||||
prev_at: int
|
||||
percentile: float
|
||||
@@ -70,7 +70,7 @@ class SuccessModel(BaseSuccessModel):
|
||||
rd: float
|
||||
apm: float
|
||||
pps: float
|
||||
vs: float | None
|
||||
vs: float | None = None
|
||||
decaying: bool
|
||||
|
||||
class Connections(BaseModel):
|
||||
@@ -78,41 +78,41 @@ class SuccessModel(BaseSuccessModel):
|
||||
id: str
|
||||
username: str
|
||||
|
||||
discord: Discord | None
|
||||
discord: Discord | None = None
|
||||
|
||||
class Distinguishment(BaseModel):
|
||||
type: str # noqa: A003
|
||||
type: str
|
||||
|
||||
id: str = Field(..., alias='_id')
|
||||
username: str
|
||||
role: Literal['anon', 'user', 'bot', 'halfmod', 'mod', 'admin', 'sysop', 'banned']
|
||||
ts: datetime | None
|
||||
botmaster: str | None
|
||||
ts: datetime | None = None
|
||||
botmaster: str | None = None
|
||||
badges: list[Badge]
|
||||
xp: float
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
gametime: float
|
||||
country: str | None
|
||||
badstanding: bool | None
|
||||
supporter: bool | None # osk说是必有, 但实际上不是 fk osk
|
||||
country: str | None = None
|
||||
badstanding: bool | None = None
|
||||
supporter: bool | None = None # osk说是必有, 但实际上不是 fk osk
|
||||
supporter_tier: int
|
||||
verified: bool
|
||||
league: NeverPlayedLeague | NeverRatedLeague | RatedLeague
|
||||
avatar_revision: int | None
|
||||
avatar_revision: int | None = None
|
||||
"""This user's avatar ID. Get their avatar at
|
||||
|
||||
https://tetr.io/user-content/avatars/{ USERID }.jpg?rv={ AVATAR_REVISION }"""
|
||||
banner_revision: int | None
|
||||
banner_revision: int | None = None
|
||||
"""This user's banner ID. Get their banner at
|
||||
|
||||
https://tetr.io/user-content/banners/{ USERID }.jpg?rv={ BANNER_REVISION }
|
||||
|
||||
Ignore this field if the user is not a supporter."""
|
||||
bio: str | None
|
||||
bio: str | None = None
|
||||
connections: Connections
|
||||
friend_count: int
|
||||
distinguishment: Distinguishment | None
|
||||
friend_count: int | None = None
|
||||
distinguishment: Distinguishment | None = None
|
||||
|
||||
user: User
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@ class EndContext(BaseModel):
|
||||
zero: bool
|
||||
locked: bool
|
||||
prev: int
|
||||
frameoffset: int
|
||||
frameoffset: int | None = None
|
||||
|
||||
class Clears(BaseModel):
|
||||
singles: int
|
||||
doubles: int
|
||||
triples: int
|
||||
quads: int
|
||||
pentas: int | None
|
||||
pentas: int | None = None
|
||||
realtspins: int
|
||||
minitspins: int
|
||||
minitspinsingles: int
|
||||
@@ -33,7 +33,7 @@ class EndContext(BaseModel):
|
||||
class Garbage(BaseModel):
|
||||
sent: int
|
||||
received: int
|
||||
attack: int | None
|
||||
attack: int | None = None
|
||||
cleared: int
|
||||
|
||||
class Finesse(BaseModel):
|
||||
@@ -46,18 +46,18 @@ class EndContext(BaseModel):
|
||||
level_lines: int
|
||||
level_lines_needed: int
|
||||
inputs: int
|
||||
holds: int | None
|
||||
holds: int | None = None
|
||||
time: Time
|
||||
score: int
|
||||
zenlevel: int
|
||||
zenprogress: int
|
||||
zenlevel: int | None = None
|
||||
zenprogress: int | None = None
|
||||
level: int
|
||||
combo: int
|
||||
currentcombopower: int | None # WTF
|
||||
currentcombopower: int | None = None # WTF
|
||||
topcombo: int
|
||||
btb: int
|
||||
topbtb: int
|
||||
currentbtbchainpower: int | None # WTF * 2
|
||||
currentbtbchainpower: int | None = None # WTF * 2
|
||||
tspins: int
|
||||
piecesplaced: int
|
||||
clears: Clears
|
||||
@@ -79,7 +79,7 @@ class BaseModeRecord(BaseModel):
|
||||
replayid: str
|
||||
user: User
|
||||
ts: datetime
|
||||
ismulti: bool | None
|
||||
ismulti: bool | None = None
|
||||
endcontext: EndContext
|
||||
|
||||
class MultiRecord(BaseModel):
|
||||
@@ -92,21 +92,19 @@ class BaseModeRecord(BaseModel):
|
||||
replayid: str
|
||||
user: User
|
||||
ts: datetime
|
||||
ismulti: bool | None
|
||||
ismulti: bool | None = None
|
||||
endcontext: list[EndContext]
|
||||
|
||||
record: SoloRecord | MultiRecord | None
|
||||
rank: int | None
|
||||
record: SoloRecord | MultiRecord | None = None
|
||||
rank: int | None = None
|
||||
|
||||
|
||||
class SuccessModel(BaseSuccessModel):
|
||||
class Data(BaseModel):
|
||||
class Records(BaseModel):
|
||||
class Sprint(BaseModeRecord):
|
||||
...
|
||||
class Sprint(BaseModeRecord): ...
|
||||
|
||||
class Blitz(BaseModeRecord):
|
||||
...
|
||||
class Blitz(BaseModeRecord): ...
|
||||
|
||||
sprint: Sprint = Field(..., alias='40l')
|
||||
blitz: Blitz
|
||||
|
||||
31
nonebot_plugin_tetris_stats/game_data_processor/schemas.py
Normal file
31
nonebot_plugin_tetris_stats/game_data_processor/schemas.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..utils.typing import GameType
|
||||
|
||||
|
||||
class Base(BaseModel):
|
||||
platform: GameType
|
||||
|
||||
|
||||
class BaseUser(ABC, Base):
|
||||
"""游戏用户"""
|
||||
|
||||
def __eq__(self, __value: object) -> bool:
|
||||
if isinstance(__value, BaseUser):
|
||||
return self.unique_identifier == __value.unique_identifier
|
||||
return False
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def unique_identifier(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class BaseRawResponse(Base):
|
||||
"""原始请求数据"""
|
||||
|
||||
|
||||
class BaseProcessedData(Base):
|
||||
"""处理/验证后的数据"""
|
||||
@@ -1,13 +1,14 @@
|
||||
from arclet.alconna import Alconna, Arg, ArgFlag, Args, CommandMeta, Option
|
||||
from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option
|
||||
from nonebot.adapters import Bot, Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import At, on_alconna
|
||||
from nonebot_plugin_orm import get_session
|
||||
|
||||
from ...db import query_bind_info
|
||||
from ...utils.exception import MessageFormatError, NeedCatchError
|
||||
from ...utils.exception import HandleNotFinishedError, NeedCatchError
|
||||
from ...utils.platform import get_platform
|
||||
from ...utils.typing import Me
|
||||
from .. import add_default_handlers
|
||||
from ..constant import BIND_COMMAND, QUERY_COMMAND
|
||||
from .constant import GAME_TYPE
|
||||
from .processor import Processor, User, identify_user_info
|
||||
@@ -51,6 +52,7 @@ alc = on_alconna(
|
||||
dest='query',
|
||||
help_text='查询 TOP 游戏信息',
|
||||
),
|
||||
Arg('other', AllParam, flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL]),
|
||||
meta=CommandMeta(
|
||||
description='查询 TetrisOnline波兰服 的信息',
|
||||
example='top绑定scdhh\ntop查我',
|
||||
@@ -74,7 +76,8 @@ async def _(bot: Bot, event: Event, matcher: Matcher, account: User):
|
||||
try:
|
||||
await matcher.finish(await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id()))
|
||||
except NeedCatchError as e:
|
||||
await matcher.finish(str(e))
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('query')
|
||||
@@ -97,7 +100,8 @@ async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me):
|
||||
try:
|
||||
await matcher.finish(message + await proc.handle_query())
|
||||
except NeedCatchError as e:
|
||||
await matcher.finish(str(e))
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('query')
|
||||
@@ -110,9 +114,8 @@ async def _(event: Event, matcher: Matcher, account: User):
|
||||
try:
|
||||
await matcher.finish(await proc.handle_query())
|
||||
except NeedCatchError as e:
|
||||
await matcher.finish(str(e))
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.handle()
|
||||
async def _(matcher: Matcher, account: MessageFormatError):
|
||||
await matcher.finish(str(account))
|
||||
add_default_handlers(alc)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from ...utils.typing import GameType
|
||||
from typing import Literal
|
||||
|
||||
GAME_TYPE: GameType = 'TOP'
|
||||
GAME_TYPE: Literal['TOP'] = 'TOP'
|
||||
BASE_URL = 'http://tetrisonline.pl/top/'
|
||||
|
||||
@@ -2,7 +2,7 @@ from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from io import StringIO
|
||||
from re import match
|
||||
from typing import NoReturn
|
||||
from typing import Literal
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from lxml import etree
|
||||
@@ -12,27 +12,20 @@ from pandas import read_html
|
||||
from ...db import create_or_update_bind
|
||||
from ...utils.exception import MessageFormatError, RequestError
|
||||
from ...utils.request import Request, splice_url
|
||||
from ...utils.typing import GameType
|
||||
from .. import ProcessedData as ProcessedDataMeta
|
||||
from .. import Processor as ProcessorMeta
|
||||
from .. import RawResponse as RawResponseMeta
|
||||
from .. import User as UserMeta
|
||||
from ..schemas import BaseUser
|
||||
from .constant import BASE_URL, GAME_TYPE
|
||||
from .schemas.response import ProcessedData, RawResponse
|
||||
|
||||
|
||||
@dataclass
|
||||
class User(UserMeta):
|
||||
class User(BaseUser):
|
||||
platform: Literal['TOP'] = GAME_TYPE
|
||||
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class RawResponse(RawResponseMeta):
|
||||
user_profile: bytes | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessedData(ProcessedDataMeta):
|
||||
user_profile: str | None = None
|
||||
@property
|
||||
def unique_identifier(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -64,7 +57,7 @@ class Processor(ProcessorMeta):
|
||||
self.processed_data = ProcessedData()
|
||||
|
||||
@property
|
||||
def game_platform(self) -> GameType:
|
||||
def game_platform(self) -> Literal['TOP']:
|
||||
return GAME_TYPE
|
||||
|
||||
async def handle_bind(self, platform: str, account: str) -> str:
|
||||
@@ -94,10 +87,9 @@ class Processor(ProcessorMeta):
|
||||
self.processed_data.user_profile = self.raw_response.user_profile.decode()
|
||||
return self.processed_data.user_profile
|
||||
|
||||
async def check_user(self) -> None | NoReturn:
|
||||
async def check_user(self) -> None:
|
||||
if 'user not found!' in await self.get_user_profile():
|
||||
raise RequestError('用户不存在!')
|
||||
return None
|
||||
|
||||
async def get_user_name(self) -> str:
|
||||
"""获取用户名"""
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...schemas import BaseProcessedData, BaseRawResponse
|
||||
from ..constant import GAME_TYPE
|
||||
|
||||
|
||||
class RawResponse(BaseRawResponse):
|
||||
platform: Literal['TOP'] = GAME_TYPE
|
||||
|
||||
user_profile: bytes | None = None
|
||||
|
||||
|
||||
class ProcessedData(BaseProcessedData):
|
||||
platform: Literal['TOP'] = GAME_TYPE
|
||||
|
||||
user_profile: str | None = None
|
||||
@@ -1,13 +1,16 @@
|
||||
from arclet.alconna import Alconna, Arg, ArgFlag, Args, CommandMeta, Option
|
||||
from typing import NoReturn
|
||||
|
||||
from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option
|
||||
from nonebot.adapters import Bot, Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import At, on_alconna
|
||||
from nonebot_plugin_orm import get_session
|
||||
|
||||
from ...db import query_bind_info
|
||||
from ...utils.exception import MessageFormatError, NeedCatchError
|
||||
from ...utils.exception import HandleNotFinishedError, NeedCatchError, RequestError
|
||||
from ...utils.platform import get_platform
|
||||
from ...utils.typing import Me
|
||||
from .. import add_default_handlers
|
||||
from ..constant import BIND_COMMAND, QUERY_COMMAND
|
||||
from .constant import GAME_TYPE
|
||||
from .processor import Processor, User, identify_user_info
|
||||
@@ -52,6 +55,7 @@ alc = on_alconna(
|
||||
dest='query',
|
||||
help_text='查询 茶服 游戏信息',
|
||||
),
|
||||
Arg('other', AllParam, flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL]),
|
||||
meta=CommandMeta(
|
||||
description='查询 TetrisOnline茶服 的信息',
|
||||
example='茶服查我',
|
||||
@@ -64,27 +68,63 @@ alc = on_alconna(
|
||||
aliases={'tos', 'TOS'},
|
||||
)
|
||||
|
||||
try:
|
||||
from nonebot.adapters.onebot.v11 import GROUP, MessageEvent
|
||||
from nonebot.adapters.onebot.v11 import Bot as OB11Bot
|
||||
|
||||
@alc.assign('bind')
|
||||
async def _(event: MessageEvent, matcher: Matcher):
|
||||
await matcher.finish('QQ 平台无需绑定')
|
||||
async def finish_special_query(matcher: Matcher, proc: Processor) -> NoReturn:
|
||||
try:
|
||||
await matcher.finish(await proc.handle_query())
|
||||
except NeedCatchError as e:
|
||||
if isinstance(e, RequestError) and '未找到此用户' in e.message:
|
||||
matcher.skip()
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
try:
|
||||
from nonebot.adapters.onebot.v11 import GROUP as OB11GROUP
|
||||
from nonebot.adapters.onebot.v11 import Bot as OB11Bot
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent as OB11MessageEvent
|
||||
|
||||
@alc.assign('query')
|
||||
async def _(bot: OB11Bot, event: MessageEvent, matcher: Matcher, target: At | Me):
|
||||
if event.is_tome() and await GROUP(bot, event):
|
||||
async def _(bot: OB11Bot, event: OB11MessageEvent, matcher: Matcher, target: At | Me):
|
||||
if event.is_tome() and await OB11GROUP(bot, event):
|
||||
await matcher.finish('不能查询bot的信息')
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=User(teaid=target.target if isinstance(target, At) else event.get_user_id()),
|
||||
user=User(teaid=f'onebot-{target.target}' if isinstance(target, At) else f'onebot-{event.get_user_id()}'),
|
||||
command_args=[],
|
||||
)
|
||||
try:
|
||||
await matcher.finish(await proc.handle_query())
|
||||
except NeedCatchError as e:
|
||||
await matcher.finish(str(e))
|
||||
await finish_special_query(matcher, proc)
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from nonebot.adapters.kaiheila.event import MessageEvent as KookMessageEvent
|
||||
|
||||
@alc.assign('query')
|
||||
async def _(event: KookMessageEvent, matcher: Matcher, target: At | Me):
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=User(teaid=f'kook-{target.target}' if isinstance(target, At) else f'kook-{event.get_user_id()}'),
|
||||
command_args=[],
|
||||
)
|
||||
await finish_special_query(matcher, proc)
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from nonebot.adapters.discord import MessageEvent as DiscordMessageEvent
|
||||
|
||||
@alc.assign('query')
|
||||
async def _(event: DiscordMessageEvent, matcher: Matcher, target: At | Me):
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=User(teaid=f'discord-{target.target}' if isinstance(target, At) else f'discord-{event.get_user_id()}'),
|
||||
command_args=[],
|
||||
)
|
||||
await finish_special_query(matcher, proc)
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -99,7 +139,8 @@ async def _(bot: Bot, event: Event, matcher: Matcher, account: User):
|
||||
try:
|
||||
await matcher.finish(await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id()))
|
||||
except NeedCatchError as e:
|
||||
await matcher.finish(str(e))
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('query')
|
||||
@@ -116,13 +157,14 @@ async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me):
|
||||
message = '* 由于无法验证绑定信息, 不能保证查询到的用户为本人\n'
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=User(name=bind.game_account),
|
||||
user=User(teaid=bind.game_account),
|
||||
command_args=[],
|
||||
)
|
||||
try:
|
||||
await matcher.finish(message + await proc.handle_query())
|
||||
except NeedCatchError as e:
|
||||
await matcher.finish(str(e))
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('query')
|
||||
@@ -135,9 +177,8 @@ async def _(event: Event, matcher: Matcher, account: User):
|
||||
try:
|
||||
await matcher.finish(await proc.handle_query())
|
||||
except NeedCatchError as e:
|
||||
await matcher.finish(str(e))
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.handle()
|
||||
async def _(matcher: Matcher, account: MessageFormatError):
|
||||
await matcher.finish(str(account))
|
||||
add_default_handlers(alc)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
from ...utils.typing import GameType
|
||||
from typing import Literal
|
||||
|
||||
GAME_TYPE: GameType = 'TOS'
|
||||
BASE_URL = 'https://teatube.cn:8888/'
|
||||
GAME_TYPE: Literal['TOS'] = 'TOS'
|
||||
BASE_URL = {
|
||||
'https://teatube.cn:8888/',
|
||||
'http://cafuuchino1.studio26f.org:19970',
|
||||
'http://cafuuchino2.studio26f.org:19970',
|
||||
'http://cafuuchino3.studio26f.org:19970',
|
||||
'http://cafuuchino4.studio26f.org:19970',
|
||||
}
|
||||
|
||||
@@ -1,41 +1,35 @@
|
||||
from dataclasses import dataclass
|
||||
from re import match
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from httpx import TimeoutException
|
||||
from nonebot.compat import type_validate_json
|
||||
from nonebot_plugin_orm import get_session
|
||||
from pydantic import parse_raw_as
|
||||
|
||||
from ...db import create_or_update_bind
|
||||
from ...utils.exception import MessageFormatError, RequestError
|
||||
from ...utils.request import Request, splice_url
|
||||
from ...utils.typing import GameType
|
||||
from .. import ProcessedData as ProcessedDataMeta
|
||||
from .. import Processor as ProcessorMeta
|
||||
from .. import RawResponse as RawResponseMeta
|
||||
from .. import User as UserMeta
|
||||
from ..schemas import BaseUser
|
||||
from .constant import BASE_URL, GAME_TYPE
|
||||
from .schemas.response import ProcessedData, RawResponse
|
||||
from .schemas.user_info import SuccessModel as InfoSuccess
|
||||
from .schemas.user_info import UserInfo
|
||||
from .schemas.user_profile import UserProfile
|
||||
|
||||
|
||||
@dataclass
|
||||
class User(UserMeta):
|
||||
class User(BaseUser):
|
||||
platform: Literal['TOS'] = GAME_TYPE
|
||||
|
||||
teaid: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RawResponse(RawResponseMeta):
|
||||
user_profile: dict[frozenset[tuple[str, Any]], bytes]
|
||||
user_info: bytes | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessedData(ProcessedDataMeta):
|
||||
user_profile: dict[frozenset[tuple[str, Any]], UserProfile]
|
||||
user_info: InfoSuccess | None = None
|
||||
@property
|
||||
def unique_identifier(self) -> str:
|
||||
if self.teaid is None:
|
||||
raise ValueError('不完整的User!')
|
||||
return self.teaid
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -60,7 +54,7 @@ def identify_user_info(info: str) -> User | MessageFormatError:
|
||||
and 2 <= len(info) <= 18 # noqa: PLR2004
|
||||
):
|
||||
return User(name=info)
|
||||
if info.isdigit():
|
||||
if info.startswith(('onebot-', 'qqguild-', 'kook-', 'discord-')) and info.split('-', maxsplit=1)[1].isdigit():
|
||||
return User(teaid=info)
|
||||
return MessageFormatError('用户名/QQ号不合法')
|
||||
|
||||
@@ -76,22 +70,20 @@ class Processor(ProcessorMeta):
|
||||
self.processed_data = ProcessedData(user_profile={})
|
||||
|
||||
@property
|
||||
def game_platform(self) -> GameType:
|
||||
def game_platform(self) -> Literal['TOS']:
|
||||
return GAME_TYPE
|
||||
|
||||
async def handle_bind(self, platform: str, account: str) -> str:
|
||||
"""处理绑定消息"""
|
||||
self.command_type = 'bind'
|
||||
await self.get_user()
|
||||
if self.user.name is None:
|
||||
raise # FIXME: 不知道怎么才能把这类型给变过来了
|
||||
async with get_session() as session:
|
||||
return await create_or_update_bind(
|
||||
session=session,
|
||||
chat_platform=platform,
|
||||
chat_account=account,
|
||||
game_platform=GAME_TYPE,
|
||||
game_account=self.user.name,
|
||||
game_account=self.user.unique_identifier,
|
||||
)
|
||||
|
||||
async def handle_query(self) -> str:
|
||||
@@ -113,45 +105,60 @@ class Processor(ProcessorMeta):
|
||||
"""获取用户信息"""
|
||||
if self.processed_data.user_info is None:
|
||||
if self.user.teaid is not None:
|
||||
url = splice_url(
|
||||
[
|
||||
BASE_URL,
|
||||
'getTeaIdInfo',
|
||||
f'?{urlencode({"teaId":self.user.teaid})}',
|
||||
]
|
||||
)
|
||||
url = [
|
||||
splice_url(
|
||||
[
|
||||
i,
|
||||
'getTeaIdInfo',
|
||||
f'?{urlencode({"teaId":self.user.teaid})}',
|
||||
]
|
||||
)
|
||||
for i in BASE_URL
|
||||
]
|
||||
else:
|
||||
url = splice_url(
|
||||
[
|
||||
BASE_URL,
|
||||
'getUsernameInfo',
|
||||
f'?{urlencode({"username":self.user.name})}',
|
||||
]
|
||||
)
|
||||
self.raw_response.user_info = await Request.request(url)
|
||||
user_info: UserInfo = parse_raw_as(UserInfo, self.raw_response.user_info) # type: ignore[arg-type]
|
||||
url = [
|
||||
splice_url(
|
||||
[
|
||||
i,
|
||||
'getUsernameInfo',
|
||||
f'?{urlencode({"username":self.user.name})}',
|
||||
]
|
||||
)
|
||||
for i in BASE_URL
|
||||
]
|
||||
self.raw_response.user_info = await Request.failover_request(
|
||||
url, failover_code=[502], failover_exc=(TimeoutException,)
|
||||
)
|
||||
user_info: UserInfo = type_validate_json(UserInfo, self.raw_response.user_info) # type: ignore[arg-type]
|
||||
if not isinstance(user_info, InfoSuccess):
|
||||
raise RequestError(f'用户信息请求错误:\n{user_info.error}')
|
||||
self.processed_data.user_info = user_info
|
||||
return self.processed_data.user_info
|
||||
|
||||
async def get_user_profile(self, other_parameter: dict[str, Any] | None = None) -> UserProfile:
|
||||
async def get_user_profile(self, other_parameter: dict[str, str | bytes] | None = None) -> UserProfile:
|
||||
"""获取用户数据"""
|
||||
if other_parameter is None:
|
||||
other_parameter = {}
|
||||
fset = frozenset(other_parameter.items())
|
||||
if self.processed_data.user_profile.get(fset) is None:
|
||||
self.raw_response.user_profile[fset] = await Request.request(
|
||||
splice_url(
|
||||
[
|
||||
BASE_URL,
|
||||
'getProfile',
|
||||
f'?{urlencode({"id":self.user.teaid or self.user.name},**other_parameter)}',
|
||||
]
|
||||
)
|
||||
params = urlencode(dict(sorted(other_parameter.items())))
|
||||
if self.processed_data.user_profile.get(params) is None:
|
||||
self.raw_response.user_profile[params] = await Request.failover_request(
|
||||
[
|
||||
splice_url(
|
||||
[
|
||||
i,
|
||||
'getProfile',
|
||||
f'?{urlencode({"id":self.user.teaid or self.user.name,**other_parameter})}',
|
||||
]
|
||||
)
|
||||
for i in BASE_URL
|
||||
],
|
||||
failover_code=[502],
|
||||
failover_exc=(TimeoutException,),
|
||||
)
|
||||
self.processed_data.user_profile[fset] = UserProfile.parse_raw(self.raw_response.user_profile[fset])
|
||||
return self.processed_data.user_profile[fset]
|
||||
self.processed_data.user_profile[params] = UserProfile.model_validate_json(
|
||||
self.raw_response.user_profile[params]
|
||||
)
|
||||
return self.processed_data.user_profile[params]
|
||||
|
||||
async def get_game_data(self) -> GameData | None:
|
||||
"""获取游戏数据"""
|
||||
@@ -211,11 +218,7 @@ class Processor(ProcessorMeta):
|
||||
message += f"\nL'PM: {game_data.lpm} ( {game_data.pps} pps )"
|
||||
message += f'\nAPM: {game_data.apm} ( x{game_data.apl} )'
|
||||
message += f'\nADPM: {game_data.adpm} ( x{game_data.adpl} ) ( {game_data.vs}vs )'
|
||||
message += (
|
||||
f'\n40L: {float(user_info.pb_sprint)/1000:.2f}s'
|
||||
if user_info.pb_sprint != 2147483647 # noqa: PLR2004
|
||||
else ''
|
||||
)
|
||||
message += f'\nMarathon: {user_info.pb_marathon}' if user_info.pb_marathon != 0 else ''
|
||||
message += f'\nChallenge: {user_info.pb_challenge}' if user_info.pb_challenge != 0 else ''
|
||||
message += f'\n40L: {float(user_info.pb_sprint)/1000:.2f}s' if user_info.pb_sprint != '2147483647' else ''
|
||||
message += f'\nMarathon: {user_info.pb_marathon}' if user_info.pb_marathon != '0' else ''
|
||||
message += f'\nChallenge: {user_info.pb_challenge}' if user_info.pb_challenge != '0' else ''
|
||||
return message
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...schemas import BaseProcessedData, BaseRawResponse
|
||||
from ..constant import GAME_TYPE
|
||||
from .user_info import SuccessModel as InfoSuccess
|
||||
from .user_profile import UserProfile
|
||||
|
||||
|
||||
class RawResponse(BaseRawResponse):
|
||||
platform: Literal['TOS'] = GAME_TYPE
|
||||
|
||||
user_profile: dict[str, bytes]
|
||||
user_info: bytes | None = None
|
||||
|
||||
|
||||
class ProcessedData(BaseProcessedData):
|
||||
platform: Literal['TOS'] = GAME_TYPE
|
||||
|
||||
user_profile: dict[str, UserProfile]
|
||||
user_info: InfoSuccess | None = None
|
||||
@@ -1,6 +1,7 @@
|
||||
import sys
|
||||
from os import environ
|
||||
from platform import system
|
||||
from re import sub
|
||||
|
||||
from nonebot import get_driver
|
||||
from nonebot.log import logger
|
||||
@@ -33,12 +34,12 @@ class BrowserManager:
|
||||
raise ImportError('加载失败, Windows 必须设置 FASTAPI_RELOAD=false 才能正常运行 playwright')
|
||||
logger.info('开始 安装/更新 playwright 浏览器')
|
||||
environ['PLAYWRIGHT_DOWNLOAD_HOST'] = 'https://npmmirror.com/mirrors/playwright/'
|
||||
if cls._handle_error(cls._call_playwright(['', 'install', 'firefox'])):
|
||||
if cls._call_playwright(['', 'install', 'firefox']):
|
||||
logger.success('安装/更新 playwright 浏览器成功')
|
||||
else:
|
||||
logger.warning('playwright 浏览器 安装/更新 失败, 尝试使用原始仓库下载')
|
||||
del environ['PLAYWRIGHT_DOWNLOAD_HOST']
|
||||
if cls._handle_error(cls._call_playwright(['', 'install', 'firefox'])):
|
||||
if cls._call_playwright(['', 'install', 'firefox']):
|
||||
logger.success('安装/更新 playwright 浏览器成功')
|
||||
else:
|
||||
logger.error('安装/更新 playwright 浏览器失败')
|
||||
@@ -52,26 +53,20 @@ class BrowserManager:
|
||||
logger.success('playwright 启动成功')
|
||||
|
||||
@classmethod
|
||||
def _call_playwright(cls, argv: list[str]) -> BaseException:
|
||||
def _call_playwright(cls, argv: list[str]) -> bool:
|
||||
"""等价于调用 playwright 的命令行程序"""
|
||||
argv_backup = sys.argv.copy()
|
||||
from re import sub
|
||||
|
||||
sys.argv[0] = sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.argv = argv
|
||||
try:
|
||||
main()
|
||||
except BaseException as e: # noqa: BLE001 不在这里处理 playwright 的异常
|
||||
return e
|
||||
except SystemExit as e:
|
||||
return e.code == 0
|
||||
except BaseException: # noqa: BLE001
|
||||
return False
|
||||
finally:
|
||||
sys.argv = argv_backup
|
||||
return SystemExit(0)
|
||||
|
||||
@classmethod
|
||||
def _handle_error(cls, error: BaseException) -> bool:
|
||||
if isinstance(error, SystemExit) and error.code == 0:
|
||||
return True
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
async def _start_browser(cls) -> Browser:
|
||||
|
||||
@@ -15,21 +15,25 @@ class NeedCatchError(TetrisStatsError):
|
||||
"""需要被捕获的异常基类"""
|
||||
|
||||
|
||||
class DoNotCatchError(TetrisStatsError):
|
||||
"""不应该被捕获的异常基类"""
|
||||
|
||||
|
||||
class RequestError(NeedCatchError):
|
||||
"""请求错误"""
|
||||
|
||||
def __init__(self, message: str = '', *, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class MessageFormatError(NeedCatchError):
|
||||
"""用户发送的消息格式不正确"""
|
||||
|
||||
|
||||
class DatabaseVersionError(DoNotCatchError):
|
||||
"""数据库版本错误"""
|
||||
class DoNotCatchError(TetrisStatsError):
|
||||
"""不应该被捕获的异常基类"""
|
||||
|
||||
|
||||
class WhatTheFuckError(DoNotCatchError):
|
||||
"""用于表示不应该出现的情况 ("""
|
||||
|
||||
|
||||
class HandleNotFinishedError(DoNotCatchError):
|
||||
"""任务没有正常完成处理的错误"""
|
||||
|
||||
@@ -144,7 +144,7 @@ class TetrisMetricsProWithLPMADPM(TetrisMetricsBasicWithLPM, TetrisMetricsBaseWi
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics( # noqa: PLR0913
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: Number,
|
||||
lpm: None = None,
|
||||
@@ -157,7 +157,7 @@ def get_metrics( # noqa: PLR0913
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics( # noqa: PLR0913
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: None = None,
|
||||
lpm: Number,
|
||||
@@ -170,7 +170,7 @@ def get_metrics( # noqa: PLR0913
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics( # noqa: PLR0913
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: None = None,
|
||||
lpm: None = None,
|
||||
@@ -183,7 +183,7 @@ def get_metrics( # noqa: PLR0913
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics( # noqa: PLR0913
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: None = None,
|
||||
lpm: None = None,
|
||||
@@ -196,7 +196,7 @@ def get_metrics( # noqa: PLR0913
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics( # noqa: PLR0913
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: Number,
|
||||
lpm: None = None,
|
||||
@@ -209,7 +209,7 @@ def get_metrics( # noqa: PLR0913
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics( # noqa: PLR0913
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: None = None,
|
||||
lpm: Number,
|
||||
@@ -222,7 +222,7 @@ def get_metrics( # noqa: PLR0913
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics( # noqa: PLR0913
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: Number,
|
||||
lpm: None = None,
|
||||
@@ -235,7 +235,7 @@ def get_metrics( # noqa: PLR0913
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics( # noqa: PLR0913
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: Number,
|
||||
lpm: None = None,
|
||||
@@ -248,7 +248,7 @@ def get_metrics( # noqa: PLR0913
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics( # noqa: PLR0913
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: None = None,
|
||||
lpm: Number,
|
||||
@@ -261,7 +261,7 @@ def get_metrics( # noqa: PLR0913
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics( # noqa: PLR0913
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: None = None,
|
||||
lpm: Number,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar
|
||||
|
||||
from nonebot import get_driver, get_plugin
|
||||
@@ -9,12 +9,15 @@ from nonebot_plugin_orm import get_session
|
||||
|
||||
from ..db.models import HistoricalData
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
|
||||
class Recorder:
|
||||
matchers: ClassVar[set[type[Matcher]]] = set()
|
||||
historical_data: ClassVar[dict[int, tuple[HistoricalData, bool]]] = {}
|
||||
error_event: ClassVar[set[int]] = set()
|
||||
|
||||
@classmethod
|
||||
def create_historical_data(cls, event_id: int, historical_data: HistoricalData) -> None:
|
||||
@@ -32,17 +35,27 @@ class Recorder:
|
||||
|
||||
@classmethod
|
||||
async def save_historical_data(cls, event_id: int) -> None:
|
||||
if event_id not in cls.historical_data:
|
||||
raise KeyError
|
||||
historical_data, completed = cls.historical_data.pop(event_id)
|
||||
historical_data, completed = cls.del_historical_data(event_id)
|
||||
if completed:
|
||||
async with get_session() as session:
|
||||
session.add(historical_data)
|
||||
await session.commit()
|
||||
|
||||
@classmethod
|
||||
def del_historical_data(cls, event_id: int) -> None:
|
||||
cls.historical_data.pop(event_id)
|
||||
def del_historical_data(cls, event_id: int) -> tuple[HistoricalData, bool]:
|
||||
return cls.historical_data.pop(event_id)
|
||||
|
||||
@classmethod
|
||||
def add_error_event(cls, event_id: int) -> None:
|
||||
cls.error_event.add(event_id)
|
||||
|
||||
@classmethod
|
||||
def del_error_event(cls, event_id: int) -> None:
|
||||
cls.error_event.remove(event_id)
|
||||
|
||||
@classmethod
|
||||
def is_error_event(cls, event_id: int) -> bool:
|
||||
return event_id in cls.error_event
|
||||
|
||||
|
||||
@driver.on_startup
|
||||
@@ -73,7 +86,9 @@ def _(bot: Bot, event: Event, matcher: Matcher):
|
||||
@run_postprocessor
|
||||
async def _(event: Event, matcher: Matcher, exception: Exception | None):
|
||||
if isinstance(matcher, tuple(Recorder.matchers)):
|
||||
event_id = id(event)
|
||||
if exception is not None:
|
||||
Recorder.del_historical_data(id(event))
|
||||
Recorder.add_error_event(event_id)
|
||||
Recorder.del_historical_data(event_id)
|
||||
else:
|
||||
await Recorder.save_historical_data(id(event))
|
||||
await Recorder.save_historical_data(event_id)
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
from collections.abc import Sequence
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from aiofiles import open
|
||||
from httpx import AsyncClient, HTTPError
|
||||
from nonebot import get_driver
|
||||
from nonebot import get_driver, get_plugin_config
|
||||
from nonebot.log import logger
|
||||
from playwright.async_api import Response
|
||||
from ujson import JSONDecodeError, dumps, loads
|
||||
|
||||
from ..config.config import CACHE_PATH
|
||||
from ..config.config import CACHE_PATH, Config
|
||||
from .browser import BrowserManager
|
||||
from .exception import RequestError
|
||||
|
||||
driver = get_driver()
|
||||
config = get_plugin_config(Config)
|
||||
|
||||
|
||||
@driver.on_startup
|
||||
@@ -37,7 +40,7 @@ def splice_url(url_list: list[str]) -> str:
|
||||
class Request:
|
||||
"""网络请求相关类"""
|
||||
|
||||
_CACHE_FILE = CACHE_PATH.joinpath('cloudflare_cache.json')
|
||||
_CACHE_FILE = CACHE_PATH / 'cloudflare_cache.json'
|
||||
_headers: dict | None = None
|
||||
_cookies: dict | None = None
|
||||
|
||||
@@ -45,36 +48,31 @@ class Request:
|
||||
async def _anti_cloudflare(cls, url: str) -> bytes:
|
||||
"""用firefox硬穿五秒盾"""
|
||||
browser = await BrowserManager.get_browser()
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
response = await page.goto(url)
|
||||
attempts = 0
|
||||
while attempts < 60: # noqa: PLR2004
|
||||
attempts += 1
|
||||
text = await page.locator('body').text_content()
|
||||
if text is None:
|
||||
await page.wait_for_timeout(1000)
|
||||
continue
|
||||
if await page.title() == 'Please Wait... | Cloudflare':
|
||||
logger.warning('疑似触发了 Cloudflare 的验证码')
|
||||
break
|
||||
try:
|
||||
loads(text)
|
||||
except JSONDecodeError:
|
||||
await page.wait_for_timeout(1000)
|
||||
else:
|
||||
if not isinstance(response, Response):
|
||||
raise RequestError('api请求失败')
|
||||
cls._headers = await response.request.all_headers()
|
||||
async with await browser.new_context() as context, await context.new_page() as page:
|
||||
response = await page.goto(url)
|
||||
attempts = 0
|
||||
while attempts < 60: # noqa: PLR2004
|
||||
attempts += 1
|
||||
text = await page.locator('body').text_content()
|
||||
if text is None:
|
||||
await page.wait_for_timeout(1000)
|
||||
continue
|
||||
if await page.title() == 'Please Wait... | Cloudflare':
|
||||
logger.warning('疑似触发了 Cloudflare 的验证码')
|
||||
break
|
||||
try:
|
||||
cls._cookies = {i['name']: i['value'] for i in await context.cookies()}
|
||||
except KeyError:
|
||||
cls._cookies = None
|
||||
await page.close()
|
||||
await context.close()
|
||||
return await response.body()
|
||||
await page.close()
|
||||
await context.close()
|
||||
loads(text)
|
||||
except JSONDecodeError:
|
||||
await page.wait_for_timeout(1000)
|
||||
else:
|
||||
if not isinstance(response, Response):
|
||||
raise RequestError('api请求失败')
|
||||
cls._headers = await response.request.all_headers()
|
||||
try:
|
||||
cls._cookies = {i['name']: i['value'] for i in await context.cookies()}
|
||||
except KeyError:
|
||||
cls._cookies = None
|
||||
return await response.body()
|
||||
raise RequestError('绕过五秒盾失败')
|
||||
|
||||
@classmethod
|
||||
@@ -115,14 +113,49 @@ class Request:
|
||||
async def request(cls, url: str, *, is_json: bool = True) -> bytes:
|
||||
"""请求api"""
|
||||
try:
|
||||
async with AsyncClient(cookies=cls._cookies) as session:
|
||||
async with AsyncClient(cookies=cls._cookies, timeout=config.tetris_req_timeout) as session:
|
||||
response = await session.get(url, headers=cls._headers)
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
raise RequestError(
|
||||
f'请求错误 code: {response.status_code} {HTTPStatus(response.status_code).phrase}\n{response.text}',
|
||||
status_code=response.status_code,
|
||||
)
|
||||
if is_json:
|
||||
loads(response.content)
|
||||
return response.content
|
||||
except HTTPError as e:
|
||||
raise RequestError(f'请求错误\n{e!r}') from e
|
||||
raise RequestError(f'请求错误 \n{e!r}') from e
|
||||
except JSONDecodeError:
|
||||
if urlparse(url).netloc.lower().endswith('tetr.io'):
|
||||
return await cls._anti_cloudflare(url)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
async def failover_request(
|
||||
cls,
|
||||
urls: Sequence[str],
|
||||
*,
|
||||
failover_code: Sequence[int],
|
||||
failover_exc: tuple[type[BaseException], ...],
|
||||
is_json: bool = True,
|
||||
) -> bytes:
|
||||
error_list: list[RequestError] = []
|
||||
for i in urls:
|
||||
logger.debug(f'尝试请求 {i}')
|
||||
try:
|
||||
return await cls.request(i, is_json=is_json)
|
||||
except RequestError as e:
|
||||
if e.status_code in failover_code: # 如果状态码在 failover_code 中, 则继续尝试下一个URL
|
||||
error_list.append(e)
|
||||
continue
|
||||
# 如果状态码不在故障转移列表中, 则查找异常栈, 如果异常栈内有 failover_exc 内的异常类型, 则继续尝试下一个URL
|
||||
tb = e.__traceback__
|
||||
while tb is not None:
|
||||
if isinstance(tb.tb_frame.f_locals.get('exc_value'), failover_exc):
|
||||
error_list.append(e)
|
||||
break
|
||||
tb = tb.tb_next
|
||||
else:
|
||||
raise
|
||||
continue
|
||||
raise RequestError(f'所有地址皆不可用\n{error_list!r}')
|
||||
|
||||
37
nonebot_plugin_tetris_stats/utils/retry.py
Normal file
37
nonebot_plugin_tetris_stats/utils/retry.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from asyncio import sleep
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
from typing import TypeVar, cast
|
||||
|
||||
from nonebot.log import logger
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def retry(
|
||||
max_attempts: int = 3,
|
||||
exception_type: type[BaseException] | tuple[type[BaseException], ...] = Exception,
|
||||
delay: timedelta | None = None,
|
||||
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
||||
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs) -> T: # noqa: ANN002, ANN003
|
||||
attempts = 0
|
||||
while attempts < max_attempts + 1:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except exception_type as e: # noqa: PERF203
|
||||
logger.exception(e)
|
||||
attempts += 1
|
||||
if attempts <= max_attempts:
|
||||
if delay is not None:
|
||||
await sleep(delay.total_seconds())
|
||||
logger.debug(f'Retrying: {func.__name__} ({attempts}/{max_attempts})')
|
||||
continue
|
||||
raise
|
||||
raise RuntimeError('Unexpectedly reached the end of the retry loop')
|
||||
|
||||
return cast(Callable[..., Awaitable[T]], wrapper)
|
||||
|
||||
return decorator
|
||||
2385
poetry.lock
generated
2385
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = 'nonebot-plugin-tetris-stats'
|
||||
version = '1.0.0.a4'
|
||||
version = '1.0.0.a16'
|
||||
description = '一款基于 NoneBot2 的用于查询 Tetris 相关游戏数据的插件'
|
||||
authors = ['scdhh <wallfjjd@gmail.com>']
|
||||
readme = 'README.md'
|
||||
@@ -10,33 +10,37 @@ license = 'AGPL-3.0'
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = '^3.10'
|
||||
nonebot2 = '^2.0.0-beta.3'
|
||||
lxml = '^4.9.1'
|
||||
nonebot2 = '^2.2.0'
|
||||
lxml = '^5.1.0'
|
||||
pandas = '>=1.4.3,<3.0.0'
|
||||
playwright = '^1.24.1'
|
||||
ujson = '^5.4.0'
|
||||
playwright = '^1.41.2'
|
||||
ujson = '^5.9.0'
|
||||
aiofiles = "^23.2.1"
|
||||
nonebot-plugin-orm = ">=0.1.1,<0.6.0"
|
||||
nonebot-plugin-localstore = "^0.5.1"
|
||||
httpx = "^0.25.0"
|
||||
nonebot-plugin-alconna = ">=0.30,<0.34"
|
||||
nonebot-plugin-apscheduler = "^0.3.0"
|
||||
nonebot-plugin-orm = ">=0.1.1,<0.8.0"
|
||||
nonebot-plugin-localstore = "^0.6.0"
|
||||
httpx = "^0.27.0"
|
||||
nonebot-plugin-alconna = ">=0.40"
|
||||
nonebot-plugin-apscheduler = "^0.4.0"
|
||||
aiocache = "^0.12.2"
|
||||
zstandard = "^0.22.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
mypy = '>=0.991,<1.8'
|
||||
types-ujson = '^5.7.0'
|
||||
mypy = '>=1.9'
|
||||
types-ujson = '^5.9.0'
|
||||
pandas-stubs = '>=1.5.2,<3.0.0'
|
||||
ruff = '>=0.0.239,<0.1.6'
|
||||
types-aiofiles = "^23.2.0.0"
|
||||
nonebot2 = { extras = ["fastapi"], version = "^2.1.1" }
|
||||
types-lxml = "^2023.3.28"
|
||||
nonebot-plugin-orm = { extras = ["default"], version = ">=0.3,<0.6" }
|
||||
nonebot-adapter-onebot = "^2.3.1"
|
||||
nonebot-adapter-satori = "^0.7.0"
|
||||
ruff = '>=0.3.0'
|
||||
types-aiofiles = "^23.2.0.20240106"
|
||||
nonebot2 = { extras = ["fastapi"], version = "^2.2.0" }
|
||||
types-lxml = "^2024.2.9"
|
||||
nonebot-plugin-orm = { extras = ["default"], version = ">=0.3,<0.8" }
|
||||
nonebot-adapter-onebot = "^2.4.1"
|
||||
nonebot-adapter-satori = "^0.11.3"
|
||||
nonebot-adapter-kaiheila = "^0.3.4"
|
||||
nonebot-adapter-discord = "^0.1.3"
|
||||
|
||||
[tool.poetry.group.debug.dependencies]
|
||||
objprint = '^0.2.2'
|
||||
viztracer = "^0.16.0"
|
||||
viztracer = "^0.16.2"
|
||||
|
||||
[build-system]
|
||||
requires = ['poetry-core>=1.0.0']
|
||||
|
||||
Reference in New Issue
Block a user