Bladeren bron

feat: mineru_web (#555)

linfeng 1 jaar geleden
bovenliggende
commit
f07c267355
31 gewijzigde bestanden met toevoegingen van 2115 en 0 verwijderingen
  1. 2 0
      projects/README.md
  2. 27 0
      projects/web_api/README.md
  3. 12 0
      projects/web_api/mineru-web接口文档.html
  4. 687 0
      projects/web_api/poetry.lock
  5. 24 0
      projects/web_api/pyproject.toml
  6. 0 0
      projects/web_api/tests/__init__.py
  7. 1 0
      projects/web_api/web_api/__init__.py
  8. 36 0
      projects/web_api/web_api/api/__init__.py
  9. 18 0
      projects/web_api/web_api/api/analysis/__init__.py
  10. 231 0
      projects/web_api/web_api/api/analysis/analysis_view.py
  11. 25 0
      projects/web_api/web_api/api/analysis/ext.py
  12. 280 0
      projects/web_api/web_api/api/analysis/formula_ext.py
  13. 46 0
      projects/web_api/web_api/api/analysis/img_md_view.py
  14. 29 0
      projects/web_api/web_api/api/analysis/models.py
  15. 162 0
      projects/web_api/web_api/api/analysis/pdf_ext.py
  16. 28 0
      projects/web_api/web_api/api/analysis/serialization.py
  17. 95 0
      projects/web_api/web_api/api/analysis/task_view.py
  18. 89 0
      projects/web_api/web_api/api/analysis/upload_view.py
  19. 61 0
      projects/web_api/web_api/api/extentions.py
  20. 54 0
      projects/web_api/web_api/app.py
  21. 0 0
      projects/web_api/web_api/common/__init__.py
  22. 23 0
      projects/web_api/web_api/common/custom_response.py
  23. 45 0
      projects/web_api/web_api/common/error_types.py
  24. 80 0
      projects/web_api/web_api/common/ext.py
  25. 1 0
      projects/web_api/web_api/common/import_models.py
  26. 19 0
      projects/web_api/web_api/common/logger.py
  27. 9 0
      projects/web_api/web_api/common/web_hook.py
  28. 0 0
      projects/web_api/web_api/config/__init__.py
  29. 31 0
      projects/web_api/web_api/config/config.yaml
  30. BIN
      projects/web_api/web_api/config/mineru_web.db
  31. 0 0
      projects/web_api/web_api/static/__init__.py

+ 2 - 0
projects/README.md

@@ -3,3 +3,5 @@
 ## 项目列表
 
 - [llama_index_rag](./llama_index_rag/README.md): 基于 llama_index 构建轻量级 RAG 系统
+
+- [web_api](./web_api/README.md): PDF解析的restful api服务

+ 27 - 0
projects/web_api/README.md

@@ -0,0 +1,27 @@
+## 安装
+
+MinerU
+
+```bash
+# mineru已安装则跳过此步骤
+
+git clone https://github.com/opendatalab/MinerU.git
+cd MinerU
+
+conda create -n MinerU python=3.10
+conda activate MinerU
+pip install .[full] --extra-index-url https://wheels.myhloli.com
+```
+
+第三方软件
+
+```bash
+cd projects/web_api
+pip install poetry
+portey install
+```
+
+接口文档
+```
+在浏览器打开 mineru-web接口文档.html
+```

File diff suppressed because it is too large
+ 12 - 0
projects/web_api/mineru-web接口文档.html


+ 687 - 0
projects/web_api/poetry.lock

@@ -0,0 +1,687 @@
+# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+
+[[package]]
+name = "alembic"
+version = "1.13.2"
+description = "A database migration tool for SQLAlchemy."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "alembic-1.13.2-py3-none-any.whl", hash = "sha256:6b8733129a6224a9a711e17c99b08462dbf7cc9670ba8f2e2ae9af860ceb1953"},
+    {file = "alembic-1.13.2.tar.gz", hash = "sha256:1ff0ae32975f4fd96028c39ed9bb3c867fe3af956bd7bb37343b54c9fe7445ef"},
+]
+
+[package.dependencies]
+Mako = "*"
+SQLAlchemy = ">=1.3.0"
+typing-extensions = ">=4"
+
+[package.extras]
+tz = ["backports.zoneinfo"]
+
+[[package]]
+name = "aniso8601"
+version = "9.0.1"
+description = "A library for parsing ISO 8601 strings."
+optional = false
+python-versions = "*"
+files = [
+    {file = "aniso8601-9.0.1-py2.py3-none-any.whl", hash = "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f"},
+    {file = "aniso8601-9.0.1.tar.gz", hash = "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973"},
+]
+
+[package.extras]
+dev = ["black", "coverage", "isort", "pre-commit", "pyenchant", "pylint"]
+
+[[package]]
+name = "blinker"
+version = "1.8.2"
+description = "Fast, simple object-to-object and broadcast signaling"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "blinker-1.8.2-py3-none-any.whl", hash = "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01"},
+    {file = "blinker-1.8.2.tar.gz", hash = "sha256:8f77b09d3bf7c795e969e9486f39c2c5e9c39d4ee07424be2bc594ece9642d83"},
+]
+
+[[package]]
+name = "click"
+version = "8.1.7"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
+    {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "flask"
+version = "3.0.3"
+description = "A simple framework for building complex web applications."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3"},
+    {file = "flask-3.0.3.tar.gz", hash = "sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842"},
+]
+
+[package.dependencies]
+blinker = ">=1.6.2"
+click = ">=8.1.3"
+itsdangerous = ">=2.1.2"
+Jinja2 = ">=3.1.2"
+Werkzeug = ">=3.0.0"
+
+[package.extras]
+async = ["asgiref (>=3.2)"]
+dotenv = ["python-dotenv"]
+
+[[package]]
+name = "flask-cors"
+version = "5.0.0"
+description = "A Flask extension adding a decorator for CORS support"
+optional = false
+python-versions = "*"
+files = [
+    {file = "Flask_Cors-5.0.0-py2.py3-none-any.whl", hash = "sha256:b9e307d082a9261c100d8fb0ba909eec6a228ed1b60a8315fd85f783d61910bc"},
+    {file = "flask_cors-5.0.0.tar.gz", hash = "sha256:5aadb4b950c4e93745034594d9f3ea6591f734bb3662e16e255ffbf5e89c88ef"},
+]
+
+[package.dependencies]
+Flask = ">=0.9"
+
+[[package]]
+name = "flask-jwt-extended"
+version = "4.6.0"
+description = "Extended JWT integration with Flask"
+optional = false
+python-versions = ">=3.7,<4"
+files = [
+    {file = "Flask-JWT-Extended-4.6.0.tar.gz", hash = "sha256:9215d05a9413d3855764bcd67035e75819d23af2fafb6b55197eb5a3313fdfb2"},
+    {file = "Flask_JWT_Extended-4.6.0-py2.py3-none-any.whl", hash = "sha256:63a28fc9731bcc6c4b8815b6f954b5904caa534fc2ae9b93b1d3ef12930dca95"},
+]
+
+[package.dependencies]
+Flask = ">=2.0,<4.0"
+PyJWT = ">=2.0,<3.0"
+Werkzeug = ">=0.14"
+
+[package.extras]
+asymmetric-crypto = ["cryptography (>=3.3.1)"]
+
+[[package]]
+name = "flask-marshmallow"
+version = "1.2.1"
+description = "Flask + marshmallow for beautiful APIs"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "flask_marshmallow-1.2.1-py3-none-any.whl", hash = "sha256:10b5048ecfaa26f7c8d0aed7d81083164450e6be8e81c04b3d4a586b3f7b6678"},
+    {file = "flask_marshmallow-1.2.1.tar.gz", hash = "sha256:00ee96399ed664963afff3b5d6ee518640b0f91dbc2aace2b5abcf32f40ef23a"},
+]
+
+[package.dependencies]
+Flask = ">=2.2"
+marshmallow = ">=3.0.0"
+
+[package.extras]
+dev = ["flask-marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"]
+docs = ["Sphinx (==7.2.6)", "marshmallow-sqlalchemy (>=0.19.0)", "sphinx-issues (==4.0.0)"]
+sqlalchemy = ["flask-sqlalchemy (>=3.0.0)", "marshmallow-sqlalchemy (>=0.29.0)"]
+tests = ["flask-marshmallow[sqlalchemy]", "pytest"]
+
+[[package]]
+name = "flask-migrate"
+version = "4.0.7"
+description = "SQLAlchemy database migrations for Flask applications using Alembic."
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "Flask-Migrate-4.0.7.tar.gz", hash = "sha256:dff7dd25113c210b069af280ea713b883f3840c1e3455274745d7355778c8622"},
+    {file = "Flask_Migrate-4.0.7-py3-none-any.whl", hash = "sha256:5c532be17e7b43a223b7500d620edae33795df27c75811ddf32560f7d48ec617"},
+]
+
+[package.dependencies]
+alembic = ">=1.9.0"
+Flask = ">=0.9"
+Flask-SQLAlchemy = ">=1.0"
+
+[[package]]
+name = "flask-restful"
+version = "0.3.10"
+description = "Simple framework for creating REST APIs"
+optional = false
+python-versions = "*"
+files = [
+    {file = "Flask-RESTful-0.3.10.tar.gz", hash = "sha256:fe4af2ef0027df8f9b4f797aba20c5566801b6ade995ac63b588abf1a59cec37"},
+    {file = "Flask_RESTful-0.3.10-py2.py3-none-any.whl", hash = "sha256:1cf93c535172f112e080b0d4503a8d15f93a48c88bdd36dd87269bdaf405051b"},
+]
+
+[package.dependencies]
+aniso8601 = ">=0.82"
+Flask = ">=0.8"
+pytz = "*"
+six = ">=1.3.0"
+
+[package.extras]
+docs = ["sphinx"]
+
+[[package]]
+name = "flask-sqlalchemy"
+version = "3.1.1"
+description = "Add SQLAlchemy support to your Flask application."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0"},
+    {file = "flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312"},
+]
+
+[package.dependencies]
+flask = ">=2.2.5"
+sqlalchemy = ">=2.0.16"
+
+[[package]]
+name = "greenlet"
+version = "3.0.3"
+description = "Lightweight in-process concurrent programming"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"},
+    {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"},
+    {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"},
+    {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"},
+    {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"},
+    {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"},
+    {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"},
+    {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"},
+    {file = "greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"},
+    {file = "greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"},
+    {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"},
+    {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"},
+    {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"},
+    {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"},
+    {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"},
+    {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"},
+    {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"},
+    {file = "greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"},
+    {file = "greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"},
+    {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"},
+    {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"},
+    {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"},
+    {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"},
+    {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"},
+    {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"},
+    {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"},
+    {file = "greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"},
+    {file = "greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"},
+    {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"},
+    {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"},
+    {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"},
+    {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"},
+    {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"},
+    {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"},
+    {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"},
+    {file = "greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"},
+    {file = "greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"},
+    {file = "greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"},
+    {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"},
+    {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"},
+    {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"},
+    {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"},
+    {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"},
+    {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"},
+    {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"},
+    {file = "greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"},
+    {file = "greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"},
+    {file = "greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"},
+    {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"},
+    {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"},
+    {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"},
+    {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"},
+    {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"},
+    {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"},
+    {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"},
+    {file = "greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"},
+    {file = "greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"},
+    {file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"},
+]
+
+[package.extras]
+docs = ["Sphinx", "furo"]
+test = ["objgraph", "psutil"]
+
+[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+description = "Safely pass data to untrusted environments and back."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"},
+    {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"},
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.4"
+description = "A very fast and expressive template engine."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"},
+    {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "loguru"
+version = "0.7.2"
+description = "Python logging made (stupidly) simple"
+optional = false
+python-versions = ">=3.5"
+files = [
+    {file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"},
+    {file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
+win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
+
+[package.extras]
+dev = ["Sphinx (==7.2.5)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.4.1)", "mypy (==v1.5.1)", "pre-commit (==3.4.0)", "pytest (==6.1.2)", "pytest (==7.4.0)", "pytest-cov (==2.12.1)", "pytest-cov (==4.1.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.0.0)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.3.0)", "tox (==3.27.1)", "tox (==4.11.0)"]
+
+[[package]]
+name = "mako"
+version = "1.3.5"
+description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "Mako-1.3.5-py3-none-any.whl", hash = "sha256:260f1dbc3a519453a9c856dedfe4beb4e50bd5a26d96386cb6c80856556bb91a"},
+    {file = "Mako-1.3.5.tar.gz", hash = "sha256:48dbc20568c1d276a2698b36d968fa76161bf127194907ea6fc594fa81f943bc"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=0.9.2"
+
+[package.extras]
+babel = ["Babel"]
+lingua = ["lingua"]
+testing = ["pytest"]
+
+[[package]]
+name = "markupsafe"
+version = "2.1.5"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"},
+    {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"},
+]
+
+[[package]]
+name = "marshmallow"
+version = "3.22.0"
+description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "marshmallow-3.22.0-py3-none-any.whl", hash = "sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9"},
+    {file = "marshmallow-3.22.0.tar.gz", hash = "sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e"},
+]
+
+[package.dependencies]
+packaging = ">=17.0"
+
+[package.extras]
+dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"]
+docs = ["alabaster (==1.0.0)", "autodocsumm (==0.2.13)", "sphinx (==8.0.2)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"]
+tests = ["pytest", "pytz", "simplejson"]
+
+[[package]]
+name = "marshmallow-sqlalchemy"
+version = "1.1.0"
+description = "SQLAlchemy integration with the marshmallow (de)serialization library"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "marshmallow_sqlalchemy-1.1.0-py3-none-any.whl", hash = "sha256:cce261148e4c6ec4ee275f3d29352933380a1afa2fd3933f5e9ecd02fdc16ade"},
+    {file = "marshmallow_sqlalchemy-1.1.0.tar.gz", hash = "sha256:2ab092da269dafa8a05d51a58409af71a8d2183958ba47143127dd239e0359d8"},
+]
+
+[package.dependencies]
+marshmallow = ">=3.18.0"
+SQLAlchemy = ">=1.4.40,<3.0"
+
+[package.extras]
+dev = ["marshmallow-sqlalchemy[tests]", "pre-commit (>=3.5,<4.0)", "tox"]
+docs = ["alabaster (==1.0.0)", "sphinx (==8.0.2)", "sphinx-issues (==4.1.0)"]
+tests = ["pytest (<9)", "pytest-lazy-fixtures"]
+
+[[package]]
+name = "packaging"
+version = "24.1"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
+    {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
+]
+
+[[package]]
+name = "pyjwt"
+version = "2.9.0"
+description = "JSON Web Token implementation in Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"},
+    {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"},
+]
+
+[package.extras]
+crypto = ["cryptography (>=3.4.0)"]
+dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
+docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
+tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
+
+[[package]]
+name = "pytz"
+version = "2024.1"
+description = "World timezone definitions, modern and historical"
+optional = false
+python-versions = "*"
+files = [
+    {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"},
+    {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"},
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.2"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
+    {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
+    {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"},
+    {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"},
+    {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"},
+    {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"},
+    {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"},
+    {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"},
+    {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"},
+    {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"},
+    {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"},
+    {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"},
+    {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"},
+    {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"},
+    {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"},
+    {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"},
+    {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"},
+    {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"},
+    {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"},
+    {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"},
+    {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"},
+    {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"},
+    {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"},
+    {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"},
+    {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"},
+    {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"},
+    {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"},
+    {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"},
+    {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"},
+    {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"},
+    {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"},
+    {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"},
+    {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"},
+    {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"},
+    {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"},
+    {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"},
+    {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"},
+    {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"},
+    {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"},
+    {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"},
+    {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"},
+    {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"},
+    {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"},
+    {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"},
+    {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"},
+    {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"},
+    {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"},
+    {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"},
+    {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"},
+    {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"},
+    {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"},
+    {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"},
+    {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
+]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+
+[[package]]
+name = "sqlalchemy"
+version = "2.0.32"
+description = "Database Abstraction Library"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "SQLAlchemy-2.0.32-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c9045ecc2e4db59bfc97b20516dfdf8e41d910ac6fb667ebd3a79ea54084619"},
+    {file = "SQLAlchemy-2.0.32-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1467940318e4a860afd546ef61fefb98a14d935cd6817ed07a228c7f7c62f389"},
+    {file = "SQLAlchemy-2.0.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5954463675cb15db8d4b521f3566a017c8789222b8316b1e6934c811018ee08b"},
+    {file = "SQLAlchemy-2.0.32-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167e7497035c303ae50651b351c28dc22a40bb98fbdb8468cdc971821b1ae533"},
+    {file = "SQLAlchemy-2.0.32-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b27dfb676ac02529fb6e343b3a482303f16e6bc3a4d868b73935b8792edb52d0"},
+    {file = "SQLAlchemy-2.0.32-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bf2360a5e0f7bd75fa80431bf8ebcfb920c9f885e7956c7efde89031695cafb8"},
+    {file = "SQLAlchemy-2.0.32-cp310-cp310-win32.whl", hash = "sha256:306fe44e754a91cd9d600a6b070c1f2fadbb4a1a257b8781ccf33c7067fd3e4d"},
+    {file = "SQLAlchemy-2.0.32-cp310-cp310-win_amd64.whl", hash = "sha256:99db65e6f3ab42e06c318f15c98f59a436f1c78179e6a6f40f529c8cc7100b22"},
+    {file = "SQLAlchemy-2.0.32-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:21b053be28a8a414f2ddd401f1be8361e41032d2ef5884b2f31d31cb723e559f"},
+    {file = "SQLAlchemy-2.0.32-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b178e875a7a25b5938b53b006598ee7645172fccafe1c291a706e93f48499ff5"},
+    {file = "SQLAlchemy-2.0.32-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723a40ee2cc7ea653645bd4cf024326dea2076673fc9d3d33f20f6c81db83e1d"},
+    {file = "SQLAlchemy-2.0.32-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:295ff8689544f7ee7e819529633d058bd458c1fd7f7e3eebd0f9268ebc56c2a0"},
+    {file = "SQLAlchemy-2.0.32-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49496b68cd190a147118af585173ee624114dfb2e0297558c460ad7495f9dfe2"},
+    {file = "SQLAlchemy-2.0.32-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:acd9b73c5c15f0ec5ce18128b1fe9157ddd0044abc373e6ecd5ba376a7e5d961"},
+    {file = "SQLAlchemy-2.0.32-cp311-cp311-win32.whl", hash = "sha256:9365a3da32dabd3e69e06b972b1ffb0c89668994c7e8e75ce21d3e5e69ddef28"},
+    {file = "SQLAlchemy-2.0.32-cp311-cp311-win_amd64.whl", hash = "sha256:8bd63d051f4f313b102a2af1cbc8b80f061bf78f3d5bd0843ff70b5859e27924"},
+    {file = "SQLAlchemy-2.0.32-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6bab3db192a0c35e3c9d1560eb8332463e29e5507dbd822e29a0a3c48c0a8d92"},
+    {file = "SQLAlchemy-2.0.32-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:19d98f4f58b13900d8dec4ed09dd09ef292208ee44cc9c2fe01c1f0a2fe440e9"},
+    {file = "SQLAlchemy-2.0.32-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cd33c61513cb1b7371fd40cf221256456d26a56284e7d19d1f0b9f1eb7dd7e8"},
+    {file = "SQLAlchemy-2.0.32-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d6ba0497c1d066dd004e0f02a92426ca2df20fac08728d03f67f6960271feec"},
+    {file = "SQLAlchemy-2.0.32-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2b6be53e4fde0065524f1a0a7929b10e9280987b320716c1509478b712a7688c"},
+    {file = "SQLAlchemy-2.0.32-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:916a798f62f410c0b80b63683c8061f5ebe237b0f4ad778739304253353bc1cb"},
+    {file = "SQLAlchemy-2.0.32-cp312-cp312-win32.whl", hash = "sha256:31983018b74908ebc6c996a16ad3690301a23befb643093fcfe85efd292e384d"},
+    {file = "SQLAlchemy-2.0.32-cp312-cp312-win_amd64.whl", hash = "sha256:4363ed245a6231f2e2957cccdda3c776265a75851f4753c60f3004b90e69bfeb"},
+    {file = "SQLAlchemy-2.0.32-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b8afd5b26570bf41c35c0121801479958b4446751a3971fb9a480c1afd85558e"},
+    {file = "SQLAlchemy-2.0.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c750987fc876813f27b60d619b987b057eb4896b81117f73bb8d9918c14f1cad"},
+    {file = "SQLAlchemy-2.0.32-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ada0102afff4890f651ed91120c1120065663506b760da4e7823913ebd3258be"},
+    {file = "SQLAlchemy-2.0.32-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:78c03d0f8a5ab4f3034c0e8482cfcc415a3ec6193491cfa1c643ed707d476f16"},
+    {file = "SQLAlchemy-2.0.32-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:3bd1cae7519283ff525e64645ebd7a3e0283f3c038f461ecc1c7b040a0c932a1"},
+    {file = "SQLAlchemy-2.0.32-cp37-cp37m-win32.whl", hash = "sha256:01438ebcdc566d58c93af0171c74ec28efe6a29184b773e378a385e6215389da"},
+    {file = "SQLAlchemy-2.0.32-cp37-cp37m-win_amd64.whl", hash = "sha256:4979dc80fbbc9d2ef569e71e0896990bc94df2b9fdbd878290bd129b65ab579c"},
+    {file = "SQLAlchemy-2.0.32-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c742be912f57586ac43af38b3848f7688863a403dfb220193a882ea60e1ec3a"},
+    {file = "SQLAlchemy-2.0.32-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:62e23d0ac103bcf1c5555b6c88c114089587bc64d048fef5bbdb58dfd26f96da"},
+    {file = "SQLAlchemy-2.0.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:251f0d1108aab8ea7b9aadbd07fb47fb8e3a5838dde34aa95a3349876b5a1f1d"},
+    {file = "SQLAlchemy-2.0.32-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ef18a84e5116340e38eca3e7f9eeaaef62738891422e7c2a0b80feab165905f"},
+    {file = "SQLAlchemy-2.0.32-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3eb6a97a1d39976f360b10ff208c73afb6a4de86dd2a6212ddf65c4a6a2347d5"},
+    {file = "SQLAlchemy-2.0.32-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0c1c9b673d21477cec17ab10bc4decb1322843ba35b481585facd88203754fc5"},
+    {file = "SQLAlchemy-2.0.32-cp38-cp38-win32.whl", hash = "sha256:c41a2b9ca80ee555decc605bd3c4520cc6fef9abde8fd66b1cf65126a6922d65"},
+    {file = "SQLAlchemy-2.0.32-cp38-cp38-win_amd64.whl", hash = "sha256:8a37e4d265033c897892279e8adf505c8b6b4075f2b40d77afb31f7185cd6ecd"},
+    {file = "SQLAlchemy-2.0.32-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:52fec964fba2ef46476312a03ec8c425956b05c20220a1a03703537824b5e8e1"},
+    {file = "SQLAlchemy-2.0.32-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:328429aecaba2aee3d71e11f2477c14eec5990fb6d0e884107935f7fb6001632"},
+    {file = "SQLAlchemy-2.0.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85a01b5599e790e76ac3fe3aa2f26e1feba56270023d6afd5550ed63c68552b3"},
+    {file = "SQLAlchemy-2.0.32-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf04784797dcdf4c0aa952c8d234fa01974c4729db55c45732520ce12dd95b4"},
+    {file = "SQLAlchemy-2.0.32-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4488120becf9b71b3ac718f4138269a6be99a42fe023ec457896ba4f80749525"},
+    {file = "SQLAlchemy-2.0.32-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14e09e083a5796d513918a66f3d6aedbc131e39e80875afe81d98a03312889e6"},
+    {file = "SQLAlchemy-2.0.32-cp39-cp39-win32.whl", hash = "sha256:0d322cc9c9b2154ba7e82f7bf25ecc7c36fbe2d82e2933b3642fc095a52cfc78"},
+    {file = "SQLAlchemy-2.0.32-cp39-cp39-win_amd64.whl", hash = "sha256:7dd8583df2f98dea28b5cd53a1beac963f4f9d087888d75f22fcc93a07cf8d84"},
+    {file = "SQLAlchemy-2.0.32-py3-none-any.whl", hash = "sha256:e567a8793a692451f706b363ccf3c45e056b67d90ead58c3bc9471af5d212202"},
+    {file = "SQLAlchemy-2.0.32.tar.gz", hash = "sha256:c1b88cc8b02b6a5f0efb0345a03672d4c897dc7d92585176f88c67346f565ea8"},
+]
+
+[package.dependencies]
+greenlet = {version = "!=0.4.17", markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"}
+typing-extensions = ">=4.6.0"
+
+[package.extras]
+aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"]
+aioodbc = ["aioodbc", "greenlet (!=0.4.17)"]
+aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"]
+asyncio = ["greenlet (!=0.4.17)"]
+asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"]
+mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"]
+mssql = ["pyodbc"]
+mssql-pymssql = ["pymssql"]
+mssql-pyodbc = ["pyodbc"]
+mypy = ["mypy (>=0.910)"]
+mysql = ["mysqlclient (>=1.4.0)"]
+mysql-connector = ["mysql-connector-python"]
+oracle = ["cx_oracle (>=8)"]
+oracle-oracledb = ["oracledb (>=1.0.1)"]
+postgresql = ["psycopg2 (>=2.7)"]
+postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"]
+postgresql-pg8000 = ["pg8000 (>=1.29.1)"]
+postgresql-psycopg = ["psycopg (>=3.0.7)"]
+postgresql-psycopg2binary = ["psycopg2-binary"]
+postgresql-psycopg2cffi = ["psycopg2cffi"]
+postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
+pymysql = ["pymysql"]
+sqlcipher = ["sqlcipher3_binary"]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
+    {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+]
+
+[[package]]
+name = "werkzeug"
+version = "3.0.4"
+description = "The comprehensive WSGI web application library."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "werkzeug-3.0.4-py3-none-any.whl", hash = "sha256:02c9eb92b7d6c06f31a782811505d2157837cea66aaede3e217c7c27c039476c"},
+    {file = "werkzeug-3.0.4.tar.gz", hash = "sha256:34f2371506b250df4d4f84bfe7b0921e4762525762bbd936614909fe25cd7306"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.1.1"
+
+[package.extras]
+watchdog = ["watchdog (>=2.3)"]
+
+[[package]]
+name = "win32-setctime"
+version = "1.1.0"
+description = "A small Python utility to set file creation time on Windows"
+optional = false
+python-versions = ">=3.5"
+files = [
+    {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
+    {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},
+]
+
+[package.extras]
+dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.10"
+content-hash = "dda1a445d3e4ac500b0e776fae43a153624d8d62cb91ca0f2584837b622a42a7"

+ 24 - 0
projects/web_api/pyproject.toml

@@ -0,0 +1,24 @@
+[tool.poetry]
+name = "web-api"
+version = "0.1.0"
+description = ""
+authors = ["houlinfeng <m15237195947@163.com>"]
+readme = "README.md"
+
+[tool.poetry.dependencies]
+python = "^3.10"
+flask = "^3.0.3"
+flask-restful = "^0.3.10"
+flask-cors = "^5.0.0"
+flask-sqlalchemy = "^3.1.1"
+flask-migrate = "^4.0.7"
+flask-jwt-extended = "^4.6.0"
+flask-marshmallow = "^1.2.1"
+pyyaml = "^6.0.2"
+loguru = "^0.7.2"
+marshmallow-sqlalchemy = "^1.1.0"
+
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"

+ 0 - 0
projects/web_api/tests/__init__.py


+ 1 - 0
projects/web_api/web_api/__init__.py

@@ -0,0 +1 @@
+__all__ = ["common", "api"]

+ 36 - 0
projects/web_api/web_api/api/__init__.py

@@ -0,0 +1,36 @@
+import os
+from .extentions import app, db, migrate, jwt, ma
+from common.web_hook import before_request
+from common.logger import setup_log
+
+root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+print("root_dir", root_dir)
+
+def _register_db(flask_app):
+    from common import import_models
+    db.init_app(flask_app)
+    with app.app_context():
+        db.create_all()
+
+
+def create_app(config):
+    """
+    Create and configure an instance of the Flask application
+    :param config:
+    :return:
+    """
+    app.static_folder = os.path.join(root_dir, "static")
+    if config is None:
+        config = {}
+    app.config.update(config)
+    setup_log(config)
+    _register_db(app)
+    migrate.init_app(app=app, db=db)
+    jwt.init_app(app=app)
+    ma.init_app(app=app)
+    from .analysis import analysis_blue
+    app.register_blueprint(analysis_blue)
+
+    app.before_request(before_request)
+
+    return app

+ 18 - 0
projects/web_api/web_api/api/analysis/__init__.py

@@ -0,0 +1,18 @@
+from flask import Blueprint
+from ..extentions import Api
+from .upload_view import UploadPdfView
+from .analysis_view import AnalysisTaskView, AnalysisTaskProgressView
+from .img_md_view import ImgView, MdView
+from .task_view import TaskView, HistoricalTasksView, DeleteTaskView
+
+analysis_blue = Blueprint('analysis', __name__)
+
+api_v2 = Api(analysis_blue, prefix='/api/v2')
+api_v2.add_resource(UploadPdfView, '/analysis/upload_pdf')
+api_v2.add_resource(AnalysisTaskView, '/extract/task/submit')
+api_v2.add_resource(AnalysisTaskProgressView, '/extract/task/progress')
+api_v2.add_resource(ImgView, '/analysis/pdf_img')
+api_v2.add_resource(MdView, '/analysis/pdf_md')
+api_v2.add_resource(TaskView, '/extract/taskQueue')
+api_v2.add_resource(HistoricalTasksView, '/extract/list')
+api_v2.add_resource(DeleteTaskView, '/extract/task')

+ 231 - 0
projects/web_api/web_api/api/analysis/analysis_view.py

@@ -0,0 +1,231 @@
+import json
+import threading
+from pathlib import Path
+from flask import request, current_app, url_for
+from flask_restful import Resource
+from .ext import find_file, task_state_map
+# from .formula_ext import formula_detection, formula_recognition
+from .serialization import AnalysisViewSchema
+from marshmallow import ValidationError
+from ..extentions import db
+from .models import AnalysisTask, AnalysisPdf
+from .pdf_ext import analysis_pdf_task
+from common.custom_response import generate_response
+
+
+class AnalysisTaskProgressView(Resource):
+
+    def get(self):
+        """
+        获取任务进度
+        :return:
+        """
+        params = request.args
+        id = params.get('id')
+        analysis_task = AnalysisTask.query.filter(AnalysisTask.id == id).first()
+        if not analysis_task:
+            return generate_response(code=400, msg="Invalid ID", msgZH="无效id")
+        match analysis_task.task_type:
+            case 'pdf':
+                analysis_pdf = AnalysisPdf.query.filter(AnalysisPdf.id == analysis_task.analysis_pdf_id).first()
+                file_url = url_for('analysis.uploadpdfview', filename=analysis_task.file_name, as_attachment=False)
+                if analysis_task.status == 0:
+                    data = {
+                        "state": task_state_map.get(analysis_task.status),
+                        "status": analysis_pdf.status,
+                        "url": file_url,
+                        "fileName": analysis_task.file_name,
+                        "content": [],
+                        "markdownUrl": [],
+                        "fullMdLink": "",
+                        "type": analysis_task.task_type,
+                    }
+                    return generate_response(data=data)
+                elif analysis_task.status == 1:
+                    if analysis_pdf.status == 1:  # 任务正常完成
+                        bbox_info = json.loads(analysis_pdf.bbox_info)
+                        md_link_list = json.loads(analysis_pdf.md_link_list)
+                        full_md_link = analysis_pdf.full_md_link
+                        data = {
+                            "state": task_state_map.get(analysis_task.status),
+                            "status": analysis_pdf.status,
+                            "url": file_url,
+                            "fileName": analysis_task.file_name,
+                            "content": bbox_info,
+                            "markdownUrl": md_link_list,
+                            "fullMdLink": full_md_link,
+                            "type": analysis_task.task_type,
+                        }
+                        return generate_response(data=data)
+                    else:  # 任务异常结束
+                        data = {
+                            "state": task_state_map.get(analysis_task.status),
+                            "status": analysis_pdf.status,
+                            "url": file_url,
+                            "fileName": analysis_task.file_name,
+                            "content": [],
+                            "markdownUrl": [],
+                            "fullMdLink": "",
+                            "type": analysis_task.task_type,
+                        }
+                        return generate_response(code=-60004, data=data, msg="Failed to retrieve PDF parsing progress",
+                                                 msgZh="无法获取PDF解析进度")
+                else:
+                    data = {
+                        "state": task_state_map.get(analysis_task.status),
+                        "status": analysis_pdf.status,
+                        "url": file_url,
+                        "fileName": analysis_task.file_name,
+                        "content": [],
+                        "markdownUrl": [],
+                        "fullMdLink": "",
+                        "type": analysis_task.task_type,
+                    }
+                    return generate_response(data=data)
+            case 'formula-detect':
+                pass
+            case 'formula-extract':
+                pass
+            case 'table-recogn':
+                return generate_response(code=400, msg="Not yet supported", msgZH="尚不支持")
+            case _:
+                return generate_response()
+
+
+class AnalysisTaskView(Resource):
+
+    def post(self):
+        """
+        提交任务
+        :return:
+        """
+        analysis_view_schema = AnalysisViewSchema()
+        try:
+            params = analysis_view_schema.load(request.get_json())
+        except ValidationError as err:
+            return generate_response(code=400, msg=err.messages)
+        file_key = params.get("fileKey")
+        file_name = params.get("fileName")
+        task_type = params.get("taskType")
+        is_ocr = params.get("isOcr", False)
+
+        pdf_upload_folder = current_app.config['PDF_UPLOAD_FOLDER']
+        upload_dir = f"{current_app.static_folder}/{pdf_upload_folder}"
+        file_path = find_file(file_key, upload_dir)
+        match task_type:
+            case 'pdf':
+                if not file_path:
+                    return generate_response(code=400, msg="FileKey is invalid, no PDF file found",
+                                             msgZH="fileKey无效,未找到pdf文件")
+                analysis_task = AnalysisTask.query.filter(AnalysisTask.status.in_([0, 2])).first()
+                file_name = Path(file_path).name
+                with db.auto_commit():
+                    analysis_pdf_object = AnalysisPdf(
+                        file_name=file_name,
+                        file_path=file_path,
+                        status=3 if analysis_task else 0,
+                    )
+                    db.session.add(analysis_pdf_object)
+                    db.session.flush()
+                    analysis_pdf_id = analysis_pdf_object.id
+                with db.auto_commit():
+                    analysis_task_object = AnalysisTask(
+                        file_key=file_key,
+                        file_name=file_name,
+                        task_type=task_type,
+                        is_ocr=is_ocr,
+                        status=2 if analysis_task else 0,
+                        analysis_pdf_id=analysis_pdf_id
+                    )
+                    db.session.add(analysis_task_object)
+                    db.session.flush()
+                    analysis_task_id = analysis_task_object.id
+                if not analysis_task:  # 已有同类型任务在执行,请等待执行完成
+                    file_stem = Path(file_path).stem
+                    pdf_analysis_folder = current_app.config['PDF_ANALYSIS_FOLDER']
+                    pdf_dir = f"{current_app.static_folder}/{pdf_analysis_folder}/{file_stem}"
+                    image_dir = f"{pdf_dir}/images"
+                    t = threading.Thread(target=analysis_pdf_task,
+                                         args=(pdf_dir, image_dir, file_path, is_ocr, analysis_pdf_id))
+                    t.start()
+                # 生成文件的URL路径
+                file_url = url_for('analysis.uploadpdfview', filename=file_name, as_attachment=False)
+                data = {
+                    "url": file_url,
+                    "fileName": file_name,
+                    "id": analysis_task_id
+                }
+                return generate_response(data=data)
+            case 'formula-detect':
+                # if not file_path:
+                #     return generate_response(code=400, msg="FileKey is invalid, no image file found",
+                #                              msgZH="fileKey无效,未找到图片")
+                # return formula_detection(file_path, upload_dir)
+                return generate_response(code=400, msg="Not yet supported", msgZH="功能待开发")
+            case 'formula-extract':
+                # if not file_path:
+                #     return generate_response(code=400, msg="FileKey is invalid, no image file found",
+                #                              msgZH="fileKey无效,未找到图片")
+                # return formula_recognition(file_path, upload_dir)
+                return generate_response(code=400, msg="Not yet supported", msgZH="功能待开发")
+            case 'table-recogn':
+                return generate_response(code=400, msg="Not yet supported", msgZH="功能待开发")
+            case _:
+                return generate_response(code=400, msg="Not yet supported", msgZH="参数不支持")
+
+    def put(self):
+        """
+        重新发起任务
+        :return:
+        """
+        params = json.loads(request.data)
+        id = params.get('id')
+        analysis_task = AnalysisTask.query.filter(AnalysisTask.id == id).first()
+        match analysis_task.task_type:
+            case 'pdf':
+                task_r_p = AnalysisTask.query.filter(AnalysisTask.status.in_([0, 2])).first()
+                if task_r_p:
+                    with db.auto_commit():
+                        analysis_pdf_object = AnalysisPdf.query.filter_by(id=analysis_task.analysis_pdf_id).first()
+                        analysis_pdf_object.status = 3
+                        db.session.add(analysis_pdf_object)
+                    with db.auto_commit():
+                        analysis_task.status = 2
+                        db.session.add(analysis_task)
+                else:
+                    with db.auto_commit():
+                        analysis_pdf_object = AnalysisPdf.query.filter_by(id=analysis_task.analysis_pdf_id).first()
+                        analysis_pdf_object.status = 0
+                        db.session.add(analysis_pdf_object)
+                    with db.auto_commit():
+                        analysis_task.status = 0
+                        db.session.add(analysis_task)
+
+                    pdf_upload_folder = current_app.config['PDF_UPLOAD_FOLDER']
+                    upload_dir = f"{current_app.static_folder}/{pdf_upload_folder}"
+                    file_path = find_file(analysis_task.file_key, upload_dir)
+                    file_stem = Path(file_path).stem
+                    pdf_analysis_folder = current_app.config['PDF_ANALYSIS_FOLDER']
+                    pdf_dir = f"{current_app.static_folder}/{pdf_analysis_folder}/{file_stem}"
+                    image_dir = f"{pdf_dir}/images"
+                    t = threading.Thread(target=analysis_pdf_task,
+                                         args=(pdf_dir, image_dir, file_path, analysis_task.is_ocr,
+                                               analysis_task.analysis_pdf_id))
+                    t.start()
+
+                # 生成文件的URL路径
+                file_url = url_for('analysis.uploadpdfview', filename=analysis_task.file_name, as_attachment=False)
+                data = {
+                    "url": file_url,
+                    "fileName": analysis_task.file_name,
+                    "id": analysis_task.id
+                }
+                return generate_response(data=data)
+            case 'formula-detect':
+                return generate_response(code=400, msg="Not yet supported", msgZH="功能待开发")
+            case 'formula-extract':
+                return generate_response(code=400, msg="Not yet supported", msgZH="功能待开发")
+            case 'table-recogn':
+                return generate_response(code=400, msg="Not yet supported", msgZH="功能待开发")
+            case _:
+                return generate_response(code=400, msg="Not yet supported", msgZH="参数不支持")

+ 25 - 0
projects/web_api/web_api/api/analysis/ext.py

@@ -0,0 +1,25 @@
+import os
+
+task_state_map = {
+    0: "running",
+    1: "finished",
+    2: "pending",
+}
+
+
+def find_file(file_key, file_dir):
+    """
+    查询文件
+    :param file_key:  文件哈希
+    :param file_dir:  文件目录
+    :return:
+    """
+    pdf_path = ""
+    for root, subDirs, files in os.walk(file_dir):
+        for fileName in files:
+            if fileName.startswith(file_key):
+                pdf_path = os.path.join(root, fileName)
+                break
+        if pdf_path:
+            break
+    return pdf_path

+ 280 - 0
projects/web_api/web_api/api/analysis/formula_ext.py

@@ -0,0 +1,280 @@
+import os
+import pkgutil
+import numpy as np
+import yaml
+import argparse
+import cv2
+from pathlib import Path
+from ultralytics import YOLO
+from unimernet.common.config import Config
+import unimernet.tasks as tasks
+from unimernet.processors import load_processor
+from magic_pdf.libs.config_reader import get_local_models_dir, get_device
+from torchvision import transforms
+from magic_pdf.pre_proc.ocr_span_list_modify import remove_overlaps_low_confidence_spans, remove_overlaps_min_spans
+from PIL import Image
+from common.ext import singleton_func
+from common.custom_response import generate_response
+
+
+def mfd_model_init(weight):
+    mfd_model = YOLO(weight)
+    return mfd_model
+
+
+def mfr_model_init(weight_dir, cfg_path, _device_='cpu'):
+    args = argparse.Namespace(cfg_path=cfg_path, options=None)
+    cfg = Config(args)
+    cfg.config.model.pretrained = os.path.join(weight_dir, "pytorch_model.bin")
+    cfg.config.model.model_config.model_name = weight_dir
+    cfg.config.model.tokenizer_config.path = weight_dir
+    task = tasks.setup_task(cfg)
+    model = task.build_model(cfg)
+    model = model.to(_device_)
+    vis_processor = load_processor('formula_image_eval', cfg.config.datasets.formula_rec_eval.vis_processor.eval)
+    return model, vis_processor
+
+
+@singleton_func
+class CustomPEKModel:
+    def __init__(self):
+        # PDF-Extract-Kit/models
+        models_dir = get_local_models_dir()
+        self.device = get_device()
+        loader = pkgutil.get_loader("magic_pdf")
+        root_dir = Path(loader.path).parent
+        # model_config目录
+        model_config_dir = os.path.join(root_dir, 'resources', 'model_config')
+        # 构建 model_configs.yaml 文件的完整路径
+        config_path = os.path.join(model_config_dir, 'model_configs.yaml')
+        with open(config_path, "r", encoding='utf-8') as f:
+            configs = yaml.load(f, Loader=yaml.FullLoader)
+
+        # 初始化公式检测模型
+        self.mfd_model = mfd_model_init(str(os.path.join(models_dir, configs["weights"]["mfd"])))
+
+        # 初始化公式解析模型
+        mfr_weight_dir = str(os.path.join(models_dir, configs["weights"]["mfr"]))
+        mfr_cfg_path = str(os.path.join(model_config_dir, "UniMERNet", "demo.yaml"))
+        self.mfr_model, mfr_vis_processors = mfr_model_init(mfr_weight_dir, mfr_cfg_path, _device_=self.device)
+        self.mfr_transform = transforms.Compose([mfr_vis_processors, ])
+
+
+def get_all_spans(layout_dets) -> list:
+    def remove_duplicate_spans(spans):
+        new_spans = []
+        for span in spans:
+            if not any(span == existing_span for existing_span in new_spans):
+                new_spans.append(span)
+        return new_spans
+
+    all_spans = []
+    # allow_category_id_list = [3, 5, 13, 14, 15]
+    """当成span拼接的"""
+    #  3: 'image', # 图片
+    #  5: 'table',       # 表格
+    #  13: 'inline_equation',     # 行内公式
+    #  14: 'interline_equation',      # 行间公式
+    #  15: 'text',      # ocr识别文本
+    for layout_det in layout_dets:
+        if layout_det.get("bbox") is not None:
+            # 兼容直接输出bbox的模型数据,如paddle
+            x0, y0, x1, y1 = layout_det["bbox"]
+        else:
+            # 兼容直接输出poly的模型数据,如xxx
+            x0, y0, _, _, x1, y1, _, _ = layout_det["poly"]
+        bbox = [x0, y0, x1, y1]
+        layout_det["bbox"] = bbox
+        all_spans.append(layout_det)
+    return remove_duplicate_spans(all_spans)
+
+
+def formula_predict(mfd_model, image):
+    """
+    公式检测
+    :param mfd_model:
+    :param image:
+    :return:
+    """
+    latex_filling_list = []
+    # 公式检测
+    mfd_res = mfd_model.predict(image, imgsz=1888, conf=0.25, iou=0.45, verbose=True)[0]
+    for xyxy, conf, cla in zip(mfd_res.boxes.xyxy.cpu(), mfd_res.boxes.conf.cpu(), mfd_res.boxes.cls.cpu()):
+        xmin, ymin, xmax, ymax = [int(p.item()) for p in xyxy]
+        new_item = {
+            'category_id': 13 + int(cla.item()),
+            'poly': [xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax],
+            'score': round(float(conf.item()), 2),
+            'latex': '',
+        }
+        latex_filling_list.append(new_item)
+    return latex_filling_list
+
+
+def formula_detection(file_path, upload_dir):
+    """
+    公式检测
+    :param file_path:  文件路径
+    :param upload_dir:  上传文件夹
+    :return:
+    """
+    try:
+        image_open = Image.open(file_path)
+    except IOError:
+        return generate_response(code=400, msg="params is not valid", msgZh="参数类型不是图片,无效参数")
+
+    filename = Path(file_path).name
+
+    # 获取图片宽高
+    width, height = image_open.size
+    # 转换为RGB,忽略透明度通道
+    rgb_image = image_open.convert('RGB')
+    # 保存转换后的图片
+    rgb_image.save(file_path)
+
+    # 初始化模型
+    cpm = CustomPEKModel()
+    # 初始化公式检测模型
+    mfd_model = cpm.mfd_model
+
+    image_conv = Image.open(file_path)
+    image_array = np.array(image_conv)
+    pdf_width = 1416
+    pdf_height = 1888
+
+    # 重置图片大小
+    scale = min(pdf_width // 2 / width, pdf_height // 2 / height)  # 缩放比例
+    nw = int(width * scale)
+    nh = int(height * scale)
+    image_resize = cv2.resize(image_array, (nw, nh), interpolation=cv2.INTER_LINEAR)
+    resize_image_path = f"{upload_dir}/resize_{filename}"
+    cv2.imwrite(resize_image_path, image_resize)
+    # 将重置的图片贴到pdf白纸中
+    x = (pdf_width - nw) // 2
+    y = (pdf_height - nh) // 2
+    new_img = Image.new('RGB', (pdf_width, pdf_height), 'white')
+    image_scale = Image.open(resize_image_path)
+    new_img.paste(image_scale, (x, y))
+
+    # 公式检测
+    latex_filling_list = formula_predict(mfd_model, new_img)
+
+    os.remove(resize_image_path)
+
+    # 将缩放图公式检测的坐标还原为原图公式检测的坐标
+    for item in latex_filling_list:
+        item_poly = item["poly"]
+        item["poly"] = [
+            (item_poly[0] - x) / scale,
+            (item_poly[1] - y) / scale,
+            (item_poly[2] - x) / scale,
+            (item_poly[3] - y) / scale,
+            (item_poly[4] - x) / scale,
+            (item_poly[5] - y) / scale,
+            (item_poly[6] - x) / scale,
+            (item_poly[7] - y) / scale,
+        ]
+
+    if not latex_filling_list:
+        return generate_response(code=1001, msg="detection fail", msgZh="公式检测失败,图片过小,无法检测")
+
+    spans = get_all_spans(latex_filling_list)
+    '''删除重叠spans中置信度较低的那些'''
+    spans, dropped_spans_by_confidence = remove_overlaps_low_confidence_spans(spans)
+    '''删除重叠spans中较小的那些'''
+    spans, dropped_spans_by_span_overlap = remove_overlaps_min_spans(spans)
+
+    return generate_response(data={
+        'layout': spans,
+    })
+
+
+def formula_recognition(file_path, upload_dir):
+    """
+    公式识别
+    :param file_path:  文件路径
+    :param upload_dir:  上传文件夹
+    :return:
+    """
+    try:
+        image_open = Image.open(file_path)
+    except IOError:
+        return generate_response(code=400, msg="params is not valid", msgZh="参数类型不是图片,无效参数")
+
+    filename = Path(file_path).name
+
+    # 获取图片宽高
+    width, height = image_open.size
+    # 转换为RGB,忽略透明度通道
+    rgb_image = image_open.convert('RGB')
+    # 保存转换后的图片
+    rgb_image.save(file_path)
+
+    image_conv = Image.open(file_path)
+    image_array = np.array(image_conv)
+    pdf_width = 1416
+    pdf_height = 1888
+
+    # 重置图片大小
+    scale = min(pdf_width // 2 / width, pdf_height // 2 / height)  # 缩放比例
+    nw = int(width * scale)
+    nh = int(height * scale)
+    image_resize = cv2.resize(image_array, (nw, nh), interpolation=cv2.INTER_LINEAR)
+    resize_image_path = f"{upload_dir}/resize_{filename}"
+    cv2.imwrite(resize_image_path, image_resize)
+    # 将重置的图片贴到pdf白纸中
+    x = (pdf_width - nw) // 2
+    y = (pdf_height - nh) // 2
+    new_img = Image.new('RGB', (pdf_width, pdf_height), 'white')
+    image_scale = Image.open(resize_image_path)
+    new_img.paste(image_scale, (x, y))
+    new_img_array = np.array(new_img)
+
+    # 初始化模型
+    cpm = CustomPEKModel()
+    # device
+    device = cpm.device
+    # 初始化公式检测模型
+    mfd_model = cpm.mfd_model
+    # 初始化公式解析模型
+    mfr_model = cpm.mfr_model
+    mfr_transform = cpm.mfr_transform
+    # 公式识别
+    latex_filling_list, mfr_res = formula_recognition(mfd_model, new_img_array, mfr_transform, device, mfr_model,
+                                                      image_open)
+
+    os.remove(resize_image_path)
+
+    # 将缩放图公式检测的坐标还原为原图公式检测的坐标
+    for item in latex_filling_list:
+        item_poly = item["poly"]
+        item["poly"] = [
+            (item_poly[0] - x) / scale,
+            (item_poly[1] - y) / scale,
+            (item_poly[2] - x) / scale,
+            (item_poly[3] - y) / scale,
+            (item_poly[4] - x) / scale,
+            (item_poly[5] - y) / scale,
+            (item_poly[6] - x) / scale,
+            (item_poly[7] - y) / scale,
+        ]
+
+    spans = get_all_spans(latex_filling_list)
+    '''删除重叠spans中置信度较低的那些'''
+    spans, dropped_spans_by_confidence = remove_overlaps_low_confidence_spans(spans)
+    '''删除重叠spans中较小的那些'''
+    spans, dropped_spans_by_span_overlap = remove_overlaps_min_spans(spans)
+
+    if not latex_filling_list:
+        width, height = image_open.size
+        latex_filling_list.append({
+            'category_id': 14,
+            'poly': [0, 0, width, 0, width, height, 0, height],
+            'score': 1,
+            'latex': mfr_res[0] if mfr_res else "",
+        })
+
+    return generate_response(data={
+        'layout': spans if spans else latex_filling_list,
+        "mfr_res": mfr_res
+    })

+ 46 - 0
projects/web_api/web_api/api/analysis/img_md_view.py

@@ -0,0 +1,46 @@
+from pathlib import Path
+from flask import request, current_app, send_from_directory
+from flask_restful import Resource
+
+
+class ImgView(Resource):
+    def get(self):
+        """
+        获取pdf解析的图片
+        :return:
+        """
+        params = request.args
+        pdf = params.get('pdf')
+        filename = params.get('filename')
+        as_attachment = params.get('as_attachment')
+        if str(as_attachment).lower() == "true":
+            as_attachment = True
+        else:
+            as_attachment = False
+        file_stem = Path(pdf).stem
+        pdf_analysis_folder = current_app.config['PDF_ANALYSIS_FOLDER']
+        pdf_dir = f"{current_app.static_folder}/{pdf_analysis_folder}/{file_stem}"
+        image_dir = f"{pdf_dir}/images"
+        response = send_from_directory(image_dir, filename, as_attachment=as_attachment)
+        return response
+
+
+class MdView(Resource):
+    def get(self):
+        """
+        获取pdf解析的markdown
+        :return:
+        """
+        params = request.args
+        pdf = params.get('pdf')
+        filename = params.get('filename')
+        as_attachment = params.get('as_attachment')
+        if str(as_attachment).lower() == "true":
+            as_attachment = True
+        else:
+            as_attachment = False
+        file_stem = Path(pdf).stem
+        pdf_analysis_folder = current_app.config['PDF_ANALYSIS_FOLDER']
+        pdf_dir = f"{current_app.static_folder}/{pdf_analysis_folder}/{file_stem}"
+        response = send_from_directory(pdf_dir, filename, as_attachment=as_attachment)
+        return response

+ 29 - 0
projects/web_api/web_api/api/analysis/models.py

@@ -0,0 +1,29 @@
+from datetime import datetime
+from ..extentions import db
+
+
+class AnalysisTask(db.Model):
+    __tablename__ = 'analysis_task'
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    file_key = db.Column(db.Text, comment="文件唯一哈希")
+    file_name = db.Column(db.Text, comment="文件名称")
+    task_type = db.Column(db.String(128), comment="任务类型")
+    is_ocr = db.Column(db.Boolean, default=False, comment="是否ocr")
+    status = db.Column(db.Integer, default=0, comment="状态")  # 0 running  1 finished  2 pending
+    analysis_pdf_id = db.Column(db.Integer, comment="analysis_pdf的id")
+    create_date = db.Column(db.DateTime(), nullable=False, default=datetime.now)
+    update_date = db.Column(db.DateTime(), nullable=False, default=datetime.now, onupdate=datetime.now)
+
+
+class AnalysisPdf(db.Model):
+    __tablename__ = 'analysis_pdf'
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    file_name = db.Column(db.Text, comment="文件名称")
+    file_url = db.Column(db.Text, comment="文件原路径")
+    file_path = db.Column(db.Text, comment="文件路径")
+    status = db.Column(db.Integer, default=3, comment="状态")  # 0 转换中  1 已完成  2 转换失败 3 init
+    bbox_info = db.Column(db.Text, comment="坐标数据")
+    md_link_list = db.Column(db.Text, comment="markdown分页链接")
+    full_md_link = db.Column(db.Text, comment="markdown全文链接")
+    create_date = db.Column(db.DateTime(), nullable=False, default=datetime.now)
+    update_date = db.Column(db.DateTime(), nullable=False, default=datetime.now, onupdate=datetime.now)

+ 162 - 0
projects/web_api/web_api/api/analysis/pdf_ext.py

@@ -0,0 +1,162 @@
+import json
+import re
+import traceback
+from pathlib import Path
+from flask import current_app, url_for
+from magic_pdf.rw.DiskReaderWriter import DiskReaderWriter
+from magic_pdf.pipe.UNIPipe import UNIPipe
+import magic_pdf.model as model_config
+from magic_pdf.libs.json_compressor import JsonCompressor
+from magic_pdf.dict2md.ocr_mkcontent import ocr_mk_mm_markdown_with_para_and_pagination
+from .ext import find_file
+from ..extentions import app, db
+from .models import AnalysisPdf, AnalysisTask
+from common.error_types import ApiException
+from loguru import logger
+
+model_config.__use_inside_model__ = True
+
+
+def analysis_pdf(image_dir, pdf_bytes, is_ocr=False):
+    try:
+        model_json = []  # model_json传空list使用内置模型解析
+        logger.info(f"is_ocr: {is_ocr}")
+        if not is_ocr:
+            jso_useful_key = {"_pdf_type": "", "model_list": model_json}
+            image_writer = DiskReaderWriter(image_dir)
+            pipe = UNIPipe(pdf_bytes, jso_useful_key, image_writer, is_debug=True)
+            pipe.pipe_classify()
+        else:
+            jso_useful_key = {"_pdf_type": "ocr", "model_list": model_json}
+            image_writer = DiskReaderWriter(image_dir)
+            pipe = UNIPipe(pdf_bytes, jso_useful_key, image_writer, is_debug=True)
+        """如果没有传入有效的模型数据,则使用内置model解析"""
+        if len(model_json) == 0:
+            if model_config.__use_inside_model__:
+                pipe.pipe_analyze()
+            else:
+                logger.error("need model list input")
+                exit(1)
+        pipe.pipe_parse()
+        pdf_mid_data = JsonCompressor.decompress_json(pipe.get_compress_pdf_mid_data())
+        pdf_info_list = pdf_mid_data["pdf_info"]
+        md_content = json.dumps(ocr_mk_mm_markdown_with_para_and_pagination(pdf_info_list, image_dir),
+                                ensure_ascii=False)
+        bbox_info = get_bbox_info(pdf_info_list)
+        return md_content, bbox_info
+    except Exception as e:
+        logger.error(traceback.format_exc())
+
+
+def get_bbox_info(data):
+    bbox_info = []
+    for page in data:
+        preproc_blocks = page.get("preproc_blocks", [])
+        discarded_blocks = page.get("discarded_blocks", [])
+        bbox_info.append({
+            "preproc_blocks": preproc_blocks,
+            "page_idx": page.get("page_idx"),
+            "page_size": page.get("page_size"),
+            "discarded_blocks": discarded_blocks,
+        })
+    return bbox_info
+
+
+def analysis_pdf_task(pdf_dir, image_dir, pdf_path, is_ocr, analysis_pdf_id):
+    """
+    解析pdf
+    :param pdf_dir:  pdf解析目录
+    :param image_dir:  图片目录
+    :param pdf_path:  pdf路径
+    :param is_ocr:  是否启用ocr
+    :param analysis_pdf_id:  pdf解析表id
+    :return:
+    """
+    try:
+        logger.info(f"start task: {pdf_path}")
+        logger.info(f"image_dir: {image_dir}")
+        if not Path(image_dir).exists():
+            Path(image_dir).mkdir(parents=True, exist_ok=True)
+        with open(pdf_path, 'rb') as file:
+            pdf_bytes = file.read()
+        md_content, bbox_info = analysis_pdf(image_dir, pdf_bytes, is_ocr)
+        img_list = Path(image_dir).glob('*') if Path(image_dir).exists() else []
+
+        pdf_name = Path(pdf_path).name
+        with app.app_context():
+            for img in img_list:
+                img_name = Path(img).name
+                regex = re.compile(fr'.*\((.*?{img_name})')
+                regex_result = regex.search(md_content)
+                img_url = url_for('analysis.imgview', filename=img_name, as_attachment=False)
+                md_content = md_content.replace(regex_result.group(1), f"{img_url}&pdf={pdf_name}")
+
+        full_md_content = ""
+        for item in json.loads(md_content):
+            full_md_content += item["md_content"] + "\n"
+
+        full_md_name = "full.md"
+        with open(f"{pdf_dir}/{full_md_name}", "w") as file:
+            file.write(full_md_content)
+        with app.app_context():
+            full_md_link = url_for('analysis.mdview', filename=full_md_name, as_attachment=False)
+            full_md_link = f"{full_md_link}&pdf={pdf_name}"
+
+        md_link_list = []
+        with app.app_context():
+            for n, md in enumerate(json.loads(md_content)):
+                md_content = md["md_content"]
+                md_name = f"{md.get('page_no', n)}.md"
+                with open(f"{pdf_dir}/{md_name}", "w") as file:
+                    file.write(md_content)
+                md_url = url_for('analysis.mdview', filename=md_name, as_attachment=False)
+                md_link_list.append(f"{md_url}&pdf={pdf_name}")
+
+        with app.app_context():
+            with db.auto_commit():
+                analysis_pdf_object = AnalysisPdf.query.filter_by(id=analysis_pdf_id).first()
+                analysis_pdf_object.status = 1
+                analysis_pdf_object.bbox_info = json.dumps(bbox_info, ensure_ascii=False)
+                analysis_pdf_object.md_link_list = json.dumps(md_link_list, ensure_ascii=False)
+                analysis_pdf_object.full_md_link = full_md_link
+                db.session.add(analysis_pdf_object)
+            with db.auto_commit():
+                analysis_task_object = AnalysisTask.query.filter_by(analysis_pdf_id=analysis_pdf_id).first()
+                analysis_task_object.status = 1
+                db.session.add(analysis_task_object)
+        logger.info(f"finished!")
+    except Exception as e:
+        logger.error(traceback.format_exc())
+        with app.app_context():
+            with db.auto_commit():
+                analysis_pdf_object = AnalysisPdf.query.filter_by(id=analysis_pdf_id).first()
+                analysis_pdf_object.status = 2
+                db.session.add(analysis_pdf_object)
+            with db.auto_commit():
+                analysis_task_object = AnalysisTask.query.filter_by(analysis_pdf_id=analysis_pdf_id).first()
+                analysis_task_object.status = 1
+                db.session.add(analysis_task_object)
+        raise ApiException(code=500, msg="PDF parsing failed", msgZH="pdf解析失败")
+    finally:
+        # 执行pending
+        with app.app_context():
+            analysis_task_object = AnalysisTask.query.filter_by(status=2).order_by(
+                AnalysisTask.update_date.asc()).first()
+            if analysis_task_object:
+                pdf_upload_folder = current_app.config['PDF_UPLOAD_FOLDER']
+                upload_dir = f"{current_app.static_folder}/{pdf_upload_folder}"
+                file_path = find_file(analysis_task_object.file_key, upload_dir)
+                file_stem = Path(file_path).stem
+                pdf_analysis_folder = current_app.config['PDF_ANALYSIS_FOLDER']
+                pdf_dir = f"{current_app.static_folder}/{pdf_analysis_folder}/{file_stem}"
+                image_dir = f"{pdf_dir}/images"
+                with db.auto_commit():
+                    analysis_pdf_object = AnalysisPdf.query.filter_by(id=analysis_task_object.analysis_pdf_id).first()
+                    analysis_pdf_object.status = 0
+                    db.session.add(analysis_pdf_object)
+                with db.auto_commit():
+                    analysis_task_object.status = 0
+                    db.session.add(analysis_task_object)
+                analysis_pdf_task(pdf_dir, image_dir, file_path, analysis_task_object.is_ocr, analysis_task_object.analysis_pdf_id)
+            else:
+                logger.info(f"all task finished!")

+ 28 - 0
projects/web_api/web_api/api/analysis/serialization.py

@@ -0,0 +1,28 @@
+from marshmallow import Schema, fields, validates_schema, validates
+from common.error_types import ApiException
+from .models import AnalysisTask
+
+
+class BooleanField(fields.Boolean):
+    def _deserialize(self, value, attr, data, **kwargs):
+        # 进行自定义验证
+        if not isinstance(value, bool):
+            raise ApiException(code=400, msg="isOcr not a valid boolean", msgZH="isOcr不是有效的布尔值")
+
+        return value
+
+
+class AnalysisViewSchema(Schema):
+    fileKey = fields.Str(required=True)
+    fileName = fields.Str()
+    taskType = fields.Str(required=True)
+    isOcr = BooleanField()
+
+    @validates_schema(pass_many=True)
+    def validate_passwords(self, data, **kwargs):
+        task_type = data['taskType']
+        file_key = data['fileKey']
+        if not file_key:
+            raise ApiException(code=400, msg="fileKey cannot be empty", msgZH="fileKey不能为空")
+        if not task_type:
+            raise ApiException(code=400, msg="taskType cannot be empty", msgZH="taskType不能为空")

+ 95 - 0
projects/web_api/web_api/api/analysis/task_view.py

@@ -0,0 +1,95 @@
+import json
+from flask import url_for, request
+from flask_restful import Resource
+from sqlalchemy import func
+from ..extentions import db
+from .models import AnalysisTask, AnalysisPdf
+from .ext import task_state_map
+from common.custom_response import generate_response
+
+
+class TaskView(Resource):
+    def get(self):
+        """
+        查询正在进行的任务
+        :return:
+        """
+        analysis_task_running = AnalysisTask.query.filter(AnalysisTask.status == 0).first()
+        analysis_task_pending = AnalysisTask.query.filter(AnalysisTask.status == 2).order_by(
+            AnalysisTask.create_date.asc()).all()
+        pending_total = db.session.query(func.count(AnalysisTask.id)).filter(AnalysisTask.status == 2).scalar()
+        task_nums = pending_total + 1
+        data = [
+            {
+                "queues": task_nums,  # 正在排队的任务总数
+                "rank": 1,
+                "id": analysis_task_running.id,
+                "url": url_for('analysis.uploadpdfview', filename=analysis_task_running.file_name, as_attachment=False),
+                "fileName": analysis_task_running.file_name,
+                "type": analysis_task_running.task_type,
+                "state": task_state_map.get(analysis_task_running.status),
+            }
+        ]
+        for n, task in enumerate(analysis_task_pending):
+            data.append({
+                "queues": task_nums,  # 正在排队的任务总数
+                "rank": n + 2,
+                "id": task.id,
+                "url": url_for('analysis.uploadpdfview', filename=task.file_name, as_attachment=False),
+                "fileName": task.file_name,
+                "type": task.task_type,
+                "state": task_state_map.get(task.status),
+            })
+        data.reverse()
+        return generate_response(data=data, total=task_nums)
+
+
+class HistoricalTasksView(Resource):
+    def get(self):
+        """
+        获取任务历史记录
+        :return:
+        """
+        params = request.args
+        page_no = params.get('pageNo', 1)
+        page_size = params.get('pageSize', 10)
+        total = db.session.query(func.count(AnalysisTask.id)).scalar()
+        analysis_task = AnalysisTask.query.order_by(AnalysisTask.create_date.desc()).paginate(page=int(page_no),
+                                                                                              per_page=int(page_size),
+                                                                                              error_out=False)
+        data = []
+        for n, task in enumerate(analysis_task):
+            data.append({
+                "fileName": task.file_name,
+                "id": task.id,
+                "type": task.task_type,
+                "state": task_state_map.get(task.status),
+            })
+        data = {
+            "list": data,
+            "total": total,
+            "pageNo": page_no,
+            "pageSize": page_size,
+        }
+        return generate_response(data=data)
+
+
+class DeleteTaskView(Resource):
+    def delete(self):
+        """
+        删除任务历史记录
+        :return:
+        """
+        params = json.loads(request.data)
+        id = params.get('id')
+
+        analysis_task = AnalysisTask.query.filter(AnalysisTask.id == id, AnalysisTask.status != 0).first()
+        if analysis_task:
+            analysis_pdf = AnalysisPdf.query.filter(AnalysisPdf.id == AnalysisTask.analysis_pdf_id).first()
+            with db.auto_commit():
+                db.session.delete(analysis_pdf)
+                db.session.delete(analysis_task)
+        else:
+            return generate_response(code=400, msg="The ID is incorrect", msgZH="id不正确")
+
+        return generate_response(data={"id": id})

+ 89 - 0
projects/web_api/web_api/api/analysis/upload_view.py

@@ -0,0 +1,89 @@
+import json
+import traceback
+import requests
+from flask import request, current_app, url_for, send_from_directory
+from flask_restful import Resource
+from werkzeug.utils import secure_filename
+from pathlib import Path
+from common.ext import is_pdf, calculate_file_hash, url_is_pdf
+from io import BytesIO
+from werkzeug.datastructures import FileStorage
+from common.custom_response import generate_response
+from loguru import logger
+
+
+class UploadPdfView(Resource):
+
+    def get(self):
+        """
+        获取pdf
+        :return:
+        """
+        params = request.args
+        filename = params.get('filename')
+        as_attachment = params.get('as_attachment')
+        if str(as_attachment).lower() == "true":
+            as_attachment = True
+        else:
+            as_attachment = False
+        pdf_upload_folder = current_app.config['PDF_UPLOAD_FOLDER']
+        response = send_from_directory(f"{current_app.static_folder}/{pdf_upload_folder}", filename,
+                                       as_attachment=as_attachment)
+        return response
+
+    def post(self):
+        """
+        上传pdf
+        :return:
+        """
+        file_list = request.files.getlist("file")
+        if file_list:
+            file = file_list[0]
+            filename = secure_filename(file.filename)
+            if not file or file and not is_pdf(filename, file):
+                return generate_response(code=400, msg="Invalid PDF file", msgZH="PDF文件参数无效")
+        else:
+            params = json.loads(request.data)
+            pdf_url = params.get('pdfUrl')
+            try:
+                response = requests.get(pdf_url, stream=True)
+            except ConnectionError as e:
+                logger.error(traceback.format_exc())
+                return generate_response(code=400, msg="params is not valid", msgZh="参数错误,pdf链接无法访问")
+            if response.status_code != 200:
+                return generate_response(code=400, msg="params is not valid", msgZh="参数错误,pdf链接响应状态异常")
+            # 创建一个模拟的 FileStorage 对象
+            file_content = BytesIO(response.content)
+            filename = Path(pdf_url).name if ".pdf" in pdf_url else f"{Path(pdf_url).name}.pdf"
+            file = FileStorage(
+                stream=file_content,
+                filename=filename,
+                content_type=response.headers.get('Content-Type', 'application/octet-stream')
+            )
+            if not file or file and not url_is_pdf(file):
+                return generate_response(code=400, msg="Invalid PDF file", msgZH="PDF文件参数无效")
+
+        pdf_upload_folder = current_app.config['PDF_UPLOAD_FOLDER']
+        upload_dir = f"{current_app.static_folder}/{pdf_upload_folder}"
+        if not Path(upload_dir).exists():
+            Path(upload_dir).mkdir(parents=True, exist_ok=True)
+        file_key = calculate_file_hash(file)
+        # new_filename = f"{int(time.time())}_{filename}"
+        new_filename = f"{file_key}_{filename}"
+        file_path = f"{upload_dir}/{new_filename}"
+        # file.save(file_path)
+        chunk_size = 8192
+        with open(file_path, 'wb') as f:
+            while True:
+                chunk = file.stream.read(chunk_size)
+                if not chunk:
+                    break
+                f.write(chunk)
+
+        # 生成文件的URL路径
+        file_url = url_for('analysis.uploadpdfview', filename=new_filename, as_attachment=False)
+        data = {
+            "url": file_url,
+            "file_key": file_key
+        }
+        return generate_response(data=data)

+ 61 - 0
projects/web_api/web_api/api/extentions.py

@@ -0,0 +1,61 @@
+from flask import Flask, jsonify
+from flask_restful import Api as _Api
+from flask_cors import CORS
+from flask_sqlalchemy import SQLAlchemy as _SQLAlchemy
+from flask_migrate import Migrate
+from contextlib import contextmanager
+from flask_jwt_extended import JWTManager
+from flask_marshmallow import Marshmallow
+from common.error_types import ApiException
+from werkzeug.exceptions import HTTPException
+from loguru import logger
+
+
+class Api(_Api):
+    def handle_error(self, e):
+        if isinstance(e, ApiException):
+            code = e.code
+            msg = e.msg
+            msgZH = e.msgZH
+            error_code = e.error_code
+        elif isinstance(e, HTTPException):
+            code = e.code
+            msg = e.description
+            msgZH = "服务异常,详细信息请查看日志"
+            error_code = e.code
+        else:
+            code = 500
+            msg = str(e)
+            error_code = 500
+            msgZH = "服务异常,详细信息请查看日志"
+
+        # 使用 loguru 记录异常信息
+        logger.opt(exception=e).error(f"An error occurred: {msg}")
+
+        return jsonify({
+            "error": "Internal Server Error" if code == 500 else e.name,
+            "msg": msg,
+            "msgZH": msgZH,
+            "code": code,
+            "error_code": error_code
+        }), code
+
+
+class SQLAlchemy(_SQLAlchemy):
+    @contextmanager
+    def auto_commit(self):
+        try:
+            yield
+            db.session.commit()
+            db.session.flush()
+        except Exception as e:
+            db.session.rollback()
+            raise e
+
+
+app = Flask(__name__)
+CORS(app, supports_credentials=True)
+db = SQLAlchemy()
+migrate = Migrate()
+jwt = JWTManager()
+ma = Marshmallow()

+ 54 - 0
projects/web_api/web_api/app.py

@@ -0,0 +1,54 @@
+import socket
+from api import create_app
+from pathlib import Path
+import yaml
+
+
+def get_local_ip():
+    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+    sock.connect(('8.8.8.8', 80))  # Google DNS 服务器
+    ip_address = sock.getsockname()[0]
+    sock.close()
+    return ip_address
+
+
+current_file_path = Path(__file__).resolve()
+base_dir = current_file_path.parent
+config_path = base_dir / "config/config.yaml"
+
+
+class ConfigMap(dict):
+    __setattr__ = dict.__setitem__
+    __getattr__ = dict.__getitem__
+
+
+with open(str(config_path), mode='r', encoding='utf-8') as fd:
+    data = yaml.load(fd, Loader=yaml.FullLoader)
+    _config = data.get(data.get("CurrentConfig", "DevelopmentConfig"))
+config = ConfigMap()
+for k, v in _config.items():
+    config[k] = v
+config['base_dir'] = base_dir
+database = _config.get("database")
+if database:
+    if database.get("type") == "sqlite":
+        database_uri = f'sqlite:///{base_dir}/{database.get("path")}'
+    elif database.get("type") == "mysql":
+        database_uri = f'mysql+pymysql://{database.get("user")}:{database.get("password")}@{database.get("host")}:{database.get("port")}/{database.get("database")}?'
+    else:
+        database_uri = ''
+    config['SQLALCHEMY_DATABASE_URI'] = database_uri
+
+ip_address = get_local_ip()
+port = config.get("PORT", 5559)
+# 配置 SERVER_NAME
+config['SERVER_NAME'] = f'{ip_address}:5559'
+# 配置 APPLICATION_ROOT
+config['APPLICATION_ROOT'] = '/'
+# 配置 PREFERRED_URL_SCHEME
+config['PREFERRED_URL_SCHEME'] = 'http'
+
+app = create_app(config)
+
+if __name__ == '__main__':
+    app.run(host="0.0.0.0", port=port, debug=config.get("DEBUG", False))

+ 0 - 0
projects/web_api/web_api/common/__init__.py


+ 23 - 0
projects/web_api/web_api/common/custom_response.py

@@ -0,0 +1,23 @@
+from flask import jsonify
+
+
+class ResponseCode:
+    SUCCESS = 200
+    PARAM_WARING = 400
+    MESSAGE = "success"
+
+
+def generate_response(data=None, code=ResponseCode.SUCCESS, msg=ResponseCode.MESSAGE, **kwargs):
+    """
+    自定义响应
+    :param code:状态码
+    :param data:返回数据
+    :param msg:返回消息
+    :param kwargs:
+    :return:
+    """
+    msg = msg or 'success' if code == 200 else msg or 'fail'
+    success = True if code == 200 else False
+    res = jsonify(dict(code=code, success=success, data=data, msg=msg, **kwargs))
+    res.status_code = 200
+    return res

+ 45 - 0
projects/web_api/web_api/common/error_types.py

@@ -0,0 +1,45 @@
+import json
+from flask import request
+from werkzeug.exceptions import HTTPException
+
+
+class ApiException(HTTPException):
+    """API错误基类"""
+    code = 500
+    msg = 'Sorry, we made a mistake Σ(っ °Д °;)っ'
+    msgZH = ""
+    error_code = 999
+
+    def __init__(self, msg=None, msgZH=None, code=None, error_code=None, headers=None):
+        if code:
+            self.code = code
+        if msg:
+            self.msg = msg
+        if msgZH:
+            self.msgZH = msgZH
+        if error_code:
+            self.error_code = error_code
+        super(ApiException, self).__init__(msg, None)
+
+    @staticmethod
+    def get_error_url():
+        """获取出错路由和请求方式"""
+        method = request.method
+        full_path = str(request.full_path)
+        main_path = full_path.split('?')[0]
+        res = method + ' ' + main_path
+        return res
+
+    def get_body(self, environ=None, scope=None):
+        """异常返回信息"""
+        body = dict(
+            msg=self.msg,
+            error_code=self.error_code,
+            request=self.get_error_url()
+        )
+        text = json.dumps(body)
+        return text
+
+    def get_headers(self, environ=None, scope=None):
+        """异常返回格式"""
+        return [("Content-Type", "application/json")]

+ 80 - 0
projects/web_api/web_api/common/ext.py

@@ -0,0 +1,80 @@
+import hashlib
+import mimetypes
+
+
+def is_pdf(filename, file):
+    """
+    判断文件是否为PDF格式。
+
+    :param filename: 文件名
+    :param file: 文件对象
+    :return: 如果文件是PDF格式,则返回True,否则返回False
+    """
+    # 检查文件扩展名  https://arxiv.org/pdf/2405.08702 pdf链接可能存在不带扩展名的情况,先注释
+    if not filename.endswith('.pdf'):
+        return False
+
+    # 检查MIME类型
+    mime_type, _ = mimetypes.guess_type(filename)
+    print(mime_type)
+    if mime_type != 'application/pdf':
+        return False
+
+    # 可选:读取文件的前几KB内容并检查MIME类型
+    # 这一步是可选的,用于更严格的检查
+    # if not mimetypes.guess_type(filename, strict=False)[0] == 'application/pdf':
+    #     return False
+
+    # 检查文件内容
+    file_start = file.read(5)
+    file.seek(0)
+    if not file_start.startswith(b'%PDF-'):
+        return False
+
+    return True
+
+
+def url_is_pdf(file):
+    """
+    判断文件是否为PDF格式。
+
+    :param file: 文件对象
+    :return: 如果文件是PDF格式,则返回True,否则返回False
+    """
+    # 检查文件内容
+    file_start = file.read(5)
+    file.seek(0)
+    if not file_start.startswith(b'%PDF-'):
+        return False
+
+    return True
+
+
+def calculate_file_hash(file, algorithm='sha256'):
+    """
+    计算给定文件的哈希值。
+
+    :param file: 文件对象
+    :param algorithm: 哈希算法的名字,如:'sha256', 'md5', 'sha1'等
+    :return: 文件的哈希值
+    """
+    hash_func = getattr(hashlib, algorithm)()
+    block_size = 65536  # 64KB chunks
+    # with open(file_path, 'rb') as file:
+    buffer = file.read(block_size)
+    while len(buffer) > 0:
+        hash_func.update(buffer)
+        buffer = file.read(block_size)
+    file.seek(0)
+    return hash_func.hexdigest()
+
+
+def singleton_func(cls):
+    instance = {}
+
+    def _singleton(*args, **kwargs):
+        if cls not in instance:
+            instance[cls] = cls(*args, **kwargs)
+        return instance[cls]
+
+    return _singleton

+ 1 - 0
projects/web_api/web_api/common/import_models.py

@@ -0,0 +1 @@
+from api.analysis.models import *

+ 19 - 0
projects/web_api/web_api/common/logger.py

@@ -0,0 +1,19 @@
+import os
+from loguru import logger
+from pathlib import Path
+from datetime import datetime
+
+
+def setup_log(config):
+    """
+    Setup logging
+    :param config:  config file
+    :return:
+    """
+    log_path = os.path.join(Path(__file__).parent.parent, "log")
+    if not Path(log_path).exists():
+        Path(log_path).mkdir(parents=True, exist_ok=True)
+    log_level = config.get("LOG_LEVEL")
+    log_name = f'log_{datetime.now().strftime("%Y-%m-%d")}.log'
+    log_file_path = os.path.join(log_path, log_name)
+    logger.add(str(log_file_path), rotation='00:00', encoding='utf-8', level=log_level, enqueue=True)

+ 9 - 0
projects/web_api/web_api/common/web_hook.py

@@ -0,0 +1,9 @@
+
+def before_request():
+    return None
+
+
+def after_request(response):
+    response.headers.add('Access-Control-Allow-Origin', '*')
+    response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
+    return response

+ 0 - 0
projects/web_api/web_api/config/__init__.py


+ 31 - 0
projects/web_api/web_api/config/config.yaml

@@ -0,0 +1,31 @@
+# 基本配置
+BaseConfig: &base
+  DEBUG: false
+  PORT: 5559
+  LOG_LEVEL: "DEBUG"
+  SQLALCHEMY_TRACK_MODIFICATIONS: true
+  SQLALCHEMY_DATABASE_URI: ""
+  PROPAGATE_EXCEPTIONS: true
+  SECRET_KEY: "#$%^&**$##*(*^%%$**((&"
+  JWT_SECRET_KEY: "#$%^&**$##*(*^%%$**((&"
+  JWT_ACCESS_TOKEN_EXPIRES: 3600
+  PDF_UPLOAD_FOLDER: "upload_pdf"
+  PDF_ANALYSIS_FOLDER: "analysis_pdf"
+
+# 开发配置
+DevelopmentConfig:
+  <<: *base
+  database:
+    type: sqlite
+    path: config/mineru_web.db
+
+# 生产配置
+ProductionConfig:
+  <<: *base
+
+# 测试配置
+TestingConfig:
+  <<: *base
+
+# 当前使用配置
+CurrentConfig: "DevelopmentConfig"

BIN
projects/web_api/web_api/config/mineru_web.db


+ 0 - 0
projects/web_api/web_api/static/__init__.py


Some files were not shown because too many files changed in this diff