mirror of
https://github.com/A-Minos/nonebot-plugin-tetris-stats.git
synced 2026-03-05 05:36:54 +08:00
Compare commits
127 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 | |||
| 7da38e0346 | |||
|
|
84368a16c3 | ||
| 6a10ede5ba | |||
| 4c205e516f |
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -7,6 +7,6 @@ version: 2
|
|||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "pip" # See documentation for possible values
|
- package-ecosystem: "pip" # See documentation for possible values
|
||||||
directory: "/" # Location of package manifests
|
directory: "/" # Location of package manifests
|
||||||
target-branch: "dev"
|
target-branch: "main"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
|||||||
40
.github/workflows/Release.yml
vendored
40
.github/workflows/Release.yml
vendored
@@ -8,18 +8,42 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install poetry
|
||||||
|
run: pipx install poetry
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
- name: Install Poetry
|
cache: "poetry"
|
||||||
|
|
||||||
|
- run: poetry install
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
- name: Get Version
|
||||||
|
id: version
|
||||||
run: |
|
run: |
|
||||||
pip install poetry
|
echo "VERSION=$(poetry version -s)" >> $GITHUB_OUTPUT
|
||||||
- name: Build
|
echo "TAG_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
shell: bash
|
echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
run: |
|
|
||||||
poetry install
|
- name: Check Version
|
||||||
poetry env use python
|
if: steps.version.outputs.VERSION != steps.version.outputs.TAG_VERSION
|
||||||
poetry publish --build -u ${{ secrets.USERNAME }} -p ${{ secrets.PASSWORD }} -n
|
run: exit 1
|
||||||
|
|
||||||
|
- name: Build Package
|
||||||
|
run: poetry build
|
||||||
|
|
||||||
|
- name: Publish Package to PyPI
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
|
||||||
|
- name: Publish Package to GitHub Release
|
||||||
|
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*
|
*copy*
|
||||||
.vscode
|
.vscode
|
||||||
*dev*
|
*dev*
|
||||||
*cache*
|
*_cache*
|
||||||
*backup*
|
*backup*
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ CACHE_PATH: Path = get_cache_dir('nonebot_plugin_tetris_stats')
|
|||||||
class Config(BaseModel):
|
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:
|
if name:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
db_path = Path(config.db_path)
|
db_path = Path(config.db_url)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
db_path = Path('data/nonebot_plugin_tetris_stats/data.db')
|
db_path = Path('data/nonebot_plugin_tetris_stats/data.db')
|
||||||
if db_path.exists() is False:
|
if db_path.exists() is False:
|
||||||
@@ -84,7 +84,7 @@ def upgrade(name: str = '') -> None:
|
|||||||
raise RuntimeError('nonebot_plugin_tetris_stats: 请先安装 0.4.4 版本完成迁移之后再升级')
|
raise RuntimeError('nonebot_plugin_tetris_stats: 请先安装 0.4.4 版本完成迁移之后再升级')
|
||||||
logger.info('nonebot_plugin_tetris_stats: 发现来自老版本的数据, 正在迁移...')
|
logger.info('nonebot_plugin_tetris_stats: 发现来自老版本的数据, 正在迁移...')
|
||||||
migrate_old_data(connection)
|
migrate_old_data(connection)
|
||||||
db_path.unlink()
|
db_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
def downgrade(name: str = '') -> None:
|
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 datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from nonebot.adapters import Message
|
from nonebot.adapters import Message
|
||||||
from nonebot_plugin_orm import Model
|
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 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
|
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):
|
class Bind(MappedAsDataclass, Model):
|
||||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||||
chat_platform: Mapped[str] = mapped_column(String(32), index=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)
|
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_type: Mapped[CommandType] = mapped_column(String(16), index=True, init=False)
|
||||||
command_args: Mapped[list[str]] = mapped_column(JSON, init=False)
|
command_args: Mapped[list[str]] = mapped_column(JSON, init=False)
|
||||||
game_user: Mapped[User] = mapped_column(PickleType, init=False)
|
game_user: Mapped[BaseUser] = mapped_column(PydanticType(get_model=BaseUser.__subclasses__), init=False)
|
||||||
processed_data: Mapped[ProcessedData] = mapped_column(PickleType, init=False)
|
processed_data: Mapped[BaseProcessedData] = mapped_column(
|
||||||
|
PydanticType(get_model=BaseProcessedData.__subclasses__), init=False
|
||||||
|
)
|
||||||
finish_time: Mapped[datetime] = mapped_column(DateTime, init=False)
|
finish_time: Mapped[datetime] = mapped_column(DateTime, init=False)
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from datetime import datetime, timezone
|
||||||
from datetime import UTC, datetime
|
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 ..utils.typing import CommandType, GameType
|
||||||
|
from .schemas import BaseProcessedData as ProcessedData
|
||||||
|
from .schemas import BaseRawResponse as RawResponse
|
||||||
|
from .schemas import BaseUser as User
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
@dataclass
|
|
||||||
class User:
|
|
||||||
"""游戏用户"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RawResponse:
|
|
||||||
"""原始请求数据"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProcessedData:
|
|
||||||
"""处理/验证后的数据"""
|
|
||||||
|
|
||||||
|
|
||||||
from ..utils.recorder import Recorder # noqa: E402 避免循环导入
|
|
||||||
|
|
||||||
|
|
||||||
class Processor(ABC):
|
class Processor(ABC):
|
||||||
@@ -65,6 +57,9 @@ class Processor(ABC):
|
|||||||
|
|
||||||
def __del__(self) -> None:
|
def __del__(self) -> None:
|
||||||
finish_time = datetime.now(tz=UTC)
|
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 = Recorder.get_historical_data(self.event_id)
|
||||||
historical_data.game_platform = self.game_platform
|
historical_data.game_platform = self.game_platform
|
||||||
historical_data.command_type = self.command_type
|
historical_data.command_type = self.command_type
|
||||||
@@ -75,6 +70,24 @@ class Processor(ABC):
|
|||||||
Recorder.update_historical_data(self.event_id, historical_data)
|
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
|
from . import ( # noqa: F401, E402
|
||||||
io_data_processor,
|
io_data_processor,
|
||||||
top_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.adapters import Bot, Event
|
||||||
from nonebot.matcher import Matcher
|
from nonebot.matcher import Matcher
|
||||||
from nonebot_plugin_alconna import AlcMatches, At, on_alconna
|
from nonebot_plugin_alconna import At, on_alconna
|
||||||
from nonebot_plugin_orm import get_session
|
from nonebot_plugin_orm import get_session
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from ...db import query_bind_info
|
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.metrics import get_metrics
|
||||||
from ...utils.platform import get_platform
|
from ...utils.platform import get_platform
|
||||||
from ...utils.typing import Me
|
from ...utils.typing import Me
|
||||||
|
from .. import add_default_handlers
|
||||||
from ..constant import BIND_COMMAND, QUERY_COMMAND
|
from ..constant import BIND_COMMAND, QUERY_COMMAND
|
||||||
from .constant import GAME_TYPE
|
from .constant import GAME_TYPE
|
||||||
from .model import IORank
|
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
|
from .typing import Rank
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
alc = on_alconna(
|
alc = on_alconna(
|
||||||
Alconna(
|
Alconna(
|
||||||
'io',
|
'io',
|
||||||
@@ -65,6 +69,7 @@ alc = on_alconna(
|
|||||||
dest='rank',
|
dest='rank',
|
||||||
help_text='查询 IO 段位信息',
|
help_text='查询 IO 段位信息',
|
||||||
),
|
),
|
||||||
|
Arg('other', AllParam, flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL]),
|
||||||
meta=CommandMeta(
|
meta=CommandMeta(
|
||||||
description='查询 TETR.IO 的信息',
|
description='查询 TETR.IO 的信息',
|
||||||
example='io绑定scdhh\nio查我\niorankx',
|
example='io绑定scdhh\nio查我\niorankx',
|
||||||
@@ -90,7 +95,8 @@ async def _(bot: Bot, event: Event, matcher: Matcher, account: User):
|
|||||||
try:
|
try:
|
||||||
await matcher.finish(await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id()))
|
await matcher.finish(await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id()))
|
||||||
except NeedCatchError as e:
|
except NeedCatchError as e:
|
||||||
await matcher.finish(str(e))
|
await matcher.send(str(e))
|
||||||
|
raise HandleNotFinishedError from e
|
||||||
|
|
||||||
|
|
||||||
@alc.assign('query')
|
@alc.assign('query')
|
||||||
@@ -113,7 +119,8 @@ async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me):
|
|||||||
try:
|
try:
|
||||||
await matcher.finish(message + await proc.handle_query())
|
await matcher.finish(message + await proc.handle_query())
|
||||||
except NeedCatchError as e:
|
except NeedCatchError as e:
|
||||||
await matcher.finish(str(e))
|
await matcher.send(str(e))
|
||||||
|
raise HandleNotFinishedError from e
|
||||||
|
|
||||||
|
|
||||||
@alc.assign('query')
|
@alc.assign('query')
|
||||||
@@ -126,25 +133,37 @@ async def _(event: Event, matcher: Matcher, account: User):
|
|||||||
try:
|
try:
|
||||||
await matcher.finish(await proc.handle_query())
|
await matcher.finish(await proc.handle_query())
|
||||||
except NeedCatchError as e:
|
except NeedCatchError as e:
|
||||||
await matcher.finish(str(e))
|
await matcher.send(str(e))
|
||||||
|
raise HandleNotFinishedError from e
|
||||||
|
|
||||||
|
|
||||||
@alc.assign('rank')
|
@alc.assign('rank')
|
||||||
async def _(event: Event, matcher: Matcher, rank: Rank):
|
async def _(matcher: Matcher, rank: Rank):
|
||||||
if rank == 'z':
|
if rank == 'z':
|
||||||
await matcher.finish('暂不支持查询未知段位')
|
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:
|
async with get_session() as session:
|
||||||
data = (
|
latest_data = (
|
||||||
await session.scalars(select(IORank).where(IORank.rank == rank).order_by(IORank.id.desc()).limit(5))
|
await session.scalars(select(IORank).where(IORank.rank == rank).order_by(IORank.id.desc()).limit(1))
|
||||||
).all()
|
).one()
|
||||||
latest_data = data[0]
|
compare_data = (
|
||||||
message = f'{rank.upper()} 段 分数线 {latest_data.tr_line:.2f} TR, {latest_data.player_count} 名玩家\n'
|
await session.scalars(
|
||||||
if len(data) > 1:
|
select(IORank)
|
||||||
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 "→"}'
|
.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:
|
else:
|
||||||
message += '暂无对比数据'
|
message += '暂无对比数据'
|
||||||
avg = get_metrics(pps=latest_data.avg_pps, apm=latest_data.avg_apm, vs=latest_data.avg_vs)
|
avg = get_metrics(pps=latest_data.avg_pps, apm=latest_data.avg_apm, vs=latest_data.avg_vs)
|
||||||
@@ -169,19 +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'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'
|
f'ADPM: {max_vs.adpm} ( {max_vs.vs}vs ) By: {latest_data.high_vs[0]["name"].upper()}\n'
|
||||||
'\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)
|
await matcher.finish(message)
|
||||||
|
|
||||||
|
|
||||||
@alc.handle()
|
add_default_handlers(alc)
|
||||||
async def _(matcher: Matcher, account: MessageFormatError):
|
|
||||||
await matcher.finish(str(account))
|
|
||||||
|
|
||||||
|
|
||||||
@alc.handle()
|
|
||||||
async def _(matcher: Matcher, matches: AlcMatches):
|
|
||||||
if matches.head_matched:
|
|
||||||
await matcher.finish(
|
|
||||||
f'{matches.error_info!r}\n' if matches.error_info is not None else '' + '输入"io --help"查看帮助'
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
from .typing import Rank
|
||||||
|
|
||||||
GAME_TYPE: GameType = 'IO'
|
GAME_TYPE: Literal['IO'] = 'IO'
|
||||||
BASE_URL = 'https://ch.tetr.io/api/'
|
BASE_URL = 'https://ch.tetr.io/api/'
|
||||||
RANK_PERCENTILE: dict[Rank, float] = {
|
RANK_PERCENTILE: dict[Rank, float] = {
|
||||||
'x': 1,
|
'x': 1,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import UTC, datetime
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from nonebot_plugin_orm import Model
|
from nonebot_plugin_orm import Model
|
||||||
from sqlalchemy import JSON, DateTime, String
|
from sqlalchemy import JSON, DateTime, String
|
||||||
@@ -6,6 +6,8 @@ from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
|
|||||||
|
|
||||||
from .typing import Rank
|
from .typing import Rank
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
class IORank(MappedAsDataclass, Model):
|
class IORank(MappedAsDataclass, Model):
|
||||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
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_pps: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
|
||||||
high_apm: 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)
|
high_vs: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
|
||||||
create_time: Mapped[datetime] = mapped_column(
|
update_time: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
default=lambda: datetime.now(tz=UTC),
|
|
||||||
index=True,
|
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 import defaultdict
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import asdict, dataclass
|
from datetime import datetime, timedelta, timezone
|
||||||
from datetime import UTC, datetime, timedelta
|
from hashlib import sha512
|
||||||
from math import floor
|
from math import floor
|
||||||
from re import match
|
from re import match
|
||||||
from statistics import mean
|
from statistics import mean
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from aiofiles import open
|
||||||
from nonebot import get_driver
|
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_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 nonebot_plugin_orm import get_session
|
||||||
from pydantic import parse_raw_as
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
from ...db import create_or_update_bind
|
from ...db import create_or_update_bind
|
||||||
from ...utils.exception import MessageFormatError, RequestError, WhatTheFuckError
|
from ...utils.exception import MessageFormatError, RequestError, WhatTheFuckError
|
||||||
from ...utils.request import Request, splice_url
|
from ...utils.request import splice_url
|
||||||
from ...utils.typing import GameType
|
from ...utils.retry import retry
|
||||||
from .. import ProcessedData as ProcessedDataMeta
|
|
||||||
from .. import Processor as ProcessorMeta
|
from .. import Processor as ProcessorMeta
|
||||||
from .. import RawResponse as RawResponseMeta
|
from .cache import Cache
|
||||||
from .. import User as UserMeta
|
|
||||||
from .constant import BASE_URL, GAME_TYPE, RANK_PERCENTILE
|
from .constant import BASE_URL, GAME_TYPE, RANK_PERCENTILE
|
||||||
from .model import IORank
|
from .model import IORank
|
||||||
from .schemas.league_all import FailedModel as LeagueAllFailed
|
from .schemas.league_all import FailedModel as LeagueAllFailed
|
||||||
from .schemas.league_all import LeagueAll
|
from .schemas.league_all import LeagueAll
|
||||||
from .schemas.league_all import ValidUser as LeagueAllUser
|
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 FailedModel as InfoFailed
|
||||||
from .schemas.user_info import (
|
from .schemas.user_info import NeverPlayedLeague, NeverRatedLeague, UserInfo
|
||||||
NeverPlayedLeague,
|
|
||||||
NeverRatedLeague,
|
|
||||||
UserInfo,
|
|
||||||
)
|
|
||||||
from .schemas.user_info import SuccessModel as InfoSuccess
|
from .schemas.user_info import SuccessModel as InfoSuccess
|
||||||
from .schemas.user_records import FailedModel as RecordsFailed
|
from .schemas.user_records import FailedModel as RecordsFailed
|
||||||
from .schemas.user_records import SoloRecord, UserRecords
|
from .schemas.user_records import SoloRecord, UserRecords
|
||||||
from .schemas.user_records import SuccessModel as RecordsSuccess
|
from .schemas.user_records import SuccessModel as RecordsSuccess
|
||||||
from .typing import Rank
|
from .typing import Rank
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
driver = get_driver()
|
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:
|
def identify_user_info(info: str) -> User | MessageFormatError:
|
||||||
if match(r'^[a-f0-9]{24}$', info):
|
if match(r'^[a-f0-9]{24}$', info):
|
||||||
return User(ID=info)
|
return User(ID=info)
|
||||||
@@ -77,7 +62,7 @@ class Processor(ProcessorMeta):
|
|||||||
self.processed_data = ProcessedData()
|
self.processed_data = ProcessedData()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def game_platform(self) -> GameType:
|
def game_platform(self) -> Literal['IO']:
|
||||||
return GAME_TYPE
|
return GAME_TYPE
|
||||||
|
|
||||||
async def handle_bind(self, platform: str, account: str) -> str:
|
async def handle_bind(self, platform: str, account: str) -> str:
|
||||||
@@ -113,10 +98,10 @@ class Processor(ProcessorMeta):
|
|||||||
async def get_user_info(self) -> InfoSuccess:
|
async def get_user_info(self) -> InfoSuccess:
|
||||||
"""获取用户数据"""
|
"""获取用户数据"""
|
||||||
if self.processed_data.user_info is None:
|
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}'])
|
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):
|
if isinstance(user_info, InfoFailed):
|
||||||
raise RequestError(f'用户信息请求错误:\n{user_info.error}')
|
raise RequestError(f'用户信息请求错误:\n{user_info.error}')
|
||||||
self.processed_data.user_info = user_info
|
self.processed_data.user_info = user_info
|
||||||
@@ -125,20 +110,10 @@ class Processor(ProcessorMeta):
|
|||||||
async def get_user_records(self) -> RecordsSuccess:
|
async def get_user_records(self) -> RecordsSuccess:
|
||||||
"""获取Solo数据"""
|
"""获取Solo数据"""
|
||||||
if self.processed_data.user_records is None:
|
if self.processed_data.user_records is None:
|
||||||
self.raw_response.user_records = await Request.request(
|
self.raw_response.user_records = await Cache.get(
|
||||||
splice_url(
|
splice_url([BASE_URL, 'users/', f'{self.user.ID or self.user.name}/', 'records'])
|
||||||
[
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
user_records: UserRecords = type_validate_json(UserRecords, self.raw_response.user_records) # type: ignore[arg-type]
|
||||||
if isinstance(user_records, RecordsFailed):
|
if isinstance(user_records, RecordsFailed):
|
||||||
raise RequestError(f'用户Solo数据请求错误:\n{user_records.error}')
|
raise RequestError(f'用户Solo数据请求错误:\n{user_records.error}')
|
||||||
self.processed_data.user_records = user_records
|
self.processed_data.user_records = user_records
|
||||||
@@ -155,12 +130,13 @@ class Processor(ProcessorMeta):
|
|||||||
else:
|
else:
|
||||||
if isinstance(league, NeverRatedLeague):
|
if isinstance(league, NeverRatedLeague):
|
||||||
ret_message += f'用户 {user_name} 暂未完成定级赛, 最近十场的数据:'
|
ret_message += f'用户 {user_name} 暂未完成定级赛, 最近十场的数据:'
|
||||||
elif league.rank == 'z':
|
|
||||||
ret_message += f'用户 {user_name} 暂无段位, {round(league.rating,2)} TR'
|
|
||||||
else:
|
else:
|
||||||
ret_message += (
|
if league.rank == 'z':
|
||||||
f'{league.rank.upper()} 段用户 {user_name} {round(league.rating,2)} TR (#{league.standing})'
|
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)}, 最近十场的数据:'
|
ret_message += f', 段位分 {round(league.glicko,2)}±{round(league.rd,2)}, 最近十场的数据:'
|
||||||
lpm = league.pps * 24
|
lpm = league.pps * 24
|
||||||
ret_message += f"\nL'PM: {round(lpm, 2)} ( {league.pps} pps )"
|
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)
|
@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:
|
async def get_io_rank_data() -> None:
|
||||||
league_all: LeagueAll = parse_raw_as(
|
league_all: LeagueAll = type_validate_json(
|
||||||
LeagueAll, # type: ignore[arg-type]
|
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):
|
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:
|
def pps(user: LeagueAllUser) -> float:
|
||||||
return user.league.pps
|
return user.league.pps
|
||||||
@@ -214,7 +191,11 @@ async def get_io_rank_data() -> None:
|
|||||||
sort: Callable[[list[LeagueAllUser], Callable[[LeagueAllUser], float]], LeagueAllUser],
|
sort: Callable[[list[LeagueAllUser], Callable[[LeagueAllUser], float]], LeagueAllUser],
|
||||||
) -> tuple[dict[str, str], float]:
|
) -> tuple[dict[str, str], float]:
|
||||||
user = sort(users, field)
|
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)]
|
users = [i for i in league_all.data.users if isinstance(i, LeagueAllUser)]
|
||||||
rank_to_users: defaultdict[Rank, list[LeagueAllUser]] = defaultdict(list)
|
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_pps=(build_extremes_data(rank_users, pps, _max)),
|
||||||
high_apm=(build_extremes_data(rank_users, apm, _max)),
|
high_apm=(build_extremes_data(rank_users, apm, _max)),
|
||||||
high_vs=(build_extremes_data(rank_users, vs, _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:
|
async with get_session() as session:
|
||||||
@@ -247,8 +230,8 @@ async def get_io_rank_data() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@driver.on_startup
|
@driver.on_startup
|
||||||
async def check_rank_data() -> None:
|
async def _() -> None:
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
latest_time = await session.scalar(select(IORank.create_time).order_by(IORank.id.desc()).limit(1))
|
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):
|
if latest_time is None or datetime.now(tz=UTC) - latest_time.replace(tzinfo=UTC) > timedelta(hours=6):
|
||||||
await get_io_rank_data()
|
await get_io_rank_data()
|
||||||
|
|||||||
@@ -28,20 +28,20 @@ class SuccessModel(BaseSuccessModel):
|
|||||||
league: League
|
league: League
|
||||||
supporter: bool
|
supporter: bool
|
||||||
verified: bool
|
verified: bool
|
||||||
country: str | None
|
country: str | None = None
|
||||||
|
|
||||||
class InvalidUser(BaseModel):
|
class InvalidUser(BaseModel):
|
||||||
class League(BaseModel):
|
class League(BaseModel):
|
||||||
gamesplayed: int
|
gamesplayed: int
|
||||||
gameswon: int
|
gameswon: int
|
||||||
rating: float
|
rating: float
|
||||||
glicko: float | None
|
glicko: float | None = None
|
||||||
rd: float | None
|
rd: float | None = None
|
||||||
rank: Rank
|
rank: Rank
|
||||||
bestrank: Rank
|
bestrank: Rank
|
||||||
apm: float | None
|
apm: float | None = None
|
||||||
pps: float | None
|
pps: float | None = None
|
||||||
vs: float | None
|
vs: float | None = None
|
||||||
decaying: bool
|
decaying: bool
|
||||||
|
|
||||||
id: str = Field(..., alias='_id')
|
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):
|
class Badge(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
label: str
|
label: str
|
||||||
ts: datetime | None
|
ts: datetime | None = None
|
||||||
|
|
||||||
class NeverPlayedLeague(BaseModel):
|
class NeverPlayedLeague(BaseModel):
|
||||||
gamesplayed: Literal[0]
|
gamesplayed: Literal[0]
|
||||||
@@ -29,9 +29,9 @@ class SuccessModel(BaseSuccessModel):
|
|||||||
prev_at: Literal[-1]
|
prev_at: Literal[-1]
|
||||||
percentile: Literal[-1]
|
percentile: Literal[-1]
|
||||||
percentile_rank: Literal['z']
|
percentile_rank: Literal['z']
|
||||||
apm: None
|
apm: None = Field(None)
|
||||||
pps: None
|
pps: None = Field(None)
|
||||||
vs: None
|
vs: None = Field(None)
|
||||||
decaying: bool
|
decaying: bool
|
||||||
|
|
||||||
class NeverRatedLeague(BaseModel):
|
class NeverRatedLeague(BaseModel):
|
||||||
@@ -60,8 +60,8 @@ class SuccessModel(BaseSuccessModel):
|
|||||||
bestrank: Rank
|
bestrank: Rank
|
||||||
standing: int
|
standing: int
|
||||||
standing_local: int
|
standing_local: int
|
||||||
next_rank: Rank | None
|
next_rank: Rank | None = None
|
||||||
prev_rank: Rank | None
|
prev_rank: Rank | None = None
|
||||||
next_at: int
|
next_at: int
|
||||||
prev_at: int
|
prev_at: int
|
||||||
percentile: float
|
percentile: float
|
||||||
@@ -70,7 +70,7 @@ class SuccessModel(BaseSuccessModel):
|
|||||||
rd: float
|
rd: float
|
||||||
apm: float
|
apm: float
|
||||||
pps: float
|
pps: float
|
||||||
vs: float | None
|
vs: float | None = None
|
||||||
decaying: bool
|
decaying: bool
|
||||||
|
|
||||||
class Connections(BaseModel):
|
class Connections(BaseModel):
|
||||||
@@ -78,41 +78,41 @@ class SuccessModel(BaseSuccessModel):
|
|||||||
id: str
|
id: str
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
discord: Discord | None
|
discord: Discord | None = None
|
||||||
|
|
||||||
class Distinguishment(BaseModel):
|
class Distinguishment(BaseModel):
|
||||||
type: str # noqa: A003
|
type: str
|
||||||
|
|
||||||
id: str = Field(..., alias='_id')
|
id: str = Field(..., alias='_id')
|
||||||
username: str
|
username: str
|
||||||
role: Literal['anon', 'user', 'bot', 'halfmod', 'mod', 'admin', 'sysop', 'banned']
|
role: Literal['anon', 'user', 'bot', 'halfmod', 'mod', 'admin', 'sysop', 'banned']
|
||||||
ts: datetime | None
|
ts: datetime | None = None
|
||||||
botmaster: str | None
|
botmaster: str | None = None
|
||||||
badges: list[Badge]
|
badges: list[Badge]
|
||||||
xp: float
|
xp: float
|
||||||
gamesplayed: int
|
gamesplayed: int
|
||||||
gameswon: int
|
gameswon: int
|
||||||
gametime: float
|
gametime: float
|
||||||
country: str | None
|
country: str | None = None
|
||||||
badstanding: bool | None
|
badstanding: bool | None = None
|
||||||
supporter: bool | None # osk说是必有, 但实际上不是 fk osk
|
supporter: bool | None = None # osk说是必有, 但实际上不是 fk osk
|
||||||
supporter_tier: int
|
supporter_tier: int
|
||||||
verified: bool
|
verified: bool
|
||||||
league: NeverPlayedLeague | NeverRatedLeague | RatedLeague
|
league: NeverPlayedLeague | NeverRatedLeague | RatedLeague
|
||||||
avatar_revision: int | None
|
avatar_revision: int | None = None
|
||||||
"""This user's avatar ID. Get their avatar at
|
"""This user's avatar ID. Get their avatar at
|
||||||
|
|
||||||
https://tetr.io/user-content/avatars/{ USERID }.jpg?rv={ AVATAR_REVISION }"""
|
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
|
"""This user's banner ID. Get their banner at
|
||||||
|
|
||||||
https://tetr.io/user-content/banners/{ USERID }.jpg?rv={ BANNER_REVISION }
|
https://tetr.io/user-content/banners/{ USERID }.jpg?rv={ BANNER_REVISION }
|
||||||
|
|
||||||
Ignore this field if the user is not a supporter."""
|
Ignore this field if the user is not a supporter."""
|
||||||
bio: str | None
|
bio: str | None = None
|
||||||
connections: Connections
|
connections: Connections
|
||||||
friend_count: int
|
friend_count: int | None = None
|
||||||
distinguishment: Distinguishment | None
|
distinguishment: Distinguishment | None = None
|
||||||
|
|
||||||
user: User
|
user: User
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ class EndContext(BaseModel):
|
|||||||
zero: bool
|
zero: bool
|
||||||
locked: bool
|
locked: bool
|
||||||
prev: int
|
prev: int
|
||||||
frameoffset: int
|
frameoffset: int | None = None
|
||||||
|
|
||||||
class Clears(BaseModel):
|
class Clears(BaseModel):
|
||||||
singles: int
|
singles: int
|
||||||
doubles: int
|
doubles: int
|
||||||
triples: int
|
triples: int
|
||||||
quads: int
|
quads: int
|
||||||
pentas: int | None
|
pentas: int | None = None
|
||||||
realtspins: int
|
realtspins: int
|
||||||
minitspins: int
|
minitspins: int
|
||||||
minitspinsingles: int
|
minitspinsingles: int
|
||||||
@@ -33,7 +33,7 @@ class EndContext(BaseModel):
|
|||||||
class Garbage(BaseModel):
|
class Garbage(BaseModel):
|
||||||
sent: int
|
sent: int
|
||||||
received: int
|
received: int
|
||||||
attack: int | None
|
attack: int | None = None
|
||||||
cleared: int
|
cleared: int
|
||||||
|
|
||||||
class Finesse(BaseModel):
|
class Finesse(BaseModel):
|
||||||
@@ -46,18 +46,18 @@ class EndContext(BaseModel):
|
|||||||
level_lines: int
|
level_lines: int
|
||||||
level_lines_needed: int
|
level_lines_needed: int
|
||||||
inputs: int
|
inputs: int
|
||||||
holds: int | None
|
holds: int | None = None
|
||||||
time: Time
|
time: Time
|
||||||
score: int
|
score: int
|
||||||
zenlevel: int
|
zenlevel: int | None = None
|
||||||
zenprogress: int
|
zenprogress: int | None = None
|
||||||
level: int
|
level: int
|
||||||
combo: int
|
combo: int
|
||||||
currentcombopower: int | None # WTF
|
currentcombopower: int | None = None # WTF
|
||||||
topcombo: int
|
topcombo: int
|
||||||
btb: int
|
btb: int
|
||||||
topbtb: int
|
topbtb: int
|
||||||
currentbtbchainpower: int | None # WTF * 2
|
currentbtbchainpower: int | None = None # WTF * 2
|
||||||
tspins: int
|
tspins: int
|
||||||
piecesplaced: int
|
piecesplaced: int
|
||||||
clears: Clears
|
clears: Clears
|
||||||
@@ -79,7 +79,7 @@ class BaseModeRecord(BaseModel):
|
|||||||
replayid: str
|
replayid: str
|
||||||
user: User
|
user: User
|
||||||
ts: datetime
|
ts: datetime
|
||||||
ismulti: bool | None
|
ismulti: bool | None = None
|
||||||
endcontext: EndContext
|
endcontext: EndContext
|
||||||
|
|
||||||
class MultiRecord(BaseModel):
|
class MultiRecord(BaseModel):
|
||||||
@@ -92,21 +92,19 @@ class BaseModeRecord(BaseModel):
|
|||||||
replayid: str
|
replayid: str
|
||||||
user: User
|
user: User
|
||||||
ts: datetime
|
ts: datetime
|
||||||
ismulti: bool | None
|
ismulti: bool | None = None
|
||||||
endcontext: list[EndContext]
|
endcontext: list[EndContext]
|
||||||
|
|
||||||
record: SoloRecord | MultiRecord | None
|
record: SoloRecord | MultiRecord | None = None
|
||||||
rank: int | None
|
rank: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class SuccessModel(BaseSuccessModel):
|
class SuccessModel(BaseSuccessModel):
|
||||||
class Data(BaseModel):
|
class Data(BaseModel):
|
||||||
class Records(BaseModel):
|
class Records(BaseModel):
|
||||||
class Sprint(BaseModeRecord):
|
class Sprint(BaseModeRecord): ...
|
||||||
...
|
|
||||||
|
|
||||||
class Blitz(BaseModeRecord):
|
class Blitz(BaseModeRecord): ...
|
||||||
...
|
|
||||||
|
|
||||||
sprint: Sprint = Field(..., alias='40l')
|
sprint: Sprint = Field(..., alias='40l')
|
||||||
blitz: Blitz
|
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.adapters import Bot, Event
|
||||||
from nonebot.matcher import Matcher
|
from nonebot.matcher import Matcher
|
||||||
from nonebot_plugin_alconna import AlcMatches, At, on_alconna
|
from nonebot_plugin_alconna import At, on_alconna
|
||||||
from nonebot_plugin_orm import get_session
|
from nonebot_plugin_orm import get_session
|
||||||
|
|
||||||
from ...db import query_bind_info
|
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.platform import get_platform
|
||||||
from ...utils.typing import Me
|
from ...utils.typing import Me
|
||||||
|
from .. import add_default_handlers
|
||||||
from ..constant import BIND_COMMAND, QUERY_COMMAND
|
from ..constant import BIND_COMMAND, QUERY_COMMAND
|
||||||
from .constant import GAME_TYPE
|
from .constant import GAME_TYPE
|
||||||
from .processor import Processor, User, identify_user_info
|
from .processor import Processor, User, identify_user_info
|
||||||
@@ -51,6 +52,7 @@ alc = on_alconna(
|
|||||||
dest='query',
|
dest='query',
|
||||||
help_text='查询 TOP 游戏信息',
|
help_text='查询 TOP 游戏信息',
|
||||||
),
|
),
|
||||||
|
Arg('other', AllParam, flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL]),
|
||||||
meta=CommandMeta(
|
meta=CommandMeta(
|
||||||
description='查询 TetrisOnline波兰服 的信息',
|
description='查询 TetrisOnline波兰服 的信息',
|
||||||
example='top绑定scdhh\ntop查我',
|
example='top绑定scdhh\ntop查我',
|
||||||
@@ -74,7 +76,8 @@ async def _(bot: Bot, event: Event, matcher: Matcher, account: User):
|
|||||||
try:
|
try:
|
||||||
await matcher.finish(await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id()))
|
await matcher.finish(await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id()))
|
||||||
except NeedCatchError as e:
|
except NeedCatchError as e:
|
||||||
await matcher.finish(str(e))
|
await matcher.send(str(e))
|
||||||
|
raise HandleNotFinishedError from e
|
||||||
|
|
||||||
|
|
||||||
@alc.assign('query')
|
@alc.assign('query')
|
||||||
@@ -97,7 +100,8 @@ async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me):
|
|||||||
try:
|
try:
|
||||||
await matcher.finish(message + await proc.handle_query())
|
await matcher.finish(message + await proc.handle_query())
|
||||||
except NeedCatchError as e:
|
except NeedCatchError as e:
|
||||||
await matcher.finish(str(e))
|
await matcher.send(str(e))
|
||||||
|
raise HandleNotFinishedError from e
|
||||||
|
|
||||||
|
|
||||||
@alc.assign('query')
|
@alc.assign('query')
|
||||||
@@ -110,17 +114,8 @@ async def _(event: Event, matcher: Matcher, account: User):
|
|||||||
try:
|
try:
|
||||||
await matcher.finish(await proc.handle_query())
|
await matcher.finish(await proc.handle_query())
|
||||||
except NeedCatchError as e:
|
except NeedCatchError as e:
|
||||||
await matcher.finish(str(e))
|
await matcher.send(str(e))
|
||||||
|
raise HandleNotFinishedError from e
|
||||||
|
|
||||||
|
|
||||||
@alc.handle()
|
add_default_handlers(alc)
|
||||||
async def _(matcher: Matcher, account: MessageFormatError):
|
|
||||||
await matcher.finish(str(account))
|
|
||||||
|
|
||||||
|
|
||||||
@alc.handle()
|
|
||||||
async def _(matcher: Matcher, matches: AlcMatches):
|
|
||||||
if matches.head_matched:
|
|
||||||
await matcher.finish(
|
|
||||||
f'{matches.error_info!r}\n' if matches.error_info is not None else '' + '输入"top --help"查看帮助'
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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/'
|
BASE_URL = 'http://tetrisonline.pl/top/'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from re import match
|
from re import match
|
||||||
from typing import NoReturn
|
from typing import Literal
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
@@ -12,27 +12,20 @@ from pandas import read_html
|
|||||||
from ...db import create_or_update_bind
|
from ...db import create_or_update_bind
|
||||||
from ...utils.exception import MessageFormatError, RequestError
|
from ...utils.exception import MessageFormatError, RequestError
|
||||||
from ...utils.request import Request, splice_url
|
from ...utils.request import Request, splice_url
|
||||||
from ...utils.typing import GameType
|
|
||||||
from .. import ProcessedData as ProcessedDataMeta
|
|
||||||
from .. import Processor as ProcessorMeta
|
from .. import Processor as ProcessorMeta
|
||||||
from .. import RawResponse as RawResponseMeta
|
from ..schemas import BaseUser
|
||||||
from .. import User as UserMeta
|
|
||||||
from .constant import BASE_URL, GAME_TYPE
|
from .constant import BASE_URL, GAME_TYPE
|
||||||
|
from .schemas.response import ProcessedData, RawResponse
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class User(BaseUser):
|
||||||
class User(UserMeta):
|
platform: Literal['TOP'] = GAME_TYPE
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
@property
|
||||||
@dataclass
|
def unique_identifier(self) -> str:
|
||||||
class RawResponse(RawResponseMeta):
|
return self.name
|
||||||
user_profile: bytes | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProcessedData(ProcessedDataMeta):
|
|
||||||
user_profile: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -64,7 +57,7 @@ class Processor(ProcessorMeta):
|
|||||||
self.processed_data = ProcessedData()
|
self.processed_data = ProcessedData()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def game_platform(self) -> GameType:
|
def game_platform(self) -> Literal['TOP']:
|
||||||
return GAME_TYPE
|
return GAME_TYPE
|
||||||
|
|
||||||
async def handle_bind(self, platform: str, account: str) -> str:
|
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()
|
self.processed_data.user_profile = self.raw_response.user_profile.decode()
|
||||||
return self.processed_data.user_profile
|
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():
|
if 'user not found!' in await self.get_user_profile():
|
||||||
raise RequestError('用户不存在!')
|
raise RequestError('用户不存在!')
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_user_name(self) -> str:
|
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.adapters import Bot, Event
|
||||||
from nonebot.matcher import Matcher
|
from nonebot.matcher import Matcher
|
||||||
from nonebot_plugin_alconna import AlcMatches, At, on_alconna
|
from nonebot_plugin_alconna import At, on_alconna
|
||||||
from nonebot_plugin_orm import get_session
|
from nonebot_plugin_orm import get_session
|
||||||
|
|
||||||
from ...db import query_bind_info
|
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.platform import get_platform
|
||||||
from ...utils.typing import Me
|
from ...utils.typing import Me
|
||||||
|
from .. import add_default_handlers
|
||||||
from ..constant import BIND_COMMAND, QUERY_COMMAND
|
from ..constant import BIND_COMMAND, QUERY_COMMAND
|
||||||
from .constant import GAME_TYPE
|
from .constant import GAME_TYPE
|
||||||
from .processor import Processor, User, identify_user_info
|
from .processor import Processor, User, identify_user_info
|
||||||
@@ -52,6 +55,7 @@ alc = on_alconna(
|
|||||||
dest='query',
|
dest='query',
|
||||||
help_text='查询 茶服 游戏信息',
|
help_text='查询 茶服 游戏信息',
|
||||||
),
|
),
|
||||||
|
Arg('other', AllParam, flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL]),
|
||||||
meta=CommandMeta(
|
meta=CommandMeta(
|
||||||
description='查询 TetrisOnline茶服 的信息',
|
description='查询 TetrisOnline茶服 的信息',
|
||||||
example='茶服查我',
|
example='茶服查我',
|
||||||
@@ -64,27 +68,63 @@ alc = on_alconna(
|
|||||||
aliases={'tos', 'TOS'},
|
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 finish_special_query(matcher: Matcher, proc: Processor) -> NoReturn:
|
||||||
async def _(event: MessageEvent, matcher: Matcher):
|
try:
|
||||||
await matcher.finish('QQ 平台无需绑定')
|
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')
|
@alc.assign('query')
|
||||||
async def _(bot: OB11Bot, event: MessageEvent, matcher: Matcher, target: At | Me):
|
async def _(bot: OB11Bot, event: OB11MessageEvent, matcher: Matcher, target: At | Me):
|
||||||
if event.is_tome() and await GROUP(bot, event):
|
if event.is_tome() and await OB11GROUP(bot, event):
|
||||||
await matcher.finish('不能查询bot的信息')
|
await matcher.finish('不能查询bot的信息')
|
||||||
proc = Processor(
|
proc = Processor(
|
||||||
event_id=id(event),
|
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=[],
|
command_args=[],
|
||||||
)
|
)
|
||||||
try:
|
await finish_special_query(matcher, proc)
|
||||||
await matcher.finish(await proc.handle_query())
|
|
||||||
except NeedCatchError as e:
|
except ImportError:
|
||||||
await matcher.finish(str(e))
|
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:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -99,7 +139,8 @@ async def _(bot: Bot, event: Event, matcher: Matcher, account: User):
|
|||||||
try:
|
try:
|
||||||
await matcher.finish(await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id()))
|
await matcher.finish(await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id()))
|
||||||
except NeedCatchError as e:
|
except NeedCatchError as e:
|
||||||
await matcher.finish(str(e))
|
await matcher.send(str(e))
|
||||||
|
raise HandleNotFinishedError from e
|
||||||
|
|
||||||
|
|
||||||
@alc.assign('query')
|
@alc.assign('query')
|
||||||
@@ -116,13 +157,14 @@ async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me):
|
|||||||
message = '* 由于无法验证绑定信息, 不能保证查询到的用户为本人\n'
|
message = '* 由于无法验证绑定信息, 不能保证查询到的用户为本人\n'
|
||||||
proc = Processor(
|
proc = Processor(
|
||||||
event_id=id(event),
|
event_id=id(event),
|
||||||
user=User(name=bind.game_account),
|
user=User(teaid=bind.game_account),
|
||||||
command_args=[],
|
command_args=[],
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await matcher.finish(message + await proc.handle_query())
|
await matcher.finish(message + await proc.handle_query())
|
||||||
except NeedCatchError as e:
|
except NeedCatchError as e:
|
||||||
await matcher.finish(str(e))
|
await matcher.send(str(e))
|
||||||
|
raise HandleNotFinishedError from e
|
||||||
|
|
||||||
|
|
||||||
@alc.assign('query')
|
@alc.assign('query')
|
||||||
@@ -135,17 +177,8 @@ async def _(event: Event, matcher: Matcher, account: User):
|
|||||||
try:
|
try:
|
||||||
await matcher.finish(await proc.handle_query())
|
await matcher.finish(await proc.handle_query())
|
||||||
except NeedCatchError as e:
|
except NeedCatchError as e:
|
||||||
await matcher.finish(str(e))
|
await matcher.send(str(e))
|
||||||
|
raise HandleNotFinishedError from e
|
||||||
|
|
||||||
|
|
||||||
@alc.handle()
|
add_default_handlers(alc)
|
||||||
async def _(matcher: Matcher, account: MessageFormatError):
|
|
||||||
await matcher.finish(str(account))
|
|
||||||
|
|
||||||
|
|
||||||
@alc.handle()
|
|
||||||
async def _(matcher: Matcher, matches: AlcMatches):
|
|
||||||
if matches.head_matched:
|
|
||||||
await matcher.finish(
|
|
||||||
f'{matches.error_info!r}\n' if matches.error_info is not None else '' + '输入"茶服 --help"查看帮助'
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
from ...utils.typing import GameType
|
from typing import Literal
|
||||||
|
|
||||||
GAME_TYPE: GameType = 'TOS'
|
GAME_TYPE: Literal['TOS'] = 'TOS'
|
||||||
BASE_URL = 'https://teatube.cn:8888/'
|
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 dataclasses import dataclass
|
||||||
from re import match
|
from re import match
|
||||||
from typing import Any
|
from typing import Literal
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from httpx import TimeoutException
|
||||||
|
from nonebot.compat import type_validate_json
|
||||||
from nonebot_plugin_orm import get_session
|
from nonebot_plugin_orm import get_session
|
||||||
from pydantic import parse_raw_as
|
|
||||||
|
|
||||||
from ...db import create_or_update_bind
|
from ...db import create_or_update_bind
|
||||||
from ...utils.exception import MessageFormatError, RequestError
|
from ...utils.exception import MessageFormatError, RequestError
|
||||||
from ...utils.request import Request, splice_url
|
from ...utils.request import Request, splice_url
|
||||||
from ...utils.typing import GameType
|
|
||||||
from .. import ProcessedData as ProcessedDataMeta
|
|
||||||
from .. import Processor as ProcessorMeta
|
from .. import Processor as ProcessorMeta
|
||||||
from .. import RawResponse as RawResponseMeta
|
from ..schemas import BaseUser
|
||||||
from .. import User as UserMeta
|
|
||||||
from .constant import BASE_URL, GAME_TYPE
|
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 SuccessModel as InfoSuccess
|
||||||
from .schemas.user_info import UserInfo
|
from .schemas.user_info import UserInfo
|
||||||
from .schemas.user_profile import UserProfile
|
from .schemas.user_profile import UserProfile
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class User(BaseUser):
|
||||||
class User(UserMeta):
|
platform: Literal['TOS'] = GAME_TYPE
|
||||||
|
|
||||||
teaid: str | None = None
|
teaid: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
@dataclass
|
def unique_identifier(self) -> str:
|
||||||
class RawResponse(RawResponseMeta):
|
if self.teaid is None:
|
||||||
user_profile: dict[frozenset[tuple[str, Any]], bytes]
|
raise ValueError('不完整的User!')
|
||||||
user_info: bytes | None = None
|
return self.teaid
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProcessedData(ProcessedDataMeta):
|
|
||||||
user_profile: dict[frozenset[tuple[str, Any]], UserProfile]
|
|
||||||
user_info: InfoSuccess | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -60,7 +54,7 @@ def identify_user_info(info: str) -> User | MessageFormatError:
|
|||||||
and 2 <= len(info) <= 18 # noqa: PLR2004
|
and 2 <= len(info) <= 18 # noqa: PLR2004
|
||||||
):
|
):
|
||||||
return User(name=info)
|
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 User(teaid=info)
|
||||||
return MessageFormatError('用户名/QQ号不合法')
|
return MessageFormatError('用户名/QQ号不合法')
|
||||||
|
|
||||||
@@ -76,22 +70,20 @@ class Processor(ProcessorMeta):
|
|||||||
self.processed_data = ProcessedData(user_profile={})
|
self.processed_data = ProcessedData(user_profile={})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def game_platform(self) -> GameType:
|
def game_platform(self) -> Literal['TOS']:
|
||||||
return GAME_TYPE
|
return GAME_TYPE
|
||||||
|
|
||||||
async def handle_bind(self, platform: str, account: str) -> str:
|
async def handle_bind(self, platform: str, account: str) -> str:
|
||||||
"""处理绑定消息"""
|
"""处理绑定消息"""
|
||||||
self.command_type = 'bind'
|
self.command_type = 'bind'
|
||||||
await self.get_user()
|
await self.get_user()
|
||||||
if self.user.name is None:
|
|
||||||
raise # FIXME: 不知道怎么才能把这类型给变过来了
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
return await create_or_update_bind(
|
return await create_or_update_bind(
|
||||||
session=session,
|
session=session,
|
||||||
chat_platform=platform,
|
chat_platform=platform,
|
||||||
chat_account=account,
|
chat_account=account,
|
||||||
game_platform=GAME_TYPE,
|
game_platform=GAME_TYPE,
|
||||||
game_account=self.user.name,
|
game_account=self.user.unique_identifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_query(self) -> str:
|
async def handle_query(self) -> str:
|
||||||
@@ -113,45 +105,60 @@ class Processor(ProcessorMeta):
|
|||||||
"""获取用户信息"""
|
"""获取用户信息"""
|
||||||
if self.processed_data.user_info is None:
|
if self.processed_data.user_info is None:
|
||||||
if self.user.teaid is not None:
|
if self.user.teaid is not None:
|
||||||
url = splice_url(
|
url = [
|
||||||
[
|
splice_url(
|
||||||
BASE_URL,
|
[
|
||||||
'getTeaIdInfo',
|
i,
|
||||||
f'?{urlencode({"teaId":self.user.teaid})}',
|
'getTeaIdInfo',
|
||||||
]
|
f'?{urlencode({"teaId":self.user.teaid})}',
|
||||||
)
|
]
|
||||||
|
)
|
||||||
|
for i in BASE_URL
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
url = splice_url(
|
url = [
|
||||||
[
|
splice_url(
|
||||||
BASE_URL,
|
[
|
||||||
'getUsernameInfo',
|
i,
|
||||||
f'?{urlencode({"username":self.user.name})}',
|
'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]
|
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):
|
if not isinstance(user_info, InfoSuccess):
|
||||||
raise RequestError(f'用户信息请求错误:\n{user_info.error}')
|
raise RequestError(f'用户信息请求错误:\n{user_info.error}')
|
||||||
self.processed_data.user_info = user_info
|
self.processed_data.user_info = user_info
|
||||||
return self.processed_data.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:
|
if other_parameter is None:
|
||||||
other_parameter = {}
|
other_parameter = {}
|
||||||
fset = frozenset(other_parameter.items())
|
params = urlencode(dict(sorted(other_parameter.items())))
|
||||||
if self.processed_data.user_profile.get(fset) is None:
|
if self.processed_data.user_profile.get(params) is None:
|
||||||
self.raw_response.user_profile[fset] = await Request.request(
|
self.raw_response.user_profile[params] = await Request.failover_request(
|
||||||
splice_url(
|
[
|
||||||
[
|
splice_url(
|
||||||
BASE_URL,
|
[
|
||||||
'getProfile',
|
i,
|
||||||
f'?{urlencode({"id":self.user.teaid or self.user.name},**other_parameter)}',
|
'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])
|
self.processed_data.user_profile[params] = UserProfile.model_validate_json(
|
||||||
return self.processed_data.user_profile[fset]
|
self.raw_response.user_profile[params]
|
||||||
|
)
|
||||||
|
return self.processed_data.user_profile[params]
|
||||||
|
|
||||||
async def get_game_data(self) -> GameData | None:
|
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"\nL'PM: {game_data.lpm} ( {game_data.pps} pps )"
|
||||||
message += f'\nAPM: {game_data.apm} ( x{game_data.apl} )'
|
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'\nADPM: {game_data.adpm} ( x{game_data.adpl} ) ( {game_data.vs}vs )'
|
||||||
message += (
|
message += f'\n40L: {float(user_info.pb_sprint)/1000:.2f}s' if user_info.pb_sprint != '2147483647' else ''
|
||||||
f'\n40L: {float(user_info.pb_sprint)/1000:.2f}s'
|
message += f'\nMarathon: {user_info.pb_marathon}' if user_info.pb_marathon != '0' else ''
|
||||||
if user_info.pb_sprint != 2147483647 # noqa: PLR2004
|
message += f'\nChallenge: {user_info.pb_challenge}' if user_info.pb_challenge != '0' else ''
|
||||||
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
|
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
|
import sys
|
||||||
from os import environ
|
from os import environ
|
||||||
from platform import system
|
from platform import system
|
||||||
|
from re import sub
|
||||||
|
|
||||||
from nonebot import get_driver
|
from nonebot import get_driver
|
||||||
from nonebot.log import logger
|
from nonebot.log import logger
|
||||||
@@ -33,12 +34,12 @@ class BrowserManager:
|
|||||||
raise ImportError('加载失败, Windows 必须设置 FASTAPI_RELOAD=false 才能正常运行 playwright')
|
raise ImportError('加载失败, Windows 必须设置 FASTAPI_RELOAD=false 才能正常运行 playwright')
|
||||||
logger.info('开始 安装/更新 playwright 浏览器')
|
logger.info('开始 安装/更新 playwright 浏览器')
|
||||||
environ['PLAYWRIGHT_DOWNLOAD_HOST'] = 'https://npmmirror.com/mirrors/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 浏览器成功')
|
logger.success('安装/更新 playwright 浏览器成功')
|
||||||
else:
|
else:
|
||||||
logger.warning('playwright 浏览器 安装/更新 失败, 尝试使用原始仓库下载')
|
logger.warning('playwright 浏览器 安装/更新 失败, 尝试使用原始仓库下载')
|
||||||
del environ['PLAYWRIGHT_DOWNLOAD_HOST']
|
del environ['PLAYWRIGHT_DOWNLOAD_HOST']
|
||||||
if cls._handle_error(cls._call_playwright(['', 'install', 'firefox'])):
|
if cls._call_playwright(['', 'install', 'firefox']):
|
||||||
logger.success('安装/更新 playwright 浏览器成功')
|
logger.success('安装/更新 playwright 浏览器成功')
|
||||||
else:
|
else:
|
||||||
logger.error('安装/更新 playwright 浏览器失败')
|
logger.error('安装/更新 playwright 浏览器失败')
|
||||||
@@ -52,26 +53,20 @@ class BrowserManager:
|
|||||||
logger.success('playwright 启动成功')
|
logger.success('playwright 启动成功')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _call_playwright(cls, argv: list[str]) -> BaseException:
|
def _call_playwright(cls, argv: list[str]) -> bool:
|
||||||
"""等价于调用 playwright 的命令行程序"""
|
"""等价于调用 playwright 的命令行程序"""
|
||||||
argv_backup = sys.argv.copy()
|
argv_backup = sys.argv.copy()
|
||||||
from re import sub
|
|
||||||
|
|
||||||
sys.argv[0] = sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
sys.argv = argv
|
sys.argv = argv
|
||||||
try:
|
try:
|
||||||
main()
|
main()
|
||||||
except BaseException as e: # noqa: BLE001 不在这里处理 playwright 的异常
|
except SystemExit as e:
|
||||||
return e
|
return e.code == 0
|
||||||
|
except BaseException: # noqa: BLE001
|
||||||
|
return False
|
||||||
finally:
|
finally:
|
||||||
sys.argv = argv_backup
|
sys.argv = argv_backup
|
||||||
return SystemExit(0)
|
return True
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _handle_error(cls, error: BaseException) -> bool:
|
|
||||||
if isinstance(error, SystemExit) and error.code == 0:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _start_browser(cls) -> Browser:
|
async def _start_browser(cls) -> Browser:
|
||||||
|
|||||||
@@ -15,21 +15,25 @@ class NeedCatchError(TetrisStatsError):
|
|||||||
"""需要被捕获的异常基类"""
|
"""需要被捕获的异常基类"""
|
||||||
|
|
||||||
|
|
||||||
class DoNotCatchError(TetrisStatsError):
|
|
||||||
"""不应该被捕获的异常基类"""
|
|
||||||
|
|
||||||
|
|
||||||
class RequestError(NeedCatchError):
|
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 MessageFormatError(NeedCatchError):
|
||||||
"""用户发送的消息格式不正确"""
|
"""用户发送的消息格式不正确"""
|
||||||
|
|
||||||
|
|
||||||
class DatabaseVersionError(DoNotCatchError):
|
class DoNotCatchError(TetrisStatsError):
|
||||||
"""数据库版本错误"""
|
"""不应该被捕获的异常基类"""
|
||||||
|
|
||||||
|
|
||||||
class WhatTheFuckError(DoNotCatchError):
|
class WhatTheFuckError(DoNotCatchError):
|
||||||
"""用于表示不应该出现的情况 ("""
|
"""用于表示不应该出现的情况 ("""
|
||||||
|
|
||||||
|
|
||||||
|
class HandleNotFinishedError(DoNotCatchError):
|
||||||
|
"""任务没有正常完成处理的错误"""
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class TetrisMetricsProWithLPMADPM(TetrisMetricsBasicWithLPM, TetrisMetricsBaseWi
|
|||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get_metrics( # noqa: PLR0913
|
def get_metrics(
|
||||||
*,
|
*,
|
||||||
pps: Number,
|
pps: Number,
|
||||||
lpm: None = None,
|
lpm: None = None,
|
||||||
@@ -157,7 +157,7 @@ def get_metrics( # noqa: PLR0913
|
|||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get_metrics( # noqa: PLR0913
|
def get_metrics(
|
||||||
*,
|
*,
|
||||||
pps: None = None,
|
pps: None = None,
|
||||||
lpm: Number,
|
lpm: Number,
|
||||||
@@ -170,7 +170,7 @@ def get_metrics( # noqa: PLR0913
|
|||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get_metrics( # noqa: PLR0913
|
def get_metrics(
|
||||||
*,
|
*,
|
||||||
pps: None = None,
|
pps: None = None,
|
||||||
lpm: None = None,
|
lpm: None = None,
|
||||||
@@ -183,7 +183,7 @@ def get_metrics( # noqa: PLR0913
|
|||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get_metrics( # noqa: PLR0913
|
def get_metrics(
|
||||||
*,
|
*,
|
||||||
pps: None = None,
|
pps: None = None,
|
||||||
lpm: None = None,
|
lpm: None = None,
|
||||||
@@ -196,7 +196,7 @@ def get_metrics( # noqa: PLR0913
|
|||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get_metrics( # noqa: PLR0913
|
def get_metrics(
|
||||||
*,
|
*,
|
||||||
pps: Number,
|
pps: Number,
|
||||||
lpm: None = None,
|
lpm: None = None,
|
||||||
@@ -209,7 +209,7 @@ def get_metrics( # noqa: PLR0913
|
|||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get_metrics( # noqa: PLR0913
|
def get_metrics(
|
||||||
*,
|
*,
|
||||||
pps: None = None,
|
pps: None = None,
|
||||||
lpm: Number,
|
lpm: Number,
|
||||||
@@ -222,7 +222,7 @@ def get_metrics( # noqa: PLR0913
|
|||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get_metrics( # noqa: PLR0913
|
def get_metrics(
|
||||||
*,
|
*,
|
||||||
pps: Number,
|
pps: Number,
|
||||||
lpm: None = None,
|
lpm: None = None,
|
||||||
@@ -235,7 +235,7 @@ def get_metrics( # noqa: PLR0913
|
|||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get_metrics( # noqa: PLR0913
|
def get_metrics(
|
||||||
*,
|
*,
|
||||||
pps: Number,
|
pps: Number,
|
||||||
lpm: None = None,
|
lpm: None = None,
|
||||||
@@ -248,7 +248,7 @@ def get_metrics( # noqa: PLR0913
|
|||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get_metrics( # noqa: PLR0913
|
def get_metrics(
|
||||||
*,
|
*,
|
||||||
pps: None = None,
|
pps: None = None,
|
||||||
lpm: Number,
|
lpm: Number,
|
||||||
@@ -261,7 +261,7 @@ def get_metrics( # noqa: PLR0913
|
|||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get_metrics( # noqa: PLR0913
|
def get_metrics(
|
||||||
*,
|
*,
|
||||||
pps: None = None,
|
pps: None = None,
|
||||||
lpm: Number,
|
lpm: Number,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import UTC, datetime
|
from datetime import datetime, timezone
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from nonebot import get_driver, get_plugin
|
from nonebot import get_driver, get_plugin
|
||||||
@@ -9,12 +9,15 @@ from nonebot_plugin_orm import get_session
|
|||||||
|
|
||||||
from ..db.models import HistoricalData
|
from ..db.models import HistoricalData
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
driver = get_driver()
|
driver = get_driver()
|
||||||
|
|
||||||
|
|
||||||
class Recorder:
|
class Recorder:
|
||||||
matchers: ClassVar[set[type[Matcher]]] = set()
|
matchers: ClassVar[set[type[Matcher]]] = set()
|
||||||
historical_data: ClassVar[dict[int, tuple[HistoricalData, bool]]] = {}
|
historical_data: ClassVar[dict[int, tuple[HistoricalData, bool]]] = {}
|
||||||
|
error_event: ClassVar[set[int]] = set()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_historical_data(cls, event_id: int, historical_data: HistoricalData) -> None:
|
def create_historical_data(cls, event_id: int, historical_data: HistoricalData) -> None:
|
||||||
@@ -32,17 +35,27 @@ class Recorder:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def save_historical_data(cls, event_id: int) -> None:
|
async def save_historical_data(cls, event_id: int) -> None:
|
||||||
if event_id not in cls.historical_data:
|
historical_data, completed = cls.del_historical_data(event_id)
|
||||||
raise KeyError
|
|
||||||
historical_data, completed = cls.historical_data.pop(event_id)
|
|
||||||
if completed:
|
if completed:
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
session.add(historical_data)
|
session.add(historical_data)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def del_historical_data(cls, event_id: int) -> None:
|
def del_historical_data(cls, event_id: int) -> tuple[HistoricalData, bool]:
|
||||||
cls.historical_data.pop(event_id)
|
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
|
@driver.on_startup
|
||||||
@@ -73,7 +86,9 @@ def _(bot: Bot, event: Event, matcher: Matcher):
|
|||||||
@run_postprocessor
|
@run_postprocessor
|
||||||
async def _(event: Event, matcher: Matcher, exception: Exception | None):
|
async def _(event: Event, matcher: Matcher, exception: Exception | None):
|
||||||
if isinstance(matcher, tuple(Recorder.matchers)):
|
if isinstance(matcher, tuple(Recorder.matchers)):
|
||||||
|
event_id = id(event)
|
||||||
if exception is not None:
|
if exception is not None:
|
||||||
Recorder.del_historical_data(id(event))
|
Recorder.add_error_event(event_id)
|
||||||
|
Recorder.del_historical_data(event_id)
|
||||||
else:
|
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 urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
from aiofiles import open
|
from aiofiles import open
|
||||||
from httpx import AsyncClient, HTTPError
|
from httpx import AsyncClient, HTTPError
|
||||||
from nonebot import get_driver
|
from nonebot import get_driver, get_plugin_config
|
||||||
from nonebot.log import logger
|
from nonebot.log import logger
|
||||||
from playwright.async_api import Response
|
from playwright.async_api import Response
|
||||||
from ujson import JSONDecodeError, dumps, loads
|
from ujson import JSONDecodeError, dumps, loads
|
||||||
|
|
||||||
from ..config.config import CACHE_PATH
|
from ..config.config import CACHE_PATH, Config
|
||||||
from .browser import BrowserManager
|
from .browser import BrowserManager
|
||||||
from .exception import RequestError
|
from .exception import RequestError
|
||||||
|
|
||||||
driver = get_driver()
|
driver = get_driver()
|
||||||
|
config = get_plugin_config(Config)
|
||||||
|
|
||||||
|
|
||||||
@driver.on_startup
|
@driver.on_startup
|
||||||
@@ -37,7 +40,7 @@ def splice_url(url_list: list[str]) -> str:
|
|||||||
class Request:
|
class Request:
|
||||||
"""网络请求相关类"""
|
"""网络请求相关类"""
|
||||||
|
|
||||||
_CACHE_FILE = CACHE_PATH.joinpath('cloudflare_cache.json')
|
_CACHE_FILE = CACHE_PATH / 'cloudflare_cache.json'
|
||||||
_headers: dict | None = None
|
_headers: dict | None = None
|
||||||
_cookies: dict | None = None
|
_cookies: dict | None = None
|
||||||
|
|
||||||
@@ -45,36 +48,31 @@ class Request:
|
|||||||
async def _anti_cloudflare(cls, url: str) -> bytes:
|
async def _anti_cloudflare(cls, url: str) -> bytes:
|
||||||
"""用firefox硬穿五秒盾"""
|
"""用firefox硬穿五秒盾"""
|
||||||
browser = await BrowserManager.get_browser()
|
browser = await BrowserManager.get_browser()
|
||||||
context = await browser.new_context()
|
async with await browser.new_context() as context, await context.new_page() as page:
|
||||||
page = await context.new_page()
|
response = await page.goto(url)
|
||||||
response = await page.goto(url)
|
attempts = 0
|
||||||
attempts = 0
|
while attempts < 60: # noqa: PLR2004
|
||||||
while attempts < 60: # noqa: PLR2004
|
attempts += 1
|
||||||
attempts += 1
|
text = await page.locator('body').text_content()
|
||||||
text = await page.locator('body').text_content()
|
if text is None:
|
||||||
if text is None:
|
await page.wait_for_timeout(1000)
|
||||||
await page.wait_for_timeout(1000)
|
continue
|
||||||
continue
|
if await page.title() == 'Please Wait... | Cloudflare':
|
||||||
if await page.title() == 'Please Wait... | Cloudflare':
|
logger.warning('疑似触发了 Cloudflare 的验证码')
|
||||||
logger.warning('疑似触发了 Cloudflare 的验证码')
|
break
|
||||||
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()
|
|
||||||
try:
|
try:
|
||||||
cls._cookies = {i['name']: i['value'] for i in await context.cookies()}
|
loads(text)
|
||||||
except KeyError:
|
except JSONDecodeError:
|
||||||
cls._cookies = None
|
await page.wait_for_timeout(1000)
|
||||||
await page.close()
|
else:
|
||||||
await context.close()
|
if not isinstance(response, Response):
|
||||||
return await response.body()
|
raise RequestError('api请求失败')
|
||||||
await page.close()
|
cls._headers = await response.request.all_headers()
|
||||||
await context.close()
|
try:
|
||||||
|
cls._cookies = {i['name']: i['value'] for i in await context.cookies()}
|
||||||
|
except KeyError:
|
||||||
|
cls._cookies = None
|
||||||
|
return await response.body()
|
||||||
raise RequestError('绕过五秒盾失败')
|
raise RequestError('绕过五秒盾失败')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -115,14 +113,49 @@ class Request:
|
|||||||
async def request(cls, url: str, *, is_json: bool = True) -> bytes:
|
async def request(cls, url: str, *, is_json: bool = True) -> bytes:
|
||||||
"""请求api"""
|
"""请求api"""
|
||||||
try:
|
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)
|
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:
|
if is_json:
|
||||||
loads(response.content)
|
loads(response.content)
|
||||||
return response.content
|
return response.content
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
raise RequestError(f'请求错误\n{e!r}') from e
|
raise RequestError(f'请求错误 \n{e!r}') from e
|
||||||
except JSONDecodeError:
|
except JSONDecodeError:
|
||||||
if urlparse(url).netloc.lower().endswith('tetr.io'):
|
if urlparse(url).netloc.lower().endswith('tetr.io'):
|
||||||
return await cls._anti_cloudflare(url)
|
return await cls._anti_cloudflare(url)
|
||||||
raise
|
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]
|
[tool.poetry]
|
||||||
name = 'nonebot-plugin-tetris-stats'
|
name = 'nonebot-plugin-tetris-stats'
|
||||||
version = '1.0.0.a3'
|
version = '1.0.0.a16'
|
||||||
description = '一款基于 NoneBot2 的用于查询 Tetris 相关游戏数据的插件'
|
description = '一款基于 NoneBot2 的用于查询 Tetris 相关游戏数据的插件'
|
||||||
authors = ['scdhh <wallfjjd@gmail.com>']
|
authors = ['scdhh <wallfjjd@gmail.com>']
|
||||||
readme = 'README.md'
|
readme = 'README.md'
|
||||||
@@ -10,33 +10,37 @@ license = 'AGPL-3.0'
|
|||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = '^3.10'
|
python = '^3.10'
|
||||||
nonebot2 = '^2.0.0-beta.3'
|
nonebot2 = '^2.2.0'
|
||||||
lxml = '^4.9.1'
|
lxml = '^5.1.0'
|
||||||
pandas = '>=1.4.3,<3.0.0'
|
pandas = '>=1.4.3,<3.0.0'
|
||||||
playwright = '^1.24.1'
|
playwright = '^1.41.2'
|
||||||
ujson = '^5.4.0'
|
ujson = '^5.9.0'
|
||||||
aiofiles = "^23.2.1"
|
aiofiles = "^23.2.1"
|
||||||
nonebot-plugin-orm = ">=0.1.1,<0.6.0"
|
nonebot-plugin-orm = ">=0.1.1,<0.8.0"
|
||||||
nonebot-plugin-localstore = "^0.5.1"
|
nonebot-plugin-localstore = "^0.6.0"
|
||||||
httpx = "^0.25.0"
|
httpx = "^0.27.0"
|
||||||
nonebot-plugin-alconna = ">=0.30,<0.34"
|
nonebot-plugin-alconna = ">=0.40"
|
||||||
nonebot-plugin-apscheduler = "^0.3.0"
|
nonebot-plugin-apscheduler = "^0.4.0"
|
||||||
|
aiocache = "^0.12.2"
|
||||||
|
zstandard = "^0.22.0"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
mypy = '>=0.991,<1.8'
|
mypy = '>=1.9'
|
||||||
types-ujson = '^5.7.0'
|
types-ujson = '^5.9.0'
|
||||||
pandas-stubs = '>=1.5.2,<3.0.0'
|
pandas-stubs = '>=1.5.2,<3.0.0'
|
||||||
ruff = '>=0.0.239,<0.1.6'
|
ruff = '>=0.3.0'
|
||||||
types-aiofiles = "^23.2.0.0"
|
types-aiofiles = "^23.2.0.20240106"
|
||||||
nonebot2 = { extras = ["fastapi"], version = "^2.1.1" }
|
nonebot2 = { extras = ["fastapi"], version = "^2.2.0" }
|
||||||
types-lxml = "^2023.3.28"
|
types-lxml = "^2024.2.9"
|
||||||
nonebot-plugin-orm = { extras = ["default"], version = ">=0.3,<0.6" }
|
nonebot-plugin-orm = { extras = ["default"], version = ">=0.3,<0.8" }
|
||||||
nonebot-adapter-onebot = "^2.3.1"
|
nonebot-adapter-onebot = "^2.4.1"
|
||||||
nonebot-adapter-satori = "^0.7.0"
|
nonebot-adapter-satori = "^0.11.3"
|
||||||
|
nonebot-adapter-kaiheila = "^0.3.4"
|
||||||
|
nonebot-adapter-discord = "^0.1.3"
|
||||||
|
|
||||||
[tool.poetry.group.debug.dependencies]
|
[tool.poetry.group.debug.dependencies]
|
||||||
objprint = '^0.2.2'
|
objprint = '^0.2.2'
|
||||||
viztracer = "^0.16.0"
|
viztracer = "^0.16.2"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ['poetry-core>=1.0.0']
|
requires = ['poetry-core>=1.0.0']
|
||||||
|
|||||||
Reference in New Issue
Block a user