├── .env.template ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md ├── dependabot.yml └── workflows │ ├── pre-commit.yml │ ├── routine_testing.yml │ ├── smokeshow.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── kubernetes └── deployment.yaml ├── main.py ├── requirements-dev.txt ├── requirements-tests.txt ├── requirements.txt ├── scripts └── ssl │ └── generate_ssl.py ├── src ├── __init__.py ├── api │ ├── __init__.py │ ├── constant │ │ ├── __init__.py │ │ ├── buses.py │ │ └── general.py │ ├── models │ │ ├── __init__.py │ │ ├── buses.py │ │ └── courses.py │ ├── routers │ │ ├── __init__.py │ │ ├── announcements.py │ │ ├── buses.py │ │ ├── courses.py │ │ ├── departments.py │ │ ├── dining.py │ │ ├── energy.py │ │ ├── libraries.py │ │ ├── locations.py │ │ └── newsletters.py │ └── schemas │ │ ├── __init__.py │ │ ├── announcements.py │ │ ├── buses.py │ │ ├── courses.py │ │ ├── departments.py │ │ ├── dining.py │ │ ├── energy.py │ │ ├── libraries.py │ │ ├── locations.py │ │ └── newsletters.py └── utils │ ├── __init__.py │ ├── nthudata.py │ └── schema.py └── tests ├── test_announcements.py ├── test_buses.py ├── test_courses.py ├── test_departments.py ├── test_dining.py ├── test_energy.py ├── test_libraries.py ├── test_locations.py └── test_newsletters.py /.env.template: -------------------------------------------------------------------------------- 1 | DEV_MODE=True 2 | PORT=5000 3 | NTHU_DATA_URL=https://data.nthusa.tw -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report. 3 | title: "bug: " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | 11 | - type: textarea 12 | id: current-behavior 13 | attributes: 14 | label: Current Behavior 15 | placeholder: Tell us what you see! 16 | validations: 17 | required: true 18 | 19 | - type: textarea 20 | id: description 21 | attributes: 22 | label: Description 23 | description: More description about this bug. 24 | placeholder: Tell us more infomation! 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | id: possible-solution 30 | attributes: 31 | label: Possible Solution 32 | description: Any possible solution about this bug. 33 | placeholder: Tell us your ideas about this bug! 34 | validations: 35 | required: false 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: File a feature request. 3 | title: "feat: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this feature request! 10 | 11 | - type: textarea 12 | id: what-feature 13 | attributes: 14 | label: What Feature You Want 15 | placeholder: Tell us what you think! 16 | validations: 17 | required: true 18 | 19 | - type: textarea 20 | id: description 21 | attributes: 22 | label: Description 23 | description: More description about this feature request. 24 | placeholder: Tell us more infomation! 25 | validations: 26 | required: false 27 | 28 | - type: textarea 29 | id: possible-solution 30 | attributes: 31 | label: Possible Solution 32 | description: Any possible solution about this feature. 33 | placeholder: Tell us your ideas about this feature! 34 | validations: 35 | required: false 36 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | 9 | ### Features 10 | - 11 | 12 | 15 | ### Fixes 16 | - 17 | 18 | 21 | ### Refactors 22 | - 23 | 24 | 27 | ### Internal 28 | - 29 | 30 | 33 | ### Documentations 34 | - 35 | 36 | 37 | ### Notes 38 | - 39 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: # Check for updates to GitHub Actions every weekday 11 | interval: "daily" 12 | - package-ecosystem: "pip" # See documentation for possible values 13 | directory: "/" # Location of package manifests 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.13" 16 | - uses: pre-commit/action@v3.0.1 17 | -------------------------------------------------------------------------------- /.github/workflows/routine_testing.yml: -------------------------------------------------------------------------------- 1 | name: Routine Testing 2 | 3 | on: 4 | schedule: 5 | - cron: "0 12 * * 1,3,5" 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Dump GitHub context 12 | env: 13 | GITHUB_CONTEXT: ${{ toJson(github) }} 14 | run: echo "$GITHUB_CONTEXT" 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.13" 20 | cache: "pip" 21 | - name: Install Dependencies 22 | run: | 23 | pip install -r requirements.txt 24 | pip install -r requirements-tests.txt 25 | - name: Test 26 | run: python -m pytest -n auto tests -W ignore::DeprecationWarning --cov=src --cov=tests --cov-report=xml --cov-report=html:coverage --cov-fail-under=85 27 | - name: Store coverage files 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: coverage 31 | path: | 32 | coverage 33 | coverage.xml 34 | -------------------------------------------------------------------------------- /.github/workflows/smokeshow.yml: -------------------------------------------------------------------------------- 1 | name: Generate Coverage 2 | 3 | on: 4 | workflow_run: 5 | workflows: [Test] 6 | types: [completed] 7 | 8 | permissions: 9 | statuses: write 10 | 11 | jobs: 12 | context: 13 | if: ${{ github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success' }} 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Dump GitHub context 18 | env: 19 | GITHUB_CONTEXT: ${{ toJson(github) }} 20 | run: echo "$GITHUB_CONTEXT" 21 | 22 | smokeshow: 23 | needs: context 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Dump GitHub context 27 | env: 28 | GITHUB_CONTEXT: ${{ toJson(github) }} 29 | run: echo "$GITHUB_CONTEXT" 30 | - uses: actions/setup-python@v5 31 | with: 32 | python-version: "3.13" 33 | - run: pip install smokeshow 34 | - uses: dawidd6/action-download-artifact@v9 35 | with: 36 | workflow: tests.yml 37 | commit: ${{ github.event.workflow_run.head_sha }} 38 | - run: smokeshow upload coverage/coverage 39 | env: 40 | SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Coverage {coverage-percentage} 41 | SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 85 42 | SMOKESHOW_GITHUB_CONTEXT: Coverage 43 | SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} 44 | SMOKESHOW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # test workflow from https://github.com/tiangolo/fastapi/blob/master/.github/workflows/test.yml 2 | name: Test 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | types: 10 | - opened 11 | - synchronize 12 | - reopened 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Dump GitHub context 19 | env: 20 | GITHUB_CONTEXT: ${{ toJson(github) }} 21 | run: echo "$GITHUB_CONTEXT" 22 | - uses: actions/checkout@v4 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: "3.13" 27 | cache: "pip" 28 | - name: Install Dependencies 29 | run: | 30 | pip install -r requirements.txt 31 | pip install -r requirements-tests.txt 32 | - name: Test 33 | run: python -m pytest -n auto tests -W ignore::DeprecationWarning --cov=src --cov=tests --cov-report=xml --cov-report=html:coverage --cov-fail-under=85 34 | - name: Store coverage files 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: coverage 38 | path: | 39 | coverage 40 | coverage.xml 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # certificates 2 | *.pem 3 | 4 | # temp files 5 | temp/ 6 | 7 | # history 8 | .history/ 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | share/python-wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | *.py,cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | cover/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | db.sqlite3 71 | db.sqlite3-journal 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | .pybuilder/ 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # IPython 91 | profile_default/ 92 | ipython_config.py 93 | 94 | # pyenv 95 | # For a library or package, you might want to ignore these files since the code is 96 | # intended to run in multiple environments; otherwise, check them in: 97 | # .python-version 98 | 99 | # pipenv 100 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 101 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 102 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 103 | # install all needed dependencies. 104 | #Pipfile.lock 105 | 106 | # poetry 107 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 108 | # This is especially recommended for binary packages to ensure reproducibility, and is more 109 | # commonly ignored for libraries. 110 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 111 | #poetry.lock 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | 7 | - repo: https://github.com/PyCQA/isort 8 | rev: 6.0.0 9 | hooks: 10 | - id: isort 11 | args: ["--profile", "black", "--filter-files"] 12 | 13 | - repo: https://github.com/psf/black 14 | rev: 25.1.0 15 | hooks: 16 | - id: black 17 | language_version: python3.13 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Purpose: Dockerfile for building the image of the web server 2 | FROM python:3.13-slim 3 | 4 | # Set the working directory to /app 5 | WORKDIR /usr/src/app 6 | 7 | # Set timezone to Taipei 8 | ENV TZ=Asia/Taipei 9 | RUN ln -snf /usr/share/zoneinfo/"$TZ" /etc/localtime && echo "$TZ" > /etc/timezone 10 | 11 | # Install python 12 | COPY requirements.txt . 13 | RUN pip3 install -r requirements.txt --no-cache-dir 14 | COPY . ./ 15 | 16 | # Run main.py when the container launches 17 | EXPOSE 5000 18 | CMD ["python", "main.py"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 National Tsing Hua University Student Association 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NTHU-Data-API 2 |

3 | NTHU-Data-API is a project designed for NTHU developers. 4 |
5 | It provides an easy way to fetch data from the NTHU website. 6 |

7 |

8 | 9 | Code style: black 10 | 11 | 12 | Test Coverage 13 | 14 | 15 | Test Action Status 16 | 17 |
18 | 19 | 
 20 | Maintainability Rating 21 | 22 | 23 | Lines of Code 24 | 25 | 26 | Technical Debt 27 | 28 |

29 | 30 | ## Getting Started 31 | ### Prerequisites 32 | Ensure you have Python 3 installed on your machine. You can verify this by running `python3 --version` in your terminal. If you don't have Python 3 installed, you can download it [here](https://www.python.org/downloads/). 33 | 34 | ### Installation 35 | 1. Clone the repository: 36 | ```sh 37 | git clone https://github.com/NTHU-SA/NTHU-Data-API.git 38 | ``` 39 | 2. Navigate to the project directory: 40 | ```sh 41 | cd NTHU-Data-API 42 | ``` 43 | 3. Install the required dependencies: 44 | ```sh 45 | pip3 install -r requirements.txt 46 | ``` 47 | 48 | ### Configuration 49 | Copy the environment template file and fill in your details: 50 | ```sh 51 | cp .env.template .env 52 | ``` 53 | 54 | ### Running the Application 55 | ```sh 56 | python3 main.py 57 | ``` 58 | 59 | ## Contributing 60 | We follow certain guidelines for contributing. Here are the types of commits we accept: 61 | 62 | - feat: Add or modify features. 63 | - fix: Bug fixes. 64 | - docs: Documentation changes. 65 | - style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi colons, etc). 66 | - refactor: Code changes that neither fixes a bug nor adds a feature. 67 | - update: Data changes that neither fixes a bug nor adds a feature. 68 | - perf: Code changes that improve performance. 69 | - test: Adding missing tests. 70 | - chore: Changes to the build process or auxiliary tools and libraries. 71 | - revert: Reverts a previous commit. 72 | 73 | ### pre-commit 74 | 1. Before committing, use `pre-commit` to ensure the format. 75 | ```sh 76 | pip3 install -r requirements-dev.txt 77 | ``` 78 | 2. Install `pre-commit`. 79 | ```sh 80 | pre-commit install 81 | ``` 82 | 83 | ### Running Tests 84 | To run tests locally before committing changes, follow these steps: 85 | 1. Install the required dependencies: 86 | ```sh 87 | pip3 install -r requirements-tests.txt 88 | ``` 89 | 2. Run tests: 90 | Navigate to the project's root directory and execute: 91 | ```sh 92 | python3 -m pytest -n auto tests -W ignore::DeprecationWarning 93 | ``` 94 | 3. Generate a coverage report (optional): 95 | If you need a test coverage report, run: 96 | ```sh 97 | python3 -m pytest -n auto tests -W ignore::DeprecationWarning --cov=src --cov=tests --cov-report=xml --cov-report=html:coverage --cov-fail-under=85 98 | ``` 99 | ## Credit 100 | This project is maintained by NTHUSA 32nd. 101 | 102 | ## License 103 | This project is licensed under the [MIT License](https://choosealicense.com/licenses/mit/). 104 | 105 | ## Acknowledgements 106 | Thanks to SonarCloud for providing code quality metrics: 107 | 108 | [![SonarCloud](https://sonarcloud.io/images/project_badges/sonarcloud-white.svg)](https://sonarcloud.io/summary/new_code?id=NTHU-SA_NTHU-Data-API) -------------------------------------------------------------------------------- /kubernetes/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: nthu-data-api 6 | name: nthu-data-api 7 | namespace: default 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: nthu-data-api 13 | template: 14 | metadata: 15 | labels: 16 | app: nthu-data-api 17 | spec: 18 | containers: 19 | - image: gcr.io/nthu-chatbot/github.com/nthu-sa/nthu-data-api 20 | name: nthu-data-api 21 | ports: 22 | - containerPort: 5000 23 | protocol: TCP 24 | resources: 25 | requests: 26 | cpu: "250m" 27 | ephemeral-storage: 1Gi 28 | memory: "1024Mi" 29 | limits: 30 | cpu: "500m" 31 | ephemeral-storage: 1Gi 32 | memory: "2048Mi" 33 | env: 34 | - name: TZ 35 | value: Asia/Taipei 36 | - name: ENV 37 | value: production 38 | - name: LOGURU_LEVEL 39 | value: ERROR 40 | - name: PORT 41 | value: "5000" 42 | --- 43 | apiVersion: v1 44 | kind: Service 45 | metadata: 46 | name: nthu-data-api 47 | labels: 48 | app: nthu-data-api 49 | namespace: default 50 | annotations: 51 | cloud.google.com/neg: '{"ingress": true}' 52 | spec: 53 | ports: 54 | - protocol: "TCP" 55 | port: 443 56 | targetPort: 5000 57 | selector: 58 | app: nthu-data-api 59 | type: NodePort 60 | --- 61 | apiVersion: autoscaling/v2 62 | kind: HorizontalPodAutoscaler 63 | metadata: 64 | name: nthu-data-api 65 | namespace: default 66 | spec: 67 | scaleTargetRef: 68 | apiVersion: apps/v1 69 | kind: Deployment 70 | name: nthu-data-api 71 | minReplicas: 1 72 | maxReplicas: 2 73 | metrics: 74 | - type: Resource 75 | resource: 76 | name: cpu 77 | target: 78 | type: Utilization 79 | averageUtilization: 80 80 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import uvicorn 4 | from dotenv import load_dotenv 5 | 6 | from scripts.ssl.generate_ssl import generate_certificate 7 | 8 | load_dotenv() 9 | 10 | if __name__ == "__main__": 11 | if os.getenv("DEV_MODE") == "True": 12 | # Development 13 | uvicorn.run( 14 | app="src:app", 15 | host="0.0.0.0", 16 | port=int(os.getenv("PORT", 5000)), 17 | log_level="debug", 18 | reload=True, # reload the server every time code changes 19 | ) 20 | else: 21 | # Production 22 | ssl_path = "scripts/ssl/" 23 | generate_certificate(path=ssl_path) 24 | ssl_keyfile = ssl_path + "key.pem" 25 | ssl_certfile = ssl_path + "cert.pem" 26 | uvicorn.run( 27 | app="src:app", 28 | host="0.0.0.0", 29 | port=int(os.getenv("PORT", 5000)), 30 | log_level="error", 31 | workers=2, 32 | ssl_keyfile=ssl_keyfile, 33 | ssl_certfile=ssl_certfile, 34 | ) 35 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # before commit 2 | pre-commit==4.2.0 3 | black -------------------------------------------------------------------------------- /requirements-tests.txt: -------------------------------------------------------------------------------- 1 | # Tests 2 | pytest==8.3.5 3 | pytest-xdist==3.6.1 4 | pytest-cov==6.0.0 5 | httpx==0.28.1 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Server 2 | fastapi==0.115.12 3 | uvicorn==0.34.0 4 | # Environment 5 | python-dotenv==1.1.0 6 | cachetools==5.5.2 7 | pyopenssl==25.0.0 8 | # Crawlers 9 | httpx[http2]==0.28.1 10 | requests==2.32.3 11 | beautifulsoup4==4.13.3 12 | xmltodict==0.14.2 13 | pandas==2.2.3 14 | lxml==5.3.2 15 | # Search 16 | jieba==0.42.1 17 | python-Levenshtein==0.27.1 18 | thefuzz==0.22.1 -------------------------------------------------------------------------------- /scripts/ssl/generate_ssl.py: -------------------------------------------------------------------------------- 1 | from OpenSSL import crypto 2 | 3 | 4 | def generate_certificate( 5 | organization="國立清華大學學生會", 6 | common_name="api.nthusa.tw", 7 | country="TW", 8 | duration=(10 * 365 * 24 * 60 * 60), 9 | path="", 10 | keyfilename="key.pem", 11 | certfilename="cert.pem", 12 | ): 13 | k = crypto.PKey() 14 | k.generate_key(crypto.TYPE_RSA, 4096) 15 | 16 | cert = crypto.X509() 17 | cert.get_subject().C = country 18 | cert.get_subject().O = organization 19 | cert.get_subject().CN = common_name 20 | cert.gmtime_adj_notBefore(0) 21 | cert.gmtime_adj_notAfter(duration) 22 | cert.set_issuer(cert.get_subject()) 23 | cert.set_pubkey(k) 24 | cert.sign(k, "sha512") 25 | 26 | with open(path + keyfilename, "wt") as keyfile: 27 | keyfile.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, k).decode("utf-8")) 28 | with open(path + certfilename, "wt") as certfile: 29 | certfile.write( 30 | crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode("utf-8") 31 | ) 32 | 33 | 34 | if __name__ == "__main__": 35 | generate_certificate() 36 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | from .api.routers import app 2 | -------------------------------------------------------------------------------- /src/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NTHU-SA/NTHU-Data-API/b076d7caea2e56dcc75d5a44d7bf5392ccfbc29e/src/api/__init__.py -------------------------------------------------------------------------------- /src/api/constant/__init__.py: -------------------------------------------------------------------------------- 1 | from . import buses, general 2 | -------------------------------------------------------------------------------- /src/api/constant/buses.py: -------------------------------------------------------------------------------- 1 | from fastapi import Path, Query 2 | 3 | # for routers/buses.py 4 | DEFAULT_LIMIT_DAY_CURRENT = 5 5 | BUS_TYPE_PATH = Path( 6 | ..., example="main", description="車種選擇 校本部公車 或 南大區間車" 7 | ) 8 | BUS_DIRECTION_PATH = Path(..., example="up", description="上山或下山") 9 | BUS_TYPE_QUERY = Query( 10 | ..., example="main", description="車種選擇 校本部公車 或 南大區間車" 11 | ) 12 | BUS_DAY_QUERY = Query( 13 | ..., 14 | example="weekday", 15 | description=f"平日、假日或目前時刻。選擇 current 預設回傳 {DEFAULT_LIMIT_DAY_CURRENT} 筆資料。", 16 | ) 17 | BUS_DIRECTION_QUERY = Query(..., example="up", description="上山或下山") 18 | STOPS_NAME_PATH = Path(..., example="北校門口", description="公車站牌名稱") 19 | -------------------------------------------------------------------------------- /src/api/constant/general.py: -------------------------------------------------------------------------------- 1 | from fastapi import Query 2 | 3 | # for all routers 4 | LIMITS_QUERY = Query(None, ge=1, example=5, description="最大回傳資料筆數") 5 | -------------------------------------------------------------------------------- /src/api/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NTHU-SA/NTHU-Data-API/b076d7caea2e56dcc75d5a44d7bf5392ccfbc29e/src/api/models/__init__.py -------------------------------------------------------------------------------- /src/api/models/buses.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from datetime import datetime, timedelta 5 | from functools import reduce 6 | from itertools import product 7 | from typing import Any, Literal, Optional 8 | 9 | import pandas as pd 10 | 11 | from src.api import schemas 12 | from src.utils import nthudata 13 | 14 | # --------------------------------------------------------------------------- 15 | # 常數與全域變數 16 | # --------------------------------------------------------------------------- 17 | DATA_TTL_HOURS = 4 # 資料存活時間 (小時) 18 | 19 | # 保持後續程式中 BUS_TYPE, BUS_DAY, BUS_DIRECTION 的順序一致,因 BusType、BusDay 具有 all 選項 20 | BUS_ROUTE_TYPE: list[str] = [bus_type.value for bus_type in schemas.buses.BusRouteType] 21 | BUS_ROUTE_TYPE_WITHOUT_ALL: list[str] = BUS_ROUTE_TYPE[1:] # 第一個為 all,故移除 22 | BUS_DAY: list[str] = [bus_day.value for bus_day in schemas.buses.BusDay] 23 | BUS_DAY_WITHOUT_ALL: list[str] = BUS_DAY[1:] 24 | BUS_DIRECTION: list[str] = [bus_dir.value for bus_dir in schemas.buses.BusDirection] 25 | 26 | schedule_index = pd.MultiIndex.from_product([BUS_ROUTE_TYPE, BUS_DAY, BUS_DIRECTION]) 27 | 28 | 29 | # --------------------------------------------------------------------------- 30 | # Helper Functions 31 | # --------------------------------------------------------------------------- 32 | def get_nested_value(data: dict, keys: list[str]) -> Any: 33 | """ 34 | 從巢狀字典中根據鍵路徑 (keys) 取得對應的值。 35 | 36 | Args: 37 | data (dict): 巢狀字典。 38 | keys (list[str]): 鍵路徑,依序指定巢狀結構中的鍵。 39 | 40 | Returns: 41 | Any: 根據鍵路徑取得的值。若路徑不存在,則返回 None。 42 | """ 43 | return reduce(dict.get, keys, data) 44 | 45 | 46 | def after_specific_time( 47 | target_list: list[dict], time_str: str, time_path: Optional[list[str]] = None 48 | ) -> list[dict]: 49 | """ 50 | 過濾字典列表,僅保留指定時間之後的資料。 51 | 52 | Args: 53 | target_list (list[dict]): 包含時間字串的字典列表。 54 | time_str (str): 比較用的時間字串,格式為 'HH:MM'。 55 | time_path (Optional[list[str]]): 指定在字典中取得時間字串的鍵路徑。 56 | 若為 None,則預設字典本身即為時間字串。 57 | 58 | Returns: 59 | list[dict]: 過濾後的時間在 `time_str` 之後的字典列表。 60 | """ 61 | ref_hour, ref_minute = map(int, time_str.split(":")) 62 | filtered_list = [] 63 | for item in target_list: 64 | item_time_str = get_nested_value(item, time_path) if time_path else item 65 | item_hour, item_minute = map(int, item_time_str.split(":")) 66 | if item_hour > ref_hour or ( 67 | item_hour == ref_hour and item_minute >= ref_minute 68 | ): 69 | filtered_list.append(item) 70 | return filtered_list 71 | 72 | 73 | def sort_by_time(target: list[dict], time_path: Optional[list[str]] = None) -> None: 74 | """ 75 | 依照時間排序字典列表。 76 | 77 | Args: 78 | target (list[dict]): 包含時間資訊的字典列表。 79 | time_path (Optional[list[str]]): 指定在字典中取得時間字串的鍵路徑。 80 | 若為 None,則預設字典本身即為時間字串。 81 | 時間字串格式必須為 '%H:%M'。 82 | """ 83 | target.sort( 84 | key=lambda x: datetime.strptime( 85 | get_nested_value(x, time_path) if time_path else x, "%H:%M" 86 | ) 87 | ) 88 | 89 | 90 | def gen_the_all_field(target_dataframe: pd.DataFrame, time_path: list[str]) -> None: 91 | """ 92 | 針對 DataFrame 產品組合欄位,將資料合併至 'all' 欄位,並依照時間排序。 93 | 94 | 此函式會針對 BUS_TYPE_WITHOUT_ALL 和 BUS_DAY,將 weekday 與 weekend 以及不同 BusType 的資料合併到 'all' 欄位, 95 | 並確保最終的 'all' 欄位資料已按照時間排序。 96 | 97 | Args: 98 | target_dataframe (pd.DataFrame): 包含分層索引 (MultiIndex) 的 DataFrame,索引應包含 BUS_TYPE, BUS_DAY, BUS_DIRECTION。 99 | time_path (list[str]): 指定在資料中取得時間字串的鍵路徑,用於排序。 100 | """ 101 | # 針對 BUS_TYPE_WITHOUT_ALL 合併 weekday 與 weekend 102 | for route_type, direction in product(BUS_ROUTE_TYPE_WITHOUT_ALL, BUS_DIRECTION): 103 | weekday_data = target_dataframe.loc[(route_type, "weekday", direction), "data"] 104 | weekend_data = target_dataframe.loc[(route_type, "weekend", direction), "data"] 105 | target_dataframe.loc[(route_type, "all", direction), "data"] = ( 106 | weekday_data + weekend_data 107 | ) 108 | 109 | # 合併不同 BusType 的資料 110 | for day, direction in product(BUS_DAY, BUS_DIRECTION): 111 | main_data = target_dataframe.loc[("main", day, direction), "data"] 112 | nanda_data = target_dataframe.loc[("nanda", day, direction), "data"] 113 | target_dataframe.loc[("all", day, direction), "data"] = main_data + nanda_data 114 | 115 | # 最後對所有資料依時間排序 116 | for route_type, day, direction in product(BUS_ROUTE_TYPE, BUS_DAY, BUS_DIRECTION): 117 | sort_by_time( 118 | target_dataframe.loc[(route_type, day, direction), "data"], time_path 119 | ) 120 | 121 | 122 | # --------------------------------------------------------------------------- 123 | # 資料結構定義 124 | # --------------------------------------------------------------------------- 125 | @dataclass(unsafe_hash=True) 126 | class Stop: 127 | """ 128 | 公車站點資料類別。 129 | 130 | Attributes: 131 | name (str): 站點名稱 (中文)。 132 | name_en (str): 站點英文名稱。 133 | latitude (str): 站點緯度。 134 | longitude (str): 站點經度。 135 | stopped_bus (pd.DataFrame): DataFrame 儲存停靠此站點的公車時刻表資料,初始化後設定,不參與比較或雜湊計算。 136 | """ 137 | 138 | name: str 139 | name_en: str 140 | latitude: str 141 | longitude: str 142 | stopped_bus: pd.DataFrame = field(init=False, compare=False, hash=False) 143 | 144 | def __post_init__(self): 145 | """初始化後建立空的 stopped_bus DataFrame,使用全域定義的 schedule_index 作為索引。""" 146 | self.stopped_bus = pd.DataFrame( 147 | {"data": [[] for _ in range(len(schedule_index))]}, 148 | index=schedule_index, 149 | ) 150 | 151 | 152 | @dataclass 153 | class Route: 154 | """ 155 | 公車路線資料類別。 156 | 157 | Attributes: 158 | stops (list[Stop]): 路線包含的站點列表,依序排列。 159 | _delta_time_table (dict[Stop, dict[Stop, int]]): 站點間的預估時間差 (分鐘),用於計算站點抵達時間,初始化後設定。 160 | """ 161 | 162 | stops: list[Stop] 163 | _delta_time_table: dict[Stop, dict[Stop, int]] = field( 164 | default_factory=dict, init=False 165 | ) 166 | 167 | def __post_init__(self): 168 | """初始化後設定預設的站點間時間差。 注意:此處使用全域定義的 Stop 物件作為鍵。""" 169 | self._delta_time_table = { 170 | stops["M1"]: {stops["M2"]: 1}, 171 | stops["M2"]: {stops["M1"]: 1, stops["M3"]: 1, stops["M4"]: 3}, 172 | stops["M3"]: {stops["M2"]: 1, stops["M4"]: 2, stops["M6"]: 1}, 173 | stops["M4"]: {stops["M2"]: 3, stops["M3"]: 2, stops["M5"]: 2}, 174 | stops["M5"]: {stops["M4"]: 2, stops["M7"]: 1, stops["S1"]: 15}, 175 | stops["M6"]: {stops["M2"]: 2, stops["M3"]: 1, stops["M7"]: 2}, 176 | stops["M7"]: {stops["M5"]: 1, stops["M6"]: 2}, 177 | stops["S1"]: {stops["M5"]: 15}, 178 | } 179 | 180 | def gen_accumulated_time(self) -> list[int]: 181 | """ 182 | 計算路線上每個站點的累積時間。 183 | 184 | Returns: 185 | list[int]: 包含每個站點累積時間的列表,第一個站點時間為 0 分鐘。 186 | """ 187 | acc_times = [0] 188 | for i in range(len(self.stops) - 1): 189 | acc_times.append( 190 | acc_times[i] + self._delta_time_table[self.stops[i]][self.stops[i + 1]] 191 | ) 192 | return acc_times 193 | 194 | 195 | # 站點資料 196 | M1 = Stop("北校門口", "North Main Gate", "24.79589", "120.99633") 197 | M2 = Stop("綜二館", "General Building II", "24.794176", "120.99376") 198 | M3 = Stop("楓林小徑", "Maple Path", "24.791388889", "120.991388889") 199 | M4 = Stop("人社院/生科館", "CHSS/CLS Building", "24.79", "120.990277778") 200 | M5 = Stop("台積館", "TSMC Building", "24.78695", "120.9884") 201 | M6 = Stop( 202 | "奕園停車場", "Yi Pavilion Parking Lot", "24.788284441920126", "120.99246131713849" 203 | ) 204 | M7 = Stop("南門停車場", "South Gate Parking Lot", "24.7859395", "120.9901396") 205 | S1 = Stop( 206 | "南大校區校門口右側(食品路校牆邊)", 207 | "The right side of NandaCampus front gate(Shipin Road)", 208 | "24.79438267696105", 209 | "120.965382976675", 210 | ) 211 | stops: dict[str, Stop] = { 212 | "M1": M1, 213 | "M2": M2, 214 | "M3": M3, 215 | "M4": M4, 216 | "M5": M5, 217 | "M6": M6, 218 | "M7": M7, 219 | "S1": S1, 220 | } 221 | stop_name_mapping: dict[str, Stop] = {stop.name: stop for stop in stops.values()} 222 | 223 | # 清大路網圖 (單位:分鐘) 224 | # M4 225 | # 1 1 2/ \2 15 226 | # M1 --- M2 --- M3 M5 ---- S1 227 | # 1\ /1 228 | # M6 --- M7 229 | 230 | # 紅線 231 | red_M1_M5 = Route([M1, M2, M3, M4, M5]) # 北校門往台積館 232 | red_M5_M1 = Route([M5, M7, M6, M2, M1]) # 台積館往北校門 233 | red_M2_M5 = Route([M2, M3, M4, M5]) # 綜二館往台積館 234 | red_M5_M2 = Route([M5, M7, M6, M2]) # 台積館往綜二館 235 | 236 | # 綠線 237 | green_M1_M5 = Route([M1, M2, M3, M6, M7, M5]) # 北校門往台積館 238 | green_M5_M1 = Route([M5, M4, M2, M1]) # 台積館往北校門 239 | green_M2_M5 = Route([M2, M3, M6, M7, M5]) # 綜二館往台積館 240 | green_M5_M2 = Route([M5, M4, M2]) # 台積館往綜二館 241 | 242 | # 校區區間車 243 | nanda_M1_S1 = Route([M1, M2, M4, M5, S1]) # 南大校區校門口右側往北校門 244 | nanda_S1_M1 = Route([S1, M5, M4, M2, M1]) # 北校門往南大校區校門口右側 245 | 246 | 247 | class Buses: 248 | """ 249 | 校園公車時刻表管理類別。 250 | 251 | 提供校園公車時刻表的資料獲取、處理和查詢功能。 252 | 資料來源為清華大學提供的 JSON 格式公車時刻表 API。 253 | 支援校本部公車和南大校區區間車時刻表,並提供站點資訊查詢。 254 | 255 | 資料會定期從遠端 JSON 端點更新,以確保資訊的即時性。 256 | 257 | Attributes: 258 | raw_schedule_data (pd.DataFrame): 原始公車時刻表資料,以 DataFrame 儲存,索引為多層索引 (BUS_TYPE, BUS_DAY, BUS_DIRECTION)。 259 | detailed_schedule_data (pd.DataFrame): 詳細公車時刻表資料,包含停靠站點時間等詳細資訊。 260 | info_data (pd.DataFrame): 公車路線資訊,例如首末班車時間、發車間隔等。 261 | _last_updated_time (Optional[float]): 上次資料更新時間戳記,用於 TTL 快取機制。 262 | """ 263 | 264 | def __init__(self) -> None: 265 | """ 266 | 初始化 Buses 類別。 267 | 268 | 初始化各項資料 DataFrame,並載入公車時刻表資料。 269 | 若快取資料過期或尚未初始化,則會從遠端端點重新獲取資料。 270 | """ 271 | self.raw_schedule_data = pd.DataFrame( 272 | {"data": [[] for _ in range(len(schedule_index))]}, index=schedule_index 273 | ) 274 | self.detailed_schedule_data = pd.DataFrame( 275 | {"data": [[] for _ in range(len(schedule_index))]}, index=schedule_index 276 | ) 277 | self.info_data = pd.DataFrame( 278 | { 279 | "data": [ 280 | [] 281 | for _ in range(len(BUS_ROUTE_TYPE_WITHOUT_ALL) * len(BUS_DIRECTION)) 282 | ] 283 | }, 284 | index=pd.MultiIndex.from_product( 285 | [BUS_ROUTE_TYPE_WITHOUT_ALL, BUS_DIRECTION] 286 | ), 287 | ) 288 | self.last_commit_hash = None 289 | 290 | self._res_json: dict = {} # 儲存原始 JSON 回應資料 291 | self._start_from_gen_2_bus_info: list[str] = [] # 記錄從綜二館發車的班次資訊 292 | self._last_updated_time: Optional[float] = None # 上次資料更新時間戳記 293 | 294 | async def _process_bus_data(self) -> None: 295 | """ 296 | 處理從 JSON API 獲取的公車時刻表資料。 297 | 298 | 此方法負責解析 JSON 資料,並將資料填入 `info_data`、`raw_schedule_data` 和 `detailed_schedule_data` 等 DataFrame 中。 299 | 同時會呼叫 `gen_bus_detailed_schedule_and_update_stops_data` 方法產生詳細時刻表並更新站點資訊。 300 | """ 301 | self._populate_info_data() 302 | self._populate_raw_schedule_data() 303 | self._add_fields_to_raw_schedule_data() 304 | await self.gen_bus_detailed_schedule_and_update_stops_data() 305 | 306 | def _populate_info_data(self) -> None: 307 | """ 308 | 將原始 JSON 資料填入 info_data DataFrame。 309 | 310 | 遍歷 BUS_TYPE_WITHOUT_ALL, BUS_DIRECTION 的所有組合,從 JSON 資料中提取對應的路線資訊, 311 | 並存儲到 info_data DataFrame 中。 312 | """ 313 | for route_type, direction in product(BUS_ROUTE_TYPE_WITHOUT_ALL, BUS_DIRECTION): 314 | info_key = f"toward{self.transform_toward_name(route_type, direction)}Info" 315 | info_data = self._res_json.get(info_key, {}) 316 | self.info_data.loc[(route_type, direction), "data"] = [info_data] 317 | 318 | def _populate_raw_schedule_data(self) -> None: 319 | """ 320 | 將原始 JSON 資料填入 raw_schedule_data DataFrame。 321 | 322 | 遍歷 BUS_TYPE_WITHOUT_ALL, BUS_DAY, BUS_DIRECTION 的所有組合,從 JSON 資料中提取對應的時刻表資料, 323 | 並存儲到 raw_schedule_data DataFrame 中。 324 | """ 325 | for route_type, day, direction in product( 326 | BUS_ROUTE_TYPE_WITHOUT_ALL, BUS_DAY, BUS_DIRECTION 327 | ): 328 | schedule_key = f"{day}BusScheduleToward{self.transform_toward_name(route_type, direction)}" 329 | schedule_data = self._res_json.get(schedule_key, []) 330 | self.raw_schedule_data.loc[(route_type, day, direction), "data"] = ( 331 | schedule_data 332 | ) 333 | gen_the_all_field(self.raw_schedule_data, ["time"]) # 合併與排序 'all' 欄位 334 | 335 | def _classify_bus_type( 336 | self, route_type: str, day: str, description: str 337 | ) -> schemas.buses.BusType: 338 | """ 339 | 根據公車路線類型、時刻表註解和日期分類,判斷公車類型。 340 | 341 | Args: 342 | route_type (str): 公車路線類型,'main' 代表校本部公車,'nanda' 代表南大區間車。 343 | day (str): 日期類型,'weekday' 代表平日,'weekend' 代表假日。 344 | description (str): 時刻表註解,用於判斷公車類型。 345 | 346 | Returns: 347 | schemas.buses.BusType: 公車類型,包含 'route_83', 'large-sized_bus', 'middle-sized_bus'。 348 | """ 349 | # 優先判斷是否為 83 路 350 | if route_type == "nanda" and "83" in description: 351 | return schemas.buses.BusType.route_83 352 | 353 | # 校內公車註解中包含 "大" 或南大平日,為大型巴士 354 | elif (route_type == "main" and "大" in description) or ( 355 | route_type == "nanda" and day == "weekday" 356 | ): 357 | return schemas.buses.BusType.large_sized_bus 358 | 359 | # 其他校內和校區區間公車為中型巴士 360 | else: 361 | return schemas.buses.BusType.middle_sized_bus 362 | 363 | def _add_fields_to_raw_schedule_data(self) -> None: 364 | """ 365 | 將 raw_schedule_data DataFrame 中的時刻表資料添加額外的欄位。 366 | - 加入 `bus_type` 欄位。 367 | - 若 `route_type` 為 "nanda",同時新增 `dep_stop`。 368 | """ 369 | for route_type, day, direction in product( 370 | BUS_ROUTE_TYPE_WITHOUT_ALL, BUS_DAY, BUS_DIRECTION 371 | ): 372 | for item in self.raw_schedule_data.loc[ 373 | (route_type, day, direction), "data" 374 | ]: 375 | # 新增 bus_type 欄位 376 | item["bus_type"] = self._classify_bus_type( 377 | route_type, day, item["description"] 378 | ) 379 | 380 | # nanda 新增 dep_stop 欄位 381 | if route_type == "nanda": 382 | item["dep_stop"] = "校門" if direction == "up" else "南大" 383 | 384 | def transform_toward_name( 385 | self, route: Literal["main", "nanda"], direction: Literal["up", "down"] 386 | ) -> str: 387 | """ 388 | 轉換路線與方向名稱為 JSON 資料中使用的名稱格式。 389 | 390 | Args: 391 | route (Literal["main", "nanda"]): 路線類型,'main' 代表校本部公車,'nanda' 代表南大區間車。 392 | direction (Literal["up", "down"]): 行車方向,'up' 代表往特定方向 (如台積館、南大校區),'down' 代表往反方向 (如校門口、校本部)。 393 | 394 | Returns: 395 | str: 轉換後的名稱字串,用於在 JSON 資料中查找對應的鍵。 396 | """ 397 | trans_list = { 398 | ("main", "up"): "TSMCBuilding", 399 | ("main", "down"): "MainGate", 400 | ("nanda", "up"): "SouthCampus", 401 | ("nanda", "down"): "MainCampus", 402 | } 403 | return trans_list[(route, direction)] 404 | 405 | def _get_route_data(self, route_type: Literal["main", "nanda"]) -> dict: 406 | """ 407 | 獲取特定路線類型的公車相關資料。 408 | 409 | Args: 410 | route_type (Literal["main", "nanda"]): 路線類型,'main' 代表校本部公車,'nanda' 代表南大區間車。 411 | 412 | Returns: 413 | dict: 包含指定路線類型的公車資訊和時刻表的字典。 414 | """ 415 | # 根據路線類型設置不同的向位名稱 416 | if route_type == "main": 417 | up_name = "TSMC_building" 418 | down_name = "main_gate" 419 | else: # route_type == "nanda" 420 | up_name = "south_campus" 421 | down_name = "main_campus" 422 | 423 | return { 424 | f"toward_{up_name}_info": self.info_data.loc[(route_type, "up"), "data"][0], 425 | f"weekday_bus_schedule_toward_{up_name}": self.raw_schedule_data.loc[ 426 | (route_type, "weekday", "up"), "data" 427 | ], 428 | f"weekend_bus_schedule_toward_{up_name}": self.raw_schedule_data.loc[ 429 | (route_type, "weekend", "up"), "data" 430 | ], 431 | f"toward_{down_name}_info": self.info_data.loc[ 432 | (route_type, "down"), "data" 433 | ][0], 434 | f"weekday_bus_schedule_toward_{down_name}": self.raw_schedule_data.loc[ 435 | (route_type, "weekday", "down"), "data" 436 | ], 437 | f"weekend_bus_schedule_toward_{down_name}": self.raw_schedule_data.loc[ 438 | (route_type, "weekend", "down"), "data" 439 | ], 440 | } 441 | 442 | def get_main_data(self) -> dict: 443 | """ 444 | 取得校本部公車相關資料。 445 | 446 | Returns: 447 | dict: 包含校本部公車路線資訊和時刻表的字典,鍵值包含 'toward_TSMC_building_info', 'weekday_bus_schedule_toward_TSMC_building' 等。 448 | """ 449 | return self._get_route_data("main") 450 | 451 | def get_nanda_data(self) -> dict: 452 | """ 453 | 取得南大校區區間車相關資料。 454 | 455 | Returns: 456 | dict: 包含南大校區區間車路線資訊和時刻表的字典,鍵值包含 'toward_south_campus_info', 'weekday_bus_schedule_toward_south_campus' 等。 457 | """ 458 | return self._get_route_data("nanda") 459 | 460 | def _reset_stop_data(self) -> None: 461 | """重新初始化所有 Stop 物件的 stopped_bus DataFrame,用於更新站點公車資訊。""" 462 | for stop in stops.values(): 463 | stop.stopped_bus = pd.DataFrame( 464 | {"data": [[] for _ in range(len(schedule_index))]}, 465 | index=schedule_index, 466 | ) 467 | 468 | async def update_data(self) -> None: 469 | """更新公車時刻表資料,包含從 API 獲取最新資料並重新處理。""" 470 | # asyncio.gather(self._init_task) # 等待初始化任務完成 471 | 472 | res_commit_hash, self._res_json = await nthudata.get( 473 | "buses.json" 474 | ) # 直接更新 _res_json,後續處理會使用最新的 json 資料 475 | 476 | if ( 477 | self._res_json and res_commit_hash != self.last_commit_hash 478 | ): # 只有成功獲取資料且資料不一致時才需要重新處理 479 | await self._process_bus_data() 480 | self.last_commit_hash = res_commit_hash 481 | self._start_from_gen_2_bus_info.clear() # 清空從綜二館發車的班次資訊快取 482 | 483 | def _add_on_time(self, start_time: str, time_delta: int) -> str: 484 | """ 485 | 在指定時間字串上增加分鐘數。 486 | 487 | Args: 488 | start_time (str): 開始時間字串,格式為 '%H:%M'。 489 | time_delta (int): 要增加的分鐘數。 490 | 491 | Returns: 492 | str: 增加分鐘數後的時間字串,格式為 '%H:%M'。 493 | """ 494 | st = datetime.strptime(start_time, "%H:%M") + timedelta(minutes=time_delta) 495 | return st.strftime("%H:%M") 496 | 497 | def _find_stop_from_str(self, stop_str: str) -> Optional[Stop]: 498 | """ 499 | 根據站點名稱字串查找對應的 Stop 物件。 500 | 501 | Args: 502 | stop_str (str): 站點名稱字串 (中文)。 503 | 504 | Returns: 505 | Optional[Stop]: 若找到對應的 Stop 物件則返回,否則返回 None。 506 | """ 507 | return stop_name_mapping.get(stop_str) 508 | 509 | def _route_selector( 510 | self, dep_stop: str, line: str, from_gen_2: bool = False 511 | ) -> Optional[Route]: 512 | """ 513 | 根據出發站點、路線代碼和是否從綜二館發車,選擇對應的 Route 物件。 514 | 515 | Args: 516 | dep_stop (str): 出發站點名稱字串 (如 "台積館", "校門", "綜二")。 517 | line (str): 路線代碼 (如 "red", "green")。 518 | from_gen_2 (bool): 是否從綜二館發車,僅對校本部公車紅綠線有效。 519 | 520 | Returns: 521 | Optional[Route]: 若找到對應的 Route 物件則返回,否則返回 None。 522 | """ 523 | dep_stop, line = dep_stop.strip(), line.strip() 524 | stops_lines_map: dict[tuple, Route] = { 525 | ("台積館", "red", True): red_M5_M2, 526 | ("台積館", "red", False): red_M5_M1, 527 | ("台積館", "green", True): green_M5_M2, 528 | ("台積館", "green", False): green_M5_M1, 529 | ("校門", "red"): red_M1_M5, 530 | ("綜二", "red"): red_M2_M5, 531 | ("校門", "green"): green_M1_M5, 532 | ("綜二", "green"): green_M2_M5, 533 | } 534 | key = ( 535 | (dep_stop, line) if "台積" not in dep_stop else (dep_stop, line, from_gen_2) 536 | ) 537 | return stops_lines_map.get(key) 538 | 539 | def _gen_detailed_bus_schedule( 540 | self, 541 | bus_schedule: list[dict], 542 | *, 543 | route_type: Literal["main", "nanda"] = "main", 544 | day: Literal["weekday", "weekend"] = "weekday", 545 | direction: Literal["up", "down"] = "up", 546 | ) -> list[dict]: 547 | """ 548 | 產生詳細的公車時刻表,包含每個班次在每個站點的抵達時間。 549 | 550 | Args: 551 | bus_schedule (list[dict]): 原始公車時刻表資料,包含發車時間、路線等資訊。 552 | route_type (Literal["main", "nanda"]): 公車類型,'main' 為校本部, 'nanda' 為南大區間車,預設為 'main'。 553 | day (Literal["weekday", "weekend"]): 平日或假日時刻表,預設為 'weekday'。 554 | direction (Literal["up", "down"]): 行車方向,預設為 'up'。 555 | 556 | Returns: 557 | list[dict]: 詳細公車時刻表列表,每個元素為一個字典,包含班次資訊和每個停靠站點的抵達時間。 558 | """ 559 | detailed_schedules: list[dict] = [] 560 | for bus in bus_schedule: 561 | detailed_bus_schedule = self._process_single_bus_schedule( 562 | bus, route_type=route_type, day=day, direction=direction 563 | ) 564 | detailed_schedules.append(detailed_bus_schedule) 565 | return detailed_schedules 566 | 567 | def _process_single_bus_schedule( 568 | self, 569 | bus: dict, 570 | *, 571 | route_type: Literal["main", "nanda"], 572 | day: Literal["weekday", "weekend"], 573 | direction: Literal["up", "down"], 574 | ) -> dict: 575 | """ 576 | 處理單個公車班次的時刻表,生成包含停靠站點時間的詳細資訊。 577 | 578 | Args: 579 | bus (dict): 單個公車班次的原始時刻表資料。 580 | route_type (Literal["main", "nanda"]): 公車類型。 581 | day (Literal["weekday", "weekend"]): 平日或假日。 582 | direction (Literal["up", "down"]): 行車方向。 583 | 584 | Returns: 585 | dict: 包含單個公車班次詳細時刻表的字典,包含班次資訊和每個停靠站點的抵達時間。 586 | """ 587 | temp_bus: dict[str, Any] = {"dep_info": bus, "stops_time": []} 588 | route: Optional[Route] = self._select_bus_route( 589 | bus, route_type=route_type, day=day, direction=direction 590 | ) 591 | 592 | if route: 593 | self._populate_stop_times_and_update_stop_data( 594 | temp_bus, 595 | bus, 596 | route, 597 | route_type=route_type, 598 | day=day, 599 | direction=direction, 600 | ) 601 | return temp_bus 602 | 603 | def _select_bus_route( 604 | self, 605 | bus: dict, 606 | *, 607 | route_type: Literal["main", "nanda"], 608 | day: Literal["weekday", "weekend"], 609 | direction: Literal["up", "down"], 610 | ) -> Optional[Route]: 611 | """ 612 | 根據公車資訊選擇對應的公車路線。 613 | 614 | Args: 615 | bus (dict): 單個公車班次的原始時刻表資料。 616 | route_type (Literal["main", "nanda"]): 公車類型。 617 | direction (Literal["up", "down"]): 行車方向。 618 | 619 | Returns: 620 | Optional[Route]: 若找到對應的 Route 物件則返回,否則返回 None。 621 | """ 622 | if route_type == "main": 623 | return self._select_main_bus_route(bus) 624 | elif route_type == "nanda": 625 | return self._select_nanda_bus_route(direction) 626 | return None 627 | 628 | def _select_main_bus_route(self, bus: dict) -> Optional[Route]: 629 | """ 630 | 選擇校本部公車路線。 631 | 632 | 根據公車資訊中的出發站點和路線代碼,以及是否從綜二館發車的資訊,選擇對應的 Route 物件。 633 | 634 | Args: 635 | bus (dict): 單個校本部公車班次的原始時刻表資料。 636 | 637 | Returns: 638 | Optional[Route]: 若找到對應的 Route 物件則返回,否則返回 None。 639 | """ 640 | dep_stop = bus.get("dep_stop", "") 641 | line = bus.get("line", "") 642 | dep_from_gen_2 = self._is_departure_from_gen_2(bus, line) 643 | if "綜二" in dep_stop: 644 | self._record_gen_2_departure_time(bus, line) 645 | return self._route_selector(dep_stop, line, dep_from_gen_2) 646 | 647 | def _select_nanda_bus_route( 648 | self, direction: Literal["up", "down"] 649 | ) -> Optional[Route]: 650 | """ 651 | 選擇南大校區區間車路線。 652 | 653 | 根據行車方向選擇對應的南大校區區間車 Route 物件。 654 | 655 | Args: 656 | direction (Literal["up", "down"]): 行車方向。 657 | 658 | Returns: 659 | Optional[Route]: 若找到對應的 Route 物件則返回,否則返回 None。 660 | """ 661 | return nanda_M1_S1 if direction == "up" else nanda_S1_M1 662 | 663 | def _is_departure_from_gen_2(self, bus: dict, line: str) -> bool: 664 | """ 665 | 判斷公車是否從綜二館發車。 666 | 667 | 檢查公車資訊中的時間和路線代碼是否在記錄的從綜二館發車的班次資訊列表中。 668 | 669 | Args: 670 | bus (dict): 單個公車班次的原始時刻表資料。 671 | line (str): 路線代碼。 672 | 673 | Returns: 674 | bool: 若公車從綜二館發車則返回 True,否則返回 False。 675 | """ 676 | bus_identifier = bus["time"] + line 677 | return bus_identifier in self._start_from_gen_2_bus_info or ( 678 | "0" + bus["time"] + line in self._start_from_gen_2_bus_info 679 | ) 680 | 681 | def _record_gen_2_departure_time(self, bus: dict, line: str) -> None: 682 | """ 683 | 記錄從綜二館發車的班次資訊。 684 | 685 | 將從綜二館發車的班次時間和路線代碼加入到 `_start_from_gen_2_bus_info` 列表中,用於後續判斷是否從綜二館發車。 686 | 並在發車時間上增加 7 分鐘,作為後續站點時間計算的基準。 687 | 688 | Args: 689 | bus (dict): 單個公車班次的原始時刻表資料。 690 | line (str): 路線代碼。 691 | """ 692 | self._start_from_gen_2_bus_info.append(self._add_on_time(bus["time"], 7) + line) 693 | 694 | def _populate_stop_times_and_update_stop_data( 695 | self, 696 | temp_bus: dict, 697 | bus: dict, 698 | route: Route, 699 | *, 700 | route_type: Literal["main", "nanda"], 701 | day: Literal["weekday", "weekend"], 702 | direction: Literal["up", "down"], 703 | ) -> None: 704 | """ 705 | 填充公車班次在每個站點的抵達時間,並更新站點的停靠公車資料。 706 | 707 | Args: 708 | temp_bus (dict): 儲存單個公車班次詳細時刻表的字典。 709 | bus (dict): 單個公車班次的原始時刻表資料。 710 | route (Route): 公車路線物件。 711 | route_type (Literal["main", "nanda"]): 公車類型。 712 | day (Literal["weekday", "weekend"]): 平日或假日。 713 | direction (Literal["up", "down"]): 行車方向。 714 | """ 715 | acc_times = route.gen_accumulated_time() 716 | for idx, stop in enumerate(route.stops): 717 | arrive_time = self._add_on_time(bus["time"], acc_times[idx]) 718 | self._update_stop_stopped_bus_data( 719 | stop, 720 | bus, 721 | arrive_time, 722 | route_type=route_type, 723 | day=day, 724 | direction=direction, 725 | ) 726 | temp_bus["stops_time"].append( 727 | { 728 | "stop_name": stop.name, 729 | "time": arrive_time, 730 | } 731 | ) 732 | 733 | def _update_stop_stopped_bus_data( 734 | self, 735 | stop: Stop, 736 | bus: dict, 737 | arrive_time: str, 738 | *, 739 | route_type: Literal["main", "nanda"], 740 | day: Literal["weekday", "weekend"], 741 | direction: Literal["up", "down"], 742 | ) -> None: 743 | """ 744 | 更新單個站點的停靠公車資料。 745 | 746 | 將公車班次資訊和抵達時間添加到指定站點的 `stopped_bus` DataFrame 中。 747 | 748 | Args: 749 | stop (Stop): 公車站點物件。 750 | bus (dict): 單個公車班次的原始時刻表資料。 751 | arrive_time (str): 公車班次在該站點的抵達時間。 752 | route_type (Literal["main", "nanda"]): 公車類型。 753 | day (Literal["weekday", "weekend"]): 平日或假日。 754 | direction (Literal["up", "down"]): 行車方向。 755 | """ 756 | stop_obj = self._find_stop_from_str(stop.name) 757 | if stop_obj: 758 | stop_obj.stopped_bus.loc[(route_type, day, direction), "data"].append( 759 | { 760 | "bus_info": bus, 761 | "arrive_time": arrive_time, 762 | } 763 | ) 764 | 765 | async def gen_bus_detailed_schedule_and_update_stops_data(self) -> None: 766 | """ 767 | 產生詳細公車時刻表並更新各站點的停靠公車資訊。 768 | 769 | 此方法會呼叫 `_update_data()` 更新資料,並同時更新 `detailed_schedule_data` 與各 `Stop` 物件的 `stopped_bus` 屬性。 770 | 產生詳細時刻表的過程會計算每個班次在每個站點的預計抵達時間。 771 | """ 772 | self._reset_stop_data() # 清空站點的停靠公車資料,準備重新計算 773 | # await self.update_data() # 又被移回來了:D ~~資料更新移至 _process_bus_data 中處理~~ 774 | 775 | for route_type, day, direction in product( 776 | BUS_ROUTE_TYPE_WITHOUT_ALL, BUS_DAY_WITHOUT_ALL, BUS_DIRECTION 777 | ): 778 | self.detailed_schedule_data.loc[(route_type, day, direction), "data"] = ( 779 | self._gen_detailed_bus_schedule( 780 | self.raw_schedule_data.loc[(route_type, day, direction), "data"], 781 | route_type=route_type, 782 | day=day, 783 | direction=direction, 784 | ) 785 | ) 786 | 787 | gen_the_all_field( 788 | self.detailed_schedule_data, ["dep_info", "time"] 789 | ) # 合併與排序 detailed_schedule_data 的 'all' 欄位 790 | for stop in stops.values(): 791 | gen_the_all_field( 792 | stop.stopped_bus, ["arrive_time"] 793 | ) # 合併與排序每個站點 stopped_bus 的 'all' 欄位 794 | 795 | def gen_bus_stops_info(self) -> list[dict]: 796 | """ 797 | 產生公車站點資訊列表。 798 | 799 | Returns: 800 | list[dict]: 包含所有公車站點資訊的列表,每個元素為一個字典,包含 'stop_name', 'stop_name_en', 'latitude', 'longitude' 鍵值。 801 | """ 802 | return [ 803 | { 804 | "stop_name": stop.name, 805 | "stop_name_en": stop.name_en, 806 | "latitude": stop.latitude, 807 | "longitude": stop.longitude, 808 | } 809 | for stop in stops.values() 810 | ] 811 | -------------------------------------------------------------------------------- /src/api/models/courses.py: -------------------------------------------------------------------------------- 1 | import operator 2 | import re 3 | from dataclasses import dataclass, field 4 | from typing import Any, Optional, Union 5 | 6 | from src.utils import nthudata 7 | 8 | 9 | # ============================================================================= 10 | # 課程資料類別 11 | # ============================================================================= 12 | @dataclass 13 | class CoursesData: 14 | id: str 15 | chinese_title: str 16 | english_title: str 17 | credit: str 18 | size_limit: str 19 | freshman_reservation: str 20 | object: str 21 | ge_type: str 22 | language: str 23 | note: str 24 | suspend: str 25 | class_room_and_time: str 26 | teacher: str 27 | prerequisite: str 28 | limit_note: str 29 | expertise: str 30 | program: str 31 | no_extra_selection: str 32 | required_optional_note: str 33 | 34 | # 定義各個欄位可能出現的關鍵字(同義字) 35 | FIELD_MAPPING = { 36 | "id": ["科號", "ID", "id"], 37 | "chinese_title": ["課程中文名稱", "CHINESE_TITLE", "chinese_title"], 38 | "english_title": ["課程英文名稱", "ENGLISH_TITLE", "english_title"], 39 | "credit": ["學分數", "CREDIT", "credit"], 40 | "size_limit": ["人限", "SIZE_LIMIT", "size_limit"], 41 | "freshman_reservation": [ 42 | "新生保留人數", 43 | "FRESHMAN_RESERVATION", 44 | "freshman_reservation", 45 | ], 46 | "object": ["通識對象", "OBJECT", "object"], 47 | "ge_type": ["通識類別", "GE_TYPE", "ge_type"], 48 | "language": ["授課語言", "LANGUAGE", "language"], 49 | "note": ["備註", "NOTE", "note"], 50 | "suspend": ["停開註記", "SUSPEND", "suspend"], 51 | "class_room_and_time": [ 52 | "教室與上課時間", 53 | "CLASS_ROOM_AND_TIME", 54 | "class_room_and_time", 55 | ], 56 | "teacher": ["授課教師", "TEACHER", "teacher"], 57 | "prerequisite": ["擋修說明", "PREREQUISITE", "prerequisite"], 58 | "limit_note": ["課程限制說明", "LIMIT_NOTE", "limit_note"], 59 | "expertise": ["第一二專長對應", "EXPERTISE", "expertise"], 60 | "program": ["學分學程對應", "PROGRAM", "program"], 61 | "no_extra_selection": [ 62 | "不可加簽說明", 63 | "NO_EXTRA_SELECTION", 64 | "no_extra_selection", 65 | ], 66 | "required_optional_note": [ 67 | "必選修說明", 68 | "REQUIRED_OPTIONAL_NOTE", 69 | "required_optional_note", 70 | ], 71 | } 72 | 73 | @classmethod 74 | def from_dict(cls, init_data: dict) -> "CoursesData": 75 | """ 76 | 根據 FIELD_MAPPING 從原始資料中找出對應的欄位資料, 77 | 若找不到則給空字串。 78 | """ 79 | # 準備一個新的 dict,存放轉換後的資料 80 | data = {} 81 | for canonical_field, keywords in cls.FIELD_MAPPING.items(): 82 | # 尋找 init_data 中符合任一關鍵字的欄位 83 | found = False 84 | for key in keywords: 85 | if key in init_data: 86 | data[canonical_field] = init_data[key] 87 | found = True 88 | break 89 | if not found: 90 | data[canonical_field] = "" 91 | return cls(**data) 92 | 93 | def __repr__(self) -> str: 94 | return str(self.__dict__) 95 | 96 | 97 | # ============================================================================= 98 | # 條件判斷 99 | # ============================================================================= 100 | @dataclass 101 | class Condition: 102 | row_field: str 103 | matcher: Union[str, re.Pattern] 104 | regex_match: bool = False 105 | 106 | def __post_init__(self): 107 | # 統一使用小寫的欄位名稱以方便比對 108 | self.row_field = self.row_field.lower() 109 | 110 | def check(self, course: CoursesData) -> bool: 111 | field_data = getattr(course, self.row_field, "") 112 | if self.regex_match: 113 | return re.search(self.matcher, field_data) is not None 114 | else: 115 | return field_data == self.matcher 116 | 117 | 118 | # 複合條件的類型(支援 Condition、布林值或巢狀結構) 119 | ConditionType = Union[Condition, bool, dict, list] 120 | 121 | 122 | @dataclass 123 | class Conditions: 124 | # condition_stat 儲存條件樹,預設為單一條件結構, 125 | # 若傳入 list_build_target 則直接作為複合條件樹使用 126 | condition_stat: Any = field(default=None) 127 | course: Optional[CoursesData] = field(default=None, init=False) 128 | 129 | def __init__( 130 | self, 131 | row_field: Optional[str] = None, 132 | matcher: Optional[Union[str, re.Pattern]] = None, 133 | regex_match: bool = False, 134 | *, 135 | list_build_target: Optional[list[Any]] = None, 136 | ): 137 | if list_build_target is not None: 138 | self.condition_stat = list_build_target 139 | elif row_field is not None and matcher is not None: 140 | # 使用小寫的 canonical 欄位名稱 141 | self.condition_stat = [ 142 | Condition(row_field, matcher, regex_match), 143 | "and", 144 | True, 145 | ] 146 | else: 147 | raise ValueError("必須傳入 row_field 與 matcher,或 list_build_target") 148 | self.course = None 149 | 150 | def __and__(self, other: "Conditions") -> "Conditions": 151 | return Conditions( 152 | list_build_target=[self.condition_stat, "and", other.condition_stat] 153 | ) 154 | 155 | def __or__(self, other: "Conditions") -> "Conditions": 156 | return Conditions( 157 | list_build_target=[self.condition_stat, "or", other.condition_stat] 158 | ) 159 | 160 | def _solve_condition_stat(self, data: Any) -> bool: 161 | """ 162 | 遞迴拆解條件樹,結構固定為 [lhs, op, rhs], 163 | 其中 lhs 與 rhs 可能為 Condition、布林值或巢狀結構。 164 | """ 165 | if not isinstance(data, list): 166 | # 若 data 不是 list 則直接檢查條件 167 | return self._check_condition(data) 168 | 169 | if ( 170 | len(data) < 3 171 | ): # Handle cases with less than 3 elements, might be single condition or empty list 172 | if not data: 173 | return True # No condition means accept all 174 | return self._check_condition( 175 | data[0] 176 | ) # Assume single condition if list is short 177 | 178 | # Handle flat list of conditions and operators iteratively 179 | result = self._check_condition( 180 | data[0] 181 | ) # Initialize result with the first condition 182 | i = 1 183 | while i < len(data) - 1: 184 | op = data[i] 185 | next_condition = data[i + 1] 186 | next_condition_value = self._check_condition(next_condition) 187 | if op == "and": 188 | result = result and next_condition_value 189 | elif op == "or": 190 | result = result or next_condition_value 191 | else: 192 | raise ValueError(f"Unknown operator: {op}") 193 | i += 2 194 | return result 195 | 196 | def _check_condition(self, item: ConditionType) -> bool: 197 | if isinstance(item, dict): 198 | # 若為 dict,視為傳入 Condition 的關鍵字參數 199 | return Condition(**item).check(self.course) 200 | elif isinstance(item, list): 201 | return self._solve_condition_stat(item) 202 | elif isinstance(item, Condition): 203 | return item.check(self.course) 204 | elif isinstance(item, bool): 205 | return item 206 | else: 207 | raise TypeError(f"無法處理的條件項目:{item}") 208 | 209 | def accept(self, course: CoursesData) -> bool: 210 | """根據 condition_stat 判斷該課程是否滿足所有條件""" 211 | self.course = course 212 | return self._solve_condition_stat(self.condition_stat) 213 | 214 | 215 | # ============================================================================= 216 | # Processor - 課程資料處理器 217 | # ============================================================================= 218 | class Processor: 219 | """ 220 | 課程資料處理 221 | """ 222 | 223 | def __init__(self) -> None: 224 | self.course_data: list[CoursesData] = [] 225 | self.last_commit_hash = None 226 | 227 | async def update_data(self) -> None: 228 | self.last_commit_hash, self.course_data = await nthudata.get("courses.json") 229 | 230 | # 將 dict 轉換為 CoursesData 物件 231 | self.course_data = list(map(CoursesData.from_dict, self.course_data)) 232 | 233 | print(self.last_commit_hash) 234 | 235 | def list_selected_fields(self, field: str) -> list[str]: 236 | """回傳所有課程中指定欄位的非空字串集合""" 237 | fields_set = { 238 | getattr(course, field).strip() 239 | for course in self.course_data 240 | if getattr(course, field).strip() 241 | } 242 | return list(fields_set) 243 | 244 | def list_credit(self, credit: float, op: str = "") -> list[CoursesData]: 245 | ops = { 246 | "gt": operator.gt, 247 | "lt": operator.lt, 248 | "gte": operator.ge, 249 | "lte": operator.le, 250 | "eq": operator.eq, 251 | "": operator.eq, 252 | } 253 | cmp_op = ops.get(op, operator.eq) 254 | return [ 255 | course 256 | for course in self.course_data 257 | if cmp_op(float(course.credit), credit) 258 | ] 259 | 260 | def query(self, conditions: Conditions) -> list[CoursesData]: 261 | """搜尋所有符合條件的課程,傳入的 conditions 為複合條件樹""" 262 | return [course for course in self.course_data if conditions.accept(course)] 263 | 264 | 265 | # ============================================================================= 266 | # 主程式測試區 267 | # ============================================================================= 268 | if __name__ == "__main__": 269 | processor = Processor() 270 | 271 | async def test(): 272 | await processor.update_data() 273 | 274 | import asyncio 275 | 276 | asyncio.run(test()) 277 | 278 | # 範例資料(原始資料中使用中文欄位名稱) 279 | sample_data = { 280 | "科號": "11320AES 370100", 281 | "課程中文名稱": "環境科學與工程", 282 | "課程英文名稱": "Environmental Science and Engineering", 283 | "學分數": "3", 284 | "人限": "", 285 | "新生保留人數": "0", 286 | "通識對象": " ", 287 | "通識類別": "", 288 | "授課語言": "中", 289 | "備註": " ", 290 | "停開註記": "", 291 | "教室與上課時間": "BMES醫環501\tR7R8R9\n", 292 | "授課教師": "吳劍侯\tWU, CHIEN-HOU\n", 293 | "擋修說明": "", 294 | "課程限制說明": "", 295 | "第一二專長對應": "環境科技學程(第二專長)", 296 | "學分學程對應": "(跨領域)永續發展與環境管理學分學程", 297 | "不可加簽說明": "", 298 | "必選修說明": "分環所113M 選修\t原科院學士班111B 選修\t", 299 | } 300 | # 可用 sample_data 測試 from_dict: 301 | course_sample = CoursesData.from_dict(sample_data) 302 | print("轉換後的課程資料: {}".format(course_sample)) 303 | 304 | # 以下為原有的查詢條件範例 305 | # 範例 1:中文課名為 "文化人類學專題" 且課號為 "11210ANTH651000" 306 | condition1 = Conditions("chinese_title", "文化人類學專題") & Conditions( 307 | "id", "11210ANTH651000" 308 | ) 309 | result1 = processor.query(condition1) 310 | print("中文課名 與 ID (有一堂課): {}".format(len(result1))) 311 | print(result1) 312 | 313 | # 範例 2:中文課名為 "化人類學專題" 或課號為 "11210ANTH651000" 314 | condition2 = Conditions("chinese_title", "化人類學專題") | Conditions( 315 | "id", "11210ANTH651000" 316 | ) 317 | result2 = processor.query(condition2) 318 | print("中文課名 或 ID (有一堂課): {}".format(len(result2))) 319 | print(result2) 320 | 321 | # 範例 3:中文課名包含 "產業" 322 | condition3 = Conditions("chinese_title", "產業", regex_match=True) 323 | result3 = processor.query(condition3) 324 | print("中文課名包含 '產業' 的課程 (取前5筆): {}".format(result3[:5])) 325 | 326 | # 範例 4:中文課名包含 "產業" 且 credit 為 "2" 且課號包含 "GE"(通識課程) 327 | condition4 = ( 328 | Conditions("chinese_title", "產業", regex_match=True) 329 | & Conditions("credit", "2") 330 | & Conditions("id", "GE", regex_match=True) 331 | ) 332 | result4 = processor.query(condition4) 333 | print("符合複合條件的課程 (取前5筆): {}".format(result4[:5])) 334 | 335 | print("總開課數: {}".format(len(processor.course_data))) 336 | # 範例 5:中文授課 或 英文授課 337 | condition_ce = Conditions("language", "中") | Conditions("language", "英") 338 | result_ce = processor.query(condition_ce) 339 | print("中文授課 或 英文授課 開課數量: {}".format(len(result_ce))) 340 | 341 | # 範例 6:中文授課 342 | condition_c = Conditions("language", "中") 343 | result_c = processor.query(condition_c) 344 | print("中文授課 開課數量: {}".format(len(result_c))) 345 | 346 | # 範例 7:英文授課 347 | condition_e = Conditions("language", "英") 348 | result_e = processor.query(condition_e) 349 | print("英文授課 開課數量: {}".format(len(result_e))) 350 | 351 | # 範例 8:複雜三條件測試 (微積分, 4學分, 星期W) 352 | condition_complex = Conditions( 353 | list_build_target=[ 354 | {"row_field": "chinese_title", "matcher": "微積分", "regex_match": True}, 355 | "and", 356 | {"row_field": "credit", "matcher": "4", "regex_match": True}, 357 | "and", 358 | {"row_field": "class_room_and_time", "matcher": "T", "regex_match": True}, 359 | ] 360 | ) 361 | result_complex = processor.query(condition_complex) 362 | print("複雜條件測試 (微積分, 4學分, 星期W): {}".format(len(result_complex))) 363 | print("複雜條件測試結果 (前5筆): {}".format(result_complex[:5])) 364 | -------------------------------------------------------------------------------- /src/api/routers/__init__.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from fastapi import FastAPI, Request 4 | from fastapi.middleware.cors import CORSMiddleware 5 | 6 | from . import ( 7 | announcements, 8 | buses, 9 | courses, 10 | departments, 11 | dining, 12 | energy, 13 | libraries, 14 | locations, 15 | newsletters, 16 | ) 17 | 18 | app = FastAPI() 19 | 20 | # Using explicit origins would be safer, but for a public API that needs to be accessible from anywhere: 21 | origins = ["*"] # Allow all domains (Public API) 22 | 23 | app.add_middleware( 24 | CORSMiddleware, 25 | allow_origins=origins, # Allowed origin domains list 26 | allow_credentials=False, # Setting this to False when using wildcard origins 27 | allow_methods=[ 28 | "GET", 29 | "POST", 30 | "PUT", 31 | "DELETE", 32 | "OPTIONS", 33 | "PATCH", 34 | ], # Explicitly list allowed methods 35 | allow_headers=["*"], # Allow all HTTP headers 36 | expose_headers=[ 37 | "X-Process-Time", 38 | "X-Data-Commit-Hash", 39 | ], # Expose custom headers used by the API 40 | ) 41 | 42 | 43 | @app.middleware("http") 44 | async def add_process_time_header(request: Request, call_next): 45 | start_time = time.time() 46 | response = await call_next(request) 47 | process_time = time.time() - start_time 48 | response.headers["X-Process-Time"] = str(process_time) 49 | return response 50 | 51 | 52 | app.include_router( 53 | announcements.router, prefix="/announcements", tags=["Announcements"] 54 | ) 55 | app.include_router(buses.router, prefix="/buses", tags=["Buses"]) 56 | app.include_router(courses.router, prefix="/courses", tags=["Courses"]) 57 | app.include_router(departments.router, prefix="/departments", tags=["Departments"]) 58 | app.include_router(dining.router, prefix="/dining", tags=["Dining"]) 59 | app.include_router(energy.router, prefix="/energy", tags=["Energy"]) 60 | app.include_router(libraries.router, prefix="/libraries", tags=["Libraries"]) 61 | app.include_router(locations.router, prefix="/locations", tags=["Locations"]) 62 | app.include_router(newsletters.router, prefix="/newsletters", tags=["Newsletters"]) 63 | -------------------------------------------------------------------------------- /src/api/routers/announcements.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Query 2 | from thefuzz import fuzz 3 | 4 | from src.api.schemas.announcements import AnnouncementArticle, AnnouncementDetail 5 | from src.utils import nthudata 6 | 7 | router = APIRouter() 8 | json_path = "announcements.json" 9 | 10 | 11 | @router.get("/", response_model=list[AnnouncementDetail]) 12 | async def get_announcements( 13 | department: str = Query(None, description="部門名稱", example="清華公佈欄"), 14 | ): 15 | """ 16 | 取得校內每個處室的所有公告資訊。 17 | 資料來源:各處室網站 18 | """ 19 | _commit_hash, announcements_data = await nthudata.get(json_path) 20 | if department: 21 | announcements_data = [ 22 | announcement 23 | for announcement in announcements_data 24 | if announcement["department"] == department 25 | ] 26 | return announcements_data 27 | 28 | 29 | @router.get( 30 | "/search", 31 | response_model=list[AnnouncementArticle], 32 | ) 33 | async def fuzzy_search_announcement_titles( 34 | query: str = Query(..., example="中研院", description="要查詢的公告"), 35 | ): 36 | """ 37 | 使用名稱模糊搜尋全部公告的標題。 38 | """ 39 | _commit_hash, announcements_data = await nthudata.get(json_path) 40 | tmp_results = [] 41 | for announcement in announcements_data: 42 | articles = announcement.get("articles") 43 | if articles is None: 44 | continue 45 | for article in articles: 46 | similarity = fuzz.partial_ratio(query, article["title"]) 47 | if similarity >= 60: 48 | tmp_results.append( 49 | ( 50 | similarity, # 儲存相似度 51 | article, 52 | ) 53 | ) 54 | tmp_results.sort(key=lambda x: x[0], reverse=True) 55 | return [article for _, article in tmp_results] 56 | 57 | 58 | @router.get("/lists/departments", response_model=list[str]) 59 | async def list_announcement_departments(): 60 | """ 61 | 取得所有有公告的部門列表。 62 | """ 63 | _commit_hash, announcements_data = await nthudata.get(json_path) 64 | departments = set() 65 | for announcement in announcements_data: 66 | departments.add(announcement["department"]) 67 | return list(departments) 68 | -------------------------------------------------------------------------------- /src/api/routers/buses.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | from datetime import datetime 3 | from typing import Literal 4 | 5 | from fastapi import APIRouter, Depends, FastAPI, HTTPException, Response 6 | 7 | from src.api import constant 8 | from src.api.models.buses import Buses, after_specific_time, stops 9 | from src.api.schemas.buses import ( 10 | BusDayWithCurrent, 11 | BusDirection, 12 | BusInfo, 13 | BusMainData, 14 | BusMainDetailedSchedule, 15 | BusMainSchedule, 16 | BusNandaData, 17 | BusNandaDetailedSchedule, 18 | BusNandaSchedule, 19 | BusQuery, 20 | BusRouteType, 21 | BusStopsInfo, 22 | BusStopsName, 23 | BusStopsQueryResult, 24 | ) 25 | 26 | buses = Buses() 27 | 28 | 29 | @asynccontextmanager 30 | async def lifespan(app: FastAPI): 31 | """FastAPI 的生命週期管理器,用於在應用啟動時更新公車資料。""" 32 | global buses 33 | 34 | # tasks when app starts 35 | await buses.update_data() 36 | yield 37 | # tasks when app stops 38 | 39 | 40 | router = APIRouter(lifespan=lifespan) 41 | 42 | 43 | async def add_custom_header(response: Response): 44 | """添加 X-Data-Commit-Hash 標頭。 45 | 46 | Args: 47 | response: FastAPI 的 Response 對象。 48 | """ 49 | 50 | response.headers["X-Data-Commit-Hash"] = str(buses.last_commit_hash) 51 | 52 | 53 | def get_current_time_state(): 54 | """ 55 | 取得目前時間狀態 (星期別, 時間)。 56 | 57 | Returns: 58 | tuple[str, str]: 目前星期別 ('weekday' 或 'weekend') 和時間 ('HH:MM')。 59 | """ 60 | current = datetime.now() 61 | current_time = current.time().strftime("%H:%M") 62 | current_day = "weekday" if current.weekday() < 5 else "weekend" 63 | return current_day, current_time 64 | 65 | 66 | @router.get( 67 | "/main", 68 | response_model=BusMainData, 69 | dependencies=[Depends(add_custom_header)], 70 | ) 71 | async def get_main_campus_bus_data(): 72 | """ 73 | 取得校本部公車資訊。 74 | 資料來源:總務處事務組 75 | """ 76 | await buses.update_data() 77 | try: 78 | return buses.get_main_data() 79 | except Exception as e: 80 | raise HTTPException( 81 | status_code=500, detail=f"Failed to retrieve main bus data: {e}" 82 | ) 83 | 84 | 85 | @router.get( 86 | "/nanda", 87 | response_model=BusNandaData, 88 | dependencies=[Depends(add_custom_header)], 89 | ) 90 | async def get_nanda_campus_bus_data(): 91 | """ 92 | 取得南大校區區間車資訊。 93 | 資料來源:總務處事務組 94 | """ 95 | await buses.update_data() 96 | try: 97 | return buses.get_nanda_data() 98 | except Exception as e: 99 | raise HTTPException( 100 | status_code=500, detail=f"Failed to retrieve nanda bus data: {e}" 101 | ) 102 | 103 | 104 | @router.get( 105 | "/info/{bus_type}/{direction}", 106 | response_model=list[BusInfo], 107 | dependencies=[Depends(add_custom_header)], 108 | ) 109 | async def get_bus_route_information( 110 | bus_type: Literal["main", "nanda"] = constant.buses.BUS_TYPE_PATH, 111 | direction: BusDirection = constant.buses.BUS_DIRECTION_PATH, 112 | ): 113 | """ 114 | 取得指定公車路線的資訊。 115 | """ 116 | await buses.update_data() 117 | try: 118 | data = buses.info_data.loc[(bus_type, direction), "data"] 119 | return data 120 | except KeyError: 121 | raise HTTPException( 122 | status_code=404, 123 | detail=f"Bus info not found for type '{bus_type}' and direction '{direction}'.", 124 | ) 125 | except Exception as e: 126 | raise HTTPException( 127 | status_code=500, 128 | detail=f"Failed to retrieve bus info: {e}", 129 | ) 130 | 131 | 132 | @router.get( 133 | "/info/stops", 134 | response_model=list[BusStopsInfo], 135 | dependencies=[Depends(add_custom_header)], 136 | ) 137 | async def get_bus_stops_information(): 138 | """ 139 | 取得所有公車停靠站點的資訊。 140 | """ 141 | await buses.update_data() 142 | try: 143 | return buses.gen_bus_stops_info() 144 | except Exception as e: 145 | raise HTTPException( 146 | status_code=500, detail=f"Failed to retrieve bus stops info: {e}" 147 | ) 148 | 149 | 150 | @router.get( 151 | "/schedules", 152 | response_model=list[BusMainSchedule | BusNandaSchedule | None], 153 | dependencies=[Depends(add_custom_header)], 154 | ) 155 | async def get_bus_schedules( 156 | bus_type: BusRouteType = constant.buses.BUS_TYPE_QUERY, 157 | day: BusDayWithCurrent = constant.buses.BUS_DAY_QUERY, 158 | direction: BusDirection = constant.buses.BUS_DIRECTION_QUERY, 159 | ): 160 | """ 161 | 取得指定條件的公車時刻表。 162 | """ 163 | await buses.update_data() 164 | try: 165 | if day != "current": 166 | schedule_data = buses.raw_schedule_data.loc[ 167 | (bus_type, day, direction), "data" 168 | ] 169 | return schedule_data 170 | else: 171 | current_day, current_time = get_current_time_state() 172 | schedule_data = buses.raw_schedule_data.loc[ 173 | (bus_type, current_day, direction), "data" 174 | ] 175 | res = after_specific_time( 176 | schedule_data, 177 | current_time, 178 | ["time"], 179 | ) 180 | return res[: constant.buses.DEFAULT_LIMIT_DAY_CURRENT] 181 | except KeyError: 182 | raise HTTPException( 183 | status_code=404, 184 | detail=f"Schedule not found for type '{bus_type}', day '{day}', and direction '{direction}'.", 185 | ) 186 | except Exception as e: 187 | raise HTTPException( 188 | status_code=500, detail=f"Failed to retrieve bus schedule: {e}" 189 | ) 190 | 191 | 192 | @router.get( 193 | "/stops/{stop_name}", 194 | response_model=list[BusStopsQueryResult | None], 195 | dependencies=[Depends(add_custom_header)], 196 | ) 197 | async def get_stop_bus_information_by_stop( 198 | stop_name: BusStopsName = constant.buses.STOPS_NAME_PATH, 199 | bus_type: BusRouteType = constant.buses.BUS_TYPE_QUERY, 200 | day: BusDayWithCurrent = constant.buses.BUS_DAY_QUERY, 201 | direction: BusDirection = constant.buses.BUS_DIRECTION_QUERY, 202 | query: BusQuery = Depends(), 203 | ): 204 | """ 205 | 取得指定站點的公車停靠資訊。 206 | """ 207 | await buses.update_data() 208 | return_limit = ( 209 | query.limits 210 | if day != "current" 211 | else min(filter(None, (query.limits, constant.buses.DEFAULT_LIMIT_DAY_CURRENT))) 212 | ) 213 | find_day, after_time = ( 214 | (day, query.time) if day != "current" else get_current_time_state() 215 | ) 216 | 217 | try: 218 | stop_data = stops[stop_name.name].stopped_bus.loc[ 219 | (bus_type, find_day, direction), "data" 220 | ] 221 | res = after_specific_time( 222 | stop_data, 223 | after_time, 224 | ["arrive_time"], 225 | ) 226 | return res[:return_limit] 227 | 228 | except KeyError: 229 | raise HTTPException( 230 | status_code=404, 231 | detail=f"""No bus stop data found for stop '{stop_name}', type '{bus_type}', day '{day}', \ 232 | and direction '{direction}'.""", 233 | ) 234 | except Exception as e: 235 | raise HTTPException( 236 | status_code=500, 237 | detail=f"Failed to retrieve stop bus info: {e}", 238 | ) 239 | 240 | 241 | @router.get( 242 | "/detailed", 243 | response_model=list[BusMainDetailedSchedule | BusNandaDetailedSchedule | None], 244 | dependencies=[Depends(add_custom_header)], 245 | ) 246 | async def get_detailed_bus_schedule( 247 | bus_type: BusRouteType = constant.buses.BUS_TYPE_QUERY, 248 | day: BusDayWithCurrent = constant.buses.BUS_DAY_QUERY, 249 | direction: BusDirection = constant.buses.BUS_DIRECTION_QUERY, 250 | query: BusQuery = Depends(), 251 | ): 252 | """ 253 | 取得詳細公車時刻表,包含抵達各站時間。 254 | """ 255 | await buses.update_data() 256 | return_limit = ( 257 | query.limits 258 | if day != "current" 259 | else min(filter(None, (query.limits, constant.buses.DEFAULT_LIMIT_DAY_CURRENT))) 260 | ) 261 | find_day, after_time = ( 262 | (day, query.time) if day != "current" else get_current_time_state() 263 | ) 264 | 265 | try: 266 | detailed_schedule_data = buses.detailed_schedule_data.loc[ 267 | (bus_type, find_day, direction), "data" 268 | ] 269 | res = after_specific_time( 270 | detailed_schedule_data, 271 | after_time, 272 | ["dep_info", "time"], 273 | ) 274 | return res[:return_limit] 275 | except KeyError: 276 | raise HTTPException( 277 | status_code=404, 278 | detail=f"Detailed schedule not found for type '{bus_type}', day '{day}', and direction '{direction}'.", 279 | ) 280 | except Exception as e: 281 | raise HTTPException( 282 | status_code=500, 283 | detail=f"Failed to retrieve detailed bus schedule: {e}", 284 | ) 285 | -------------------------------------------------------------------------------- /src/api/routers/courses.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | 3 | from fastapi import APIRouter, Body, Depends, FastAPI, Query, Request, Response 4 | 5 | from src.api import schemas 6 | from src.api.models.courses import Conditions, Processor 7 | 8 | courses = Processor() 9 | 10 | 11 | @asynccontextmanager 12 | async def lifespan(app: FastAPI): 13 | """FastAPI 的生命週期管理器,用於在應用啟動時更新公車資料。""" 14 | global courses 15 | 16 | # tasks when app starts 17 | await courses.update_data() 18 | yield 19 | # tasks when app stops 20 | 21 | 22 | router = APIRouter(lifespan=lifespan) 23 | 24 | 25 | async def add_custom_header(response: Response): 26 | """添加 X-Data-Commit-Hash 標頭。 27 | 28 | Args: 29 | response: FastAPI 的 Response 對象。 30 | """ 31 | 32 | response.headers["X-Data-Commit-Hash"] = str(courses.last_commit_hash) 33 | 34 | 35 | @router.get( 36 | "/", 37 | response_model=list[schemas.courses.CourseData], 38 | dependencies=[Depends(add_custom_header)], 39 | ) 40 | async def get_all_courses( 41 | response: Response, 42 | ): 43 | """ 44 | 取得所有課程。 45 | 資料來源:[教務處課務組/JSON格式下載](https://curricul.site.nthu.edu.tw/p/406-1208-111356,r7883.php?Lang=zh-tw) 46 | """ 47 | result = courses.course_data 48 | response.headers["X-Total-Count"] = str(len(result)) 49 | response.headers["X-Data-Commit-Hash"] = str(courses.last_commit_hash) 50 | return result 51 | 52 | 53 | @router.get( 54 | "/search", 55 | response_model=list[schemas.courses.CourseData], 56 | dependencies=[Depends(add_custom_header)], 57 | ) 58 | async def search_courses_by_field_and_value( 59 | request: Request, 60 | response: Response, 61 | id: str = Query(None, description="課號"), 62 | chinese_title: str = Query(None, description="課程中文名稱"), 63 | english_title: str = Query(None, description="課程英文名稱"), 64 | credit: str = Query(None, description="學分數"), 65 | size_limit: str = Query(None, description="人限:若為空字串表示無人數限制"), 66 | freshman_reservation: str = Query( 67 | None, description="新生保留人數:若為0表示無新生保留人數" 68 | ), 69 | object: str = Query( 70 | None, 71 | description="通識對象:[代碼說明(課務組)](https://curricul.site.nthu.edu.tw/p/404-1208-11133.php)", 72 | ), 73 | ge_type: str = Query(None, description="通識類別"), 74 | language: schemas.courses.CourseLanguage = Query( 75 | None, description='授課語言:"中"、"英"' 76 | ), 77 | note: str = Query(None, description="備註"), 78 | suspend: str = Query(None, description='停開註記:"停開"或空字串'), 79 | class_room_and_time: str = Query( 80 | None, 81 | description="教室與上課時間:一間教室對應一個上課時間,中間以tab分隔;多個上課教室以new line字元分開", 82 | ), 83 | teacher: str = Query( 84 | None, 85 | description="授課教師:多位教師授課以new line字元分開;教師中英文姓名以tab分開", 86 | ), 87 | prerequisite: str = Query(None, description="擋修說明:會有html entities"), 88 | limit_note: str = Query(None, description="課程限制說明"), 89 | expertise: str = Query( 90 | None, description="第一二專長對應:對應多個專長用tab字元分隔" 91 | ), 92 | program: str = Query(None, description="學分學程對應:用半形/分隔"), 93 | no_extra_selection: str = Query(None, description="不可加簽說明"), 94 | required_optional_note: str = Query( 95 | None, description="必選修說明:多個必選修班級用tab字元分隔" 96 | ), 97 | ): 98 | """ 99 | 根據提供的欄位和值搜尋課程。 100 | - 使用欄位名稱作為查詢參數 101 | - 例如:/search?chinese_title=產業.+&english_title=... 102 | """ 103 | conditions = {} 104 | query_params = request.query_params # 從 Request 物件取得查詢參數 105 | 106 | for field_name in schemas.courses.CourseFieldName: 107 | field_value = query_params.get(field_name.value) 108 | if field_value: 109 | conditions[field_name] = field_value 110 | 111 | if conditions: 112 | condition_list = [] 113 | for name, value in conditions.items(): 114 | condition_list.append( 115 | { 116 | "row_field": name.value, 117 | "matcher": value, 118 | "regex_match": True, 119 | } 120 | ) 121 | if len(condition_list) > 1: 122 | combined_condition = [] 123 | for i in range(len(condition_list)): 124 | combined_condition.append(condition_list[i]) 125 | if i < len(condition_list) - 1: 126 | combined_condition.append("and") 127 | final_condition = Conditions(list_build_target=combined_condition) 128 | else: 129 | final_condition = Conditions(list_build_target=condition_list) 130 | result = courses.query(final_condition) 131 | else: 132 | # 沒有條件時,回傳空列表 133 | result = [] 134 | 135 | response.headers["X-Total-Count"] = str(len(result)) 136 | return result 137 | 138 | 139 | @router.post( 140 | "/search", 141 | response_model=list[schemas.courses.CourseData], 142 | dependencies=[Depends(add_custom_header)], 143 | ) 144 | async def search_courses_by_condition( 145 | query_condition: ( 146 | schemas.courses.CourseQueryCondition | schemas.courses.CourseCondition 147 | ) = Body( 148 | openapi_examples={ 149 | "normal_1": { 150 | "summary": "單一搜尋條件", 151 | "description": "只使用單一搜尋條件,類似於 GET 方法", 152 | "value": { 153 | "row_field": "chinese_title", 154 | "matcher": "數統導論", 155 | "regex_match": True, 156 | }, 157 | }, 158 | "normal_2": { 159 | "summary": "兩個搜尋條件", 160 | "description": "使用兩個搜尋條件,例如:黃姓老師 或 孫姓老師開設的課程", 161 | "value": [ 162 | { 163 | "row_field": "teacher", 164 | "matcher": "黃", 165 | "regex_match": True, 166 | }, 167 | "or", 168 | { 169 | "row_field": "teacher", 170 | "matcher": "孫", 171 | "regex_match": True, 172 | }, 173 | ], 174 | }, 175 | "normal_nested": { 176 | "summary": "多層搜尋條件", 177 | "description": "使用巢狀搜尋條件,例如:(3學分的課程) 且 ((統計所 或 數學系開設的課程) 且 (開課時間是T3T4 或 開課時間是R3R4))", 178 | "value": [ 179 | {"row_field": "credit", "matcher": "3", "regex_match": True}, 180 | "and", 181 | [ 182 | [ 183 | {"row_field": "id", "matcher": "STAT", "regex_match": True}, 184 | "or", 185 | {"row_field": "id", "matcher": "MATH", "regex_match": True}, 186 | ], 187 | "and", 188 | [ 189 | { 190 | "row_field": "class_room_and_time", 191 | "matcher": "T3T4", 192 | "regex_match": True, 193 | }, 194 | "or", 195 | { 196 | "row_field": "class_room_and_time", 197 | "matcher": "R3R4", 198 | "regex_match": True, 199 | }, 200 | ], 201 | ], 202 | ], 203 | }, 204 | "normal_flatten": { 205 | "summary": "多個搜尋條件 (flatten)", 206 | "description": "使用多個搜尋條件,例如:微積分 且 4學分 且 開課時間是T", 207 | "value": [ 208 | { 209 | "row_field": "chinese_title", 210 | "matcher": "微積分", 211 | "regex_match": True, 212 | }, 213 | "and", 214 | {"row_field": "credit", "matcher": "4", "regex_match": True}, 215 | "and", 216 | { 217 | "row_field": "class_room_and_time", 218 | "matcher": "T", 219 | "regex_match": True, 220 | }, 221 | ], 222 | }, 223 | } 224 | ), 225 | ): 226 | """ 227 | 根據條件取得課程。可以使用巢狀條件。 228 | """ 229 | if type(query_condition) is schemas.courses.CourseCondition: 230 | condition = Conditions( 231 | query_condition.row_field.value, 232 | query_condition.matcher, 233 | query_condition.regex_match, 234 | ) 235 | elif type(query_condition) is schemas.courses.CourseQueryCondition: 236 | # 設定 mode="json" 是為了讓 dump 出來的內容不包含 python 的實體 (instance) 237 | condition = Conditions( 238 | list_build_target=query_condition.model_dump(mode="json") 239 | ) 240 | result = courses.query(condition) 241 | return result 242 | 243 | 244 | @router.get( 245 | "/lists/{list_name}", 246 | response_model=list[schemas.courses.CourseData], 247 | dependencies=[Depends(add_custom_header)], 248 | ) 249 | async def list_courses_by_type( 250 | list_name: schemas.courses.CourseListName, 251 | response: Response, 252 | ) -> list[schemas.courses.CourseData]: 253 | """ 254 | 取得指定類型的課程列表。 255 | """ 256 | match list_name: 257 | case "microcredits": 258 | condition = Conditions("credit", "[0-9].[0-9]", True) 259 | case "xclass": 260 | condition = Conditions("note", "X-Class", True) 261 | result = courses.query(condition) 262 | response.headers["X-Total-Count"] = str(len(result)) 263 | return result 264 | -------------------------------------------------------------------------------- /src/api/routers/departments.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from fastapi import APIRouter, HTTPException, Query 4 | from thefuzz import fuzz 5 | 6 | from src.api.schemas.departments import Department, DepartmentPerson 7 | from src.utils import nthudata 8 | 9 | router = APIRouter() 10 | json_path = "directory.json" 11 | 12 | 13 | @router.get("/", response_model=list[Department]) 14 | async def get_all_departments(): 15 | """ 16 | 取得所有部門與人員資料。 17 | 資料來源:[清華通訊錄](https://tel.net.nthu.edu.tw/nthusearch/) 18 | """ 19 | _commit_hash, directory_data = await nthudata.get(json_path) 20 | return directory_data 21 | 22 | 23 | @router.get( 24 | "/search", 25 | response_model=dict[str, Union[list[Department], list[DepartmentPerson]]], 26 | ) 27 | async def fuzzy_search_departments_and_people( 28 | query: str = Query(..., example="校長"), 29 | ): 30 | """ 31 | 使用搜尋演算法搜尋全校部門與人員名稱。 32 | """ 33 | _commit_hash, directory_data = await nthudata.get(json_path) 34 | department_results = [] 35 | for dept in directory_data: 36 | similarity = fuzz.partial_ratio(query, dept["name"]) 37 | if similarity >= 60: # 相似度門檻值,可以調整 38 | dept["similarity_score"] = similarity # 加入相似度分數方便排序 39 | department_results.append(dept) 40 | department_results.sort( 41 | key=lambda x: x.get("similarity_score", 0), reverse=True 42 | ) # 根據相似度排序 43 | 44 | people_results = [] 45 | for dept in directory_data: 46 | for person in dept["details"]["people"]: 47 | similarity = fuzz.partial_ratio(query, person["name"]) 48 | if similarity >= 70: # 相似度門檻值,可以調整 49 | person["similarity_score"] = similarity 50 | people_results.append(person) 51 | people_results.sort( 52 | key=lambda x: x.get("similarity_score", 0), reverse=True 53 | ) # 根據相似度排序 54 | 55 | return {"departments": department_results, "people": people_results} 56 | 57 | 58 | # 需要把它往下移,不然 search 會被擋住 59 | @router.get("/{index}", response_model=Department) 60 | async def get_department_by_index(index: str): 61 | _commit_hash, directory_data = await nthudata.get(json_path) 62 | for dept in directory_data: 63 | if dept["index"] == index: 64 | return dept 65 | raise HTTPException(status_code=404, detail="Department not found") 66 | -------------------------------------------------------------------------------- /src/api/routers/dining.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from fastapi import APIRouter, Path, Query 4 | from thefuzz import fuzz 5 | 6 | from src.api.schemas.dining import ( 7 | DiningBuilding, 8 | DiningBuildingName, 9 | DiningRestaurant, 10 | DiningScheduleKeyword, 11 | DiningScheduleName, 12 | ) 13 | from src.utils import nthudata 14 | 15 | router = APIRouter() 16 | json_path = "dining.json" 17 | 18 | 19 | def _is_restaurant_open(restaurant: DiningRestaurant, day: str) -> bool: 20 | """ 21 | 判斷餐廳在指定日期是否營業。 22 | 23 | 分析餐廳資料中的 'note' 欄位,檢查是否包含表示特定日期休息的關鍵字。 24 | 25 | Args: 26 | restaurant (DiningRestaurant): 要檢查的餐廳資料。 27 | day (str): 要查詢的星期幾,使用英文小寫 (例如:'monday')。 28 | 29 | Returns: 30 | bool: 若餐廳在指定日期可能營業,則返回 True,否則返回 False。 31 | """ 32 | note = restaurant.get("note", "").lower() 33 | for keyword in DiningScheduleKeyword.BREAK_KEYWORDS: 34 | for day_zh in DiningScheduleKeyword.DAY_EN_TO_ZH.get(day, []): 35 | if keyword in note and day_zh in note: 36 | return False # 找到休息關鍵字,判斷為休息 37 | return True # 未找到休息關鍵字,判斷為營業 38 | 39 | 40 | @router.get("/", response_model=list[DiningBuilding]) 41 | async def get_dining_data( 42 | building_name: DiningBuildingName = Query( 43 | None, example="小吃部", description="餐廳建築名稱(可選)" 44 | ) 45 | ) -> list[DiningBuilding]: 46 | """ 47 | 取得所有餐廳及服務性廠商資料。 48 | 資料來源:[總務處經營管理組/餐廳及服務性廠商](https://ddfm.site.nthu.edu.tw/p/404-1494-256455.php?Lang=zh-tw) 49 | """ 50 | _commit_hash, dining_data = await nthudata.get(json_path) 51 | if building_name: 52 | return [ 53 | building 54 | for building in dining_data 55 | if building["building"] == building_name 56 | ] 57 | return dining_data 58 | 59 | 60 | @router.get( 61 | "/search", 62 | response_model=list[DiningRestaurant], 63 | ) 64 | async def fuzzy_search_restaurants( 65 | query: str = Query(..., example="麵", description="餐廳模糊搜尋關鍵字") 66 | ) -> list[DiningRestaurant]: 67 | """ 68 | 使用餐廳名稱模糊搜尋餐廳資料。 69 | """ 70 | _commit_hash, dining_data = await nthudata.get(json_path) 71 | results = [] 72 | for building in dining_data: 73 | for restaurant in building.get("restaurants", []): 74 | similarity = fuzz.partial_ratio(query, restaurant["name"]) 75 | if similarity >= 60: # 相似度門檻值,可以調整 76 | restaurant["similarity_score"] = similarity # 加入相似度分數方便排序 77 | results.append(restaurant) 78 | results.sort( 79 | key=lambda x: x.get("similarity_score", 0), reverse=True 80 | ) # 根據相似度排序 81 | return results 82 | 83 | 84 | @router.get("/restaurants", response_model=list[DiningRestaurant]) 85 | async def get_all_restaurants( 86 | schedule: DiningScheduleName = Query(None, example="saturday", description="營業日") 87 | ) -> list[DiningRestaurant]: 88 | """ 89 | 取得所有餐廳資料。 90 | - 可選輸入營業日篩選該營業日有營業的餐廳,預設為全部列出。 91 | """ 92 | _commit_hash, dining_data = await nthudata.get(json_path) 93 | if schedule: 94 | if schedule == "today": 95 | schedule = datetime.now().strftime("%A").lower() 96 | return [ 97 | restaurant 98 | for building in dining_data 99 | for restaurant in building.get("restaurants", []) 100 | if _is_restaurant_open(restaurant, schedule) 101 | ] 102 | return [ 103 | restaurant 104 | for building in dining_data 105 | for restaurant in building.get("restaurants", []) 106 | ] 107 | -------------------------------------------------------------------------------- /src/api/routers/energy.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | 4 | import requests 5 | from fastapi import APIRouter, HTTPException 6 | 7 | from src.api.schemas.energy import EnergyElectricityInfo 8 | 9 | router = APIRouter() 10 | 11 | 12 | # 電力系統 13 | # 舊版: http://140.114.188.57/nthu2020/Index.aspx 14 | # 新版: http://140.114.188.86/powermanage/index.aspx 15 | def _get_realtime_electricity_usage(): 16 | URL_PREFIX = "http://140.114.188.86/powermanage/fn1/kw" 17 | URL_POSTFIX = ".aspx" 18 | 19 | electricity_usage_data = [ 20 | {"id": 1, "name": "北區一號", "capacity": 5200}, 21 | {"id": 2, "name": "北區二號", "capacity": 5600}, 22 | {"id": 3, "name": "仙宮", "capacity": 1500}, 23 | ] 24 | 25 | for item in electricity_usage_data: 26 | res = requests.get(URL_PREFIX + str(item["id"]) + URL_POSTFIX) 27 | if res.status_code != 200: 28 | raise HTTPException( 29 | status_code=500, detail="Failed to get electricity usage data." 30 | ) 31 | res_text = res.text 32 | 33 | data = re.search(r"alt=\"kW: ([\d,-]+?)\"", res_text, re.S) 34 | if data is not None: 35 | data = data.group(1) 36 | else: 37 | return None 38 | 39 | item.update( 40 | { 41 | "data": int(data.replace(",", "")), 42 | "unit": "kW", 43 | "last_updated": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 44 | } 45 | ) 46 | 47 | return electricity_usage_data 48 | 49 | 50 | @router.get("/electricity_usage", response_model=list[EnergyElectricityInfo]) 51 | async def get_realtime_electricity_usage(): 52 | """ 53 | 取得校園電力即時使用量。 54 | 資料來源:[校園能源查詢管理系統](http://140.114.188.86/powermanage/index.aspx) 55 | """ 56 | try: 57 | return _get_realtime_electricity_usage() 58 | except Exception as e: 59 | raise HTTPException(status_code=500, detail=str(e)) 60 | -------------------------------------------------------------------------------- /src/api/routers/libraries.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from datetime import datetime, timedelta 4 | 5 | import requests 6 | 7 | # TODO: 這邊之後可以考慮改寫成 async,避免跟之前一樣有機率等很久甚至卡死 8 | import xmltodict 9 | from bs4 import BeautifulSoup 10 | from fastapi import APIRouter, HTTPException, Path 11 | 12 | from src.api.schemas.libraries import ( 13 | LibraryLostAndFound, 14 | LibraryName, 15 | LibraryNumberOfGoods, 16 | LibraryRssItem, 17 | LibraryRssType, 18 | LibrarySpace, 19 | ) 20 | 21 | _default_headers = { 22 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3" 23 | } 24 | 25 | router = APIRouter() 26 | 27 | 28 | @router.get("/space", response_model=list[LibrarySpace]) 29 | def get_library_space_availability(): 30 | """ 31 | 取得圖書館空間使用資訊。 32 | 資料來源:[圖書館空間預約系統](https://libsms.lib.nthu.edu.tw/RWDAPI_New/GetDevUseStatus.aspx) 33 | """ 34 | url = "https://libsms.lib.nthu.edu.tw/RWDAPI_New/GetDevUseStatus.aspx" 35 | try: 36 | response = requests.get(url, headers=_default_headers) 37 | response.raise_for_status() 38 | response_text = response.text 39 | 40 | data = json.loads(response_text) 41 | if data["resmsg"] != "成功": 42 | raise HTTPException(status_code=404, detail="找不到空間資料") 43 | return data["rows"] 44 | except requests.exceptions.RequestException as e: 45 | raise HTTPException(status_code=500, detail=f"擷取空間資料失敗: {e}") 46 | except json.JSONDecodeError: 47 | raise HTTPException(status_code=500, detail="解析空間資料 JSON 失敗") 48 | 49 | 50 | @router.get("/lost_and_found", response_model=list[LibraryLostAndFound]) 51 | def get_library_lost_and_found_items(): 52 | """ 53 | 取得圖書館失物招領資訊。 54 | 資料來源:[圖書館失物招領系統](https://adage.lib.nthu.edu.tw/find) 55 | """ 56 | date_end = datetime.now() 57 | date_start = date_end - timedelta(days=6 * 30) 58 | date_end = date_end.strftime("%Y-%m-%d") 59 | date_start = date_start.strftime("%Y-%m-%d") 60 | 61 | post_data = { 62 | "place": "0", 63 | "date_start": date_start, 64 | "date_end": date_end, 65 | "catalog": "ALL", 66 | "keyword": "", 67 | "SUMIT": "送出", 68 | } 69 | url = "https://adage.lib.nthu.edu.tw/find/search_it.php" 70 | 71 | try: 72 | response = requests.post(url, data=post_data, headers=_default_headers) 73 | response.raise_for_status() 74 | response_text = response.text 75 | soup = BeautifulSoup(response_text, "html.parser") 76 | 77 | table = soup.find("table") 78 | if not table: 79 | return [] # 如果找不到表格,回傳空列表,而不是拋出錯誤 80 | 81 | table_rows = table.find_all("tr") 82 | if not table_rows: 83 | return [] # 如果找不到列,回傳空列表 84 | 85 | table_title = table_rows[0].find_all("td") 86 | table_title = [x.text.strip() for x in table_title] 87 | 88 | def parse_table_row(table_row): 89 | text = table_row.find_all("td") 90 | return [re.sub(r"\s+", " ", x.text.strip()) for x in text] 91 | 92 | rows_data = [parse_table_row(row) for row in table_rows[1:]] 93 | rows_data = [ 94 | dict(zip(table_title, row)) 95 | for row in rows_data 96 | if len(row) == len(table_title) 97 | ] # 確保列長度與標題長度相符 98 | return rows_data 99 | 100 | except requests.exceptions.RequestException as e: 101 | raise HTTPException(status_code=500, detail=f"擷取失物招領資料失敗: {e}") 102 | except AttributeError: # 處理 soup.find("table") 回傳 None 的情況 103 | return [] # 如果表格解析失敗,回傳空列表 104 | except Exception as e: # 捕捉任何其他解析錯誤 105 | raise HTTPException(status_code=500, detail=f"解析失物招領資料失敗: {e}") 106 | 107 | 108 | @router.get("/rss/{rss_type}", response_model=list[LibraryRssItem]) 109 | def get_library_rss_data( 110 | rss_type: LibraryRssType = Path( 111 | ..., 112 | description="RSS 類型:最新消息(news)、電子資源(eresources)、展覽及活動(exhibit)、南大與人社分館(branches)", 113 | ) 114 | ): 115 | """ 116 | 取得指定圖書館的 RSS 資料。 117 | 資料來源:[圖書館官網展覽與活動](https://www.lib.nthu.edu.tw/events/index.html) 118 | """ 119 | # 最新消息 RSS: https://www.lib.nthu.edu.tw/bulletin/RSS/export/rss_news.xml 120 | # 電子資源 RSS: https://www.lib.nthu.edu.tw/bulletin/RSS/export/rss_eresources.xml 121 | # 展覽及活動 RSS: https://www.lib.nthu.edu.tw/bulletin/RSS/export/rss_exhibit.xml 122 | # 南大與人社分館 RSS: https://www.lib.nthu.edu.tw/bulletin/RSS/export/rss_branches.xml 123 | url = f"https://www.lib.nthu.edu.tw/bulletin/RSS/export/rss_{rss_type.value}.xml" 124 | try: 125 | response = requests.get(url, headers=_default_headers) 126 | response.raise_for_status() # 針對錯誤的回應 (4xx 或 5xx) 拋出 HTTPError 127 | xml_string = response.text 128 | xml_string = xml_string.replace("
", "") 129 | rss_dict = xmltodict.parse(xml_string) 130 | rss_data = rss_dict["rss"]["channel"]["item"] 131 | if not isinstance(rss_data, list): 132 | rss_data = [rss_data] 133 | for item in rss_data: 134 | if item["image"]["url"].startswith("//"): 135 | item["image"]["url"] = f"https:{item['image']['url']}" 136 | 137 | return rss_data 138 | except requests.exceptions.RequestException as e: 139 | raise HTTPException(status_code=500, detail=f"擷取 RSS 資料失敗: {e}") 140 | except KeyError: 141 | raise HTTPException(status_code=404, detail="在回應中找不到 RSS 來源") 142 | 143 | 144 | @router.get("/openinghours/{library_name}", response_model=dict) 145 | def get_library_opening_hours( 146 | library_name: LibraryName = Path( 147 | ..., 148 | description="圖書館代號:總圖(mainlib)、人社圖書館(hslib)、南大圖書館(nandalib)、夜讀區(mainlib_moonlight_area)", 149 | ) 150 | ): 151 | """ 152 | 取得指定圖書館的開放時間。 153 | 資料來源:圖書館官網 154 | """ 155 | url = f"https://www.lib.nthu.edu.tw/bulletin/OpeningHours/{library_name.value}.json" 156 | try: 157 | response = requests.get(url, headers=_default_headers) 158 | response.raise_for_status() 159 | data_json = response.json() # parse json 160 | return data_json 161 | except requests.exceptions.RequestException as e: 162 | raise HTTPException(status_code=500, detail=f"擷取開放時間資料失敗: {e}") 163 | except json.JSONDecodeError: 164 | raise HTTPException(status_code=500, detail="解析開放時間資料 JSON 失敗") 165 | except Exception as e: 166 | raise HTTPException(status_code=500, detail=f"解析開放時間資料失敗: {e}") 167 | 168 | 169 | @router.get("/goods", response_model=LibraryNumberOfGoods) 170 | def get_library_number_of_goods(): 171 | """ 172 | 取得總圖換證數量資訊。 173 | 資料來源:圖書館官網 174 | """ 175 | url = "https://adage.lib.nthu.edu.tw/goods/Public/number_of_goods_mix.json" 176 | headers = { 177 | "Referer": "https://www.lib.nthu.edu.tw/", 178 | **_default_headers, 179 | } 180 | try: 181 | response = requests.get(url, headers=headers) 182 | response.raise_for_status() 183 | data_json = response.json() # parse json 184 | return data_json 185 | except requests.exceptions.RequestException as e: 186 | raise HTTPException(status_code=500, detail=f"擷取換證資料失敗: {e}") 187 | except json.JSONDecodeError: 188 | raise HTTPException(status_code=500, detail="解析換證資料 JSON 失敗") 189 | except Exception as e: # 捕捉潛在的解析錯誤 190 | raise HTTPException(status_code=500, detail=f"解析換證資料失敗: {e}") 191 | -------------------------------------------------------------------------------- /src/api/routers/locations.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException, Query 2 | from thefuzz import fuzz 3 | 4 | from src.api.schemas.locations import LocationDetail 5 | from src.utils import nthudata 6 | 7 | router = APIRouter() 8 | json_path = "maps.json" 9 | 10 | 11 | @router.get( 12 | "/", 13 | response_model=list[LocationDetail], 14 | ) 15 | async def get_all_locations(): 16 | """ 17 | 取得校內所有地點資訊。 18 | 資料來源:[國立清華大學校園地圖](https://www.nthu.edu.tw/campusmap) 19 | """ 20 | _commit_hash, map_data = await nthudata.get(json_path) 21 | location_list = [] 22 | for campus_locations in map_data.values(): 23 | for location_name, coordinates in campus_locations.items(): 24 | location_list.append( 25 | LocationDetail( 26 | name=location_name, 27 | latitude=coordinates["latitude"], 28 | longitude=coordinates["longitude"], 29 | ) 30 | ) 31 | return location_list 32 | 33 | 34 | @router.get( 35 | "/search", 36 | response_model=list[LocationDetail], 37 | ) 38 | async def fuzzy_search_locations( 39 | query: str = Query(..., example="校門", description="要查詢的地點"), 40 | ): 41 | """ 42 | 使用名稱模糊搜尋地點資訊。 43 | """ 44 | _commit_hash, map_data = await nthudata.get(json_path) 45 | tmp_results = [] 46 | for campus_locations in map_data.values(): 47 | for location_name, coordinates in campus_locations.items(): 48 | similarity = fuzz.partial_ratio(query, location_name) 49 | if similarity >= 60: 50 | tmp_results.append( 51 | ( 52 | similarity, # 儲存相似度 53 | LocationDetail( 54 | name=location_name, 55 | latitude=coordinates["latitude"], 56 | longitude=coordinates["longitude"], 57 | ), 58 | ) 59 | ) 60 | # 先判斷是否與查詢字串相同,再依相似度從高到低排序 61 | tmp_results.sort(key=lambda x: (x[1].name == query, x[0]), reverse=True) 62 | location_results = [item[1] for item in tmp_results] 63 | if not location_results: 64 | raise HTTPException(status_code=404, detail="Not found") 65 | return location_results 66 | -------------------------------------------------------------------------------- /src/api/routers/newsletters.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException, Path 2 | 3 | from src.api.schemas.newsletters import NewsletterInfo, NewsletterName 4 | from src.utils import nthudata 5 | 6 | router = APIRouter() 7 | json_path = "newsletters.json" 8 | 9 | 10 | @router.get("/", response_model=list[NewsletterInfo]) 11 | async def get_all_newsletters(): 12 | """ 13 | 取得所有的電子報。 14 | 資料來源:[國立清華大學電子報系統](https://newsletter.cc.nthu.edu.tw/nthu-list/index.php/zh/) 15 | """ 16 | _commit_hash, newsletter_data = await nthudata.get(json_path) 17 | return newsletter_data 18 | 19 | 20 | @router.get("/{newsletter_name}", response_model=NewsletterInfo) 21 | async def get_newsletter_by_name( 22 | newsletter_name: NewsletterName = Path( 23 | ..., example="國立清華大學學生會電子報", description="抓取的電子報名稱" 24 | ) 25 | ): 26 | """ 27 | 透過電子報名稱取得指定的電子報列表。 28 | """ 29 | _commit_hash, newsletter_data = await nthudata.get(json_path) 30 | for newsletter in newsletter_data: 31 | if newsletter["name"] == newsletter_name: 32 | return newsletter 33 | raise HTTPException(status_code=404, detail="電子報名稱不存在") 34 | -------------------------------------------------------------------------------- /src/api/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from . import ( 2 | buses, 3 | courses, 4 | departments, 5 | dining, 6 | energy, 7 | libraries, 8 | locations, 9 | newsletters, 10 | ) 11 | -------------------------------------------------------------------------------- /src/api/schemas/announcements.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Optional 2 | 3 | from pydantic import BaseModel, BeforeValidator, Field, HttpUrl 4 | 5 | from src.utils.schema import url_corrector 6 | 7 | 8 | class AnnouncementArticle(BaseModel): 9 | title: Optional[str] = Field(..., description="公告標題") 10 | link: Optional[Annotated[HttpUrl, BeforeValidator(url_corrector)]] = Field( 11 | ..., description="公告連結" 12 | ) 13 | date: Optional[str] = Field(None, description="公告日期 (YYYY-MM-DD)") 14 | 15 | 16 | class AnnouncementDetail(BaseModel): 17 | title: str = Field(..., description="佈告欄標題") 18 | link: Annotated[HttpUrl, BeforeValidator(url_corrector)] = Field( 19 | ..., description="佈告欄連結" 20 | ) 21 | language: str = Field(..., description="佈告欄語言") 22 | department: str = Field(..., description="發布部門") 23 | articles: list[AnnouncementArticle] = Field(..., description="公告列表") 24 | -------------------------------------------------------------------------------- /src/api/schemas/buses.py: -------------------------------------------------------------------------------- 1 | import re 2 | from enum import Enum 3 | from typing import Literal, Optional 4 | 5 | from fastapi import HTTPException, Query 6 | from pydantic import BaseModel, Field, field_validator 7 | 8 | 9 | class BusStopsName(str, Enum): 10 | M1 = "北校門口" 11 | M2 = "綜二館" 12 | M3 = "楓林小徑" 13 | M4 = "人社院&生科館" 14 | M5 = "台積館" 15 | M6 = "奕園停車場" 16 | M7 = "南門停車場" 17 | S1 = "南大校區校門口右側(食品路校牆邊)" 18 | 19 | 20 | class BusRouteType(str, Enum): 21 | all = "all" 22 | main = "main" 23 | nanda = "nanda" 24 | 25 | 26 | class BusType(str, Enum): 27 | route_83 = "route_83" 28 | large_sized_bus = "large-sized_bus" 29 | middle_sized_bus = "middle-sized_bus" 30 | 31 | 32 | class BusDirection(str, Enum): 33 | up = "up" 34 | down = "down" 35 | 36 | 37 | class BusDay(str, Enum): 38 | # 記得改這個的時候要一起改 BusDayWithCurrent 39 | all = "all" 40 | weekday = "weekday" 41 | weekend = "weekend" 42 | 43 | 44 | class BusDayWithCurrent(str, Enum): 45 | # 目前原生的 enum 不能 extend enum,所以只能多寫一個,改 BusDay 的時候也要改這裡 46 | all = "all" 47 | weekday = "weekday" 48 | weekend = "weekend" 49 | current = "current" 50 | 51 | 52 | class BusQuery(BaseModel): 53 | time: Optional[str] = Field( 54 | Query( 55 | "0:00", example="8:10", description="時間。若搜尋 day 選擇 current 時失效。" 56 | ), 57 | description="時間", 58 | ) 59 | limits: Optional[int] = Field( 60 | Query( 61 | None, 62 | ge=1, 63 | description="最大回傳資料筆數。若搜尋 day 選擇 current 且大於 5 時失效。", 64 | ), 65 | description="最大回傳資料筆數", 66 | ) 67 | 68 | @field_validator("time") 69 | def validate_time(cls, v): 70 | splited = v.split(":") 71 | if len(splited) != 2: 72 | raise HTTPException( 73 | status_code=422, detail="Time must be in format of HH:MM or H:MM." 74 | ) 75 | elif re.match(r"^\d{1,2}$", splited[0]) is None: 76 | raise HTTPException( 77 | status_code=422, detail="Hour must be in format of HH or H." 78 | ) 79 | elif re.match(r"^\d{2}$", splited[1]) is None: 80 | raise HTTPException( 81 | status_code=422, detail="Minute must be in format of MM." 82 | ) 83 | elif int(splited[0]) < 0 or int(splited[0]) > 23: 84 | raise HTTPException( 85 | status_code=422, detail="Hour must be positive and less than 24." 86 | ) 87 | elif int(splited[1]) < 0 or int(splited[1]) > 59: 88 | raise HTTPException( 89 | status_code=422, detail="Minute must be positive and less than 60." 90 | ) 91 | return v 92 | 93 | 94 | class BusInfo(BaseModel): 95 | direction: str = Field(..., description="方向") 96 | duration: str = Field(..., description="時刻表有效期間") 97 | route: str = Field(..., description="路線") 98 | routeEN: str = Field(..., description="英文路線") 99 | 100 | 101 | class BusStopsInfo(BaseModel): 102 | stop_name: str = Field(..., description="站牌中文名稱") 103 | stop_name_en: str = Field(..., description="站牌英文名稱") 104 | latitude: str = Field(..., description="站牌所在地緯度") 105 | longitude: str = Field(..., description="站牌所在地經度") 106 | 107 | 108 | class BusNandaSchedule(BaseModel): 109 | time: str = Field(..., description="發車時間") 110 | description: str = Field(..., description="備註") 111 | route: str = Field("南大區間車", description="路線") 112 | dep_stop: str = Field(..., description="發車地點") 113 | bus_type: BusType = Field(..., description="營運車輛類型") 114 | 115 | 116 | class BusMainSchedule(BaseModel): 117 | time: str = Field(..., description="發車時間") 118 | description: str = Field(..., description="備註") 119 | route: str = Field("校園公車", description="路線") 120 | dep_stop: str = Field(..., description="發車地點") 121 | line: str = Field(..., description="路線") 122 | bus_type: BusType = Field(..., description="營運車輛類型") 123 | 124 | 125 | class BusStopsQueryResult(BaseModel): 126 | bus_info: BusMainSchedule | BusNandaSchedule = Field(..., description="公車資訊") 127 | arrive_time: str = Field(..., description="預計到達時間") 128 | 129 | 130 | class BusArriveTime(BaseModel): 131 | # {"stop_name": stop.name, "time": arrive_time} 132 | # TODO: refacter stop_name Literal check 133 | stop_name: Literal[ 134 | BusStopsName.M1, 135 | BusStopsName.M2, 136 | BusStopsName.M3, 137 | "人社院/生科館", # 此處功能同 BusStopsName.M4,但 / 會影響 url query,故手動更改 138 | BusStopsName.M5, 139 | BusStopsName.M6, 140 | BusStopsName.M7, 141 | BusStopsName.S1, 142 | ] = Field(..., description="公車站牌名稱") 143 | time: str = Field(..., description="預計到達時間") 144 | 145 | 146 | class BusNandaDetailedSchedule(BaseModel): 147 | dep_info: BusNandaSchedule = Field(..., description="發車資訊") 148 | stops_time: list[BusArriveTime] = Field(..., description="各站發車時間") 149 | 150 | 151 | class BusMainDetailedSchedule(BaseModel): 152 | dep_info: BusMainSchedule = Field(..., description="發車資訊") 153 | stops_time: list[BusArriveTime] = Field(..., description="各站發車時間") 154 | 155 | 156 | class BusMainData(BaseModel): 157 | toward_TSMC_building_info: BusInfo = Field( 158 | ..., description="校門口往台積館公車資訊" 159 | ) 160 | weekday_bus_schedule_toward_TSMC_building: list[BusMainSchedule] = Field( 161 | ..., description="校門口往台積館公車時刻表(平日)" 162 | ) 163 | weekend_bus_schedule_toward_TSMC_building: list[BusMainSchedule] = Field( 164 | ..., description="校門口往台積館公車時刻表(假日)" 165 | ) 166 | toward_main_gate_info: BusInfo = Field(..., description="台積館往校門口公車資訊") 167 | weekday_bus_schedule_toward_main_gate: list[BusMainSchedule] = Field( 168 | ..., description="台積館往校門口公車時刻表(平日)" 169 | ) 170 | weekend_bus_schedule_toward_main_gate: list[BusMainSchedule] = Field( 171 | ..., description="台積館往校門口公車時刻表(假日)" 172 | ) 173 | 174 | 175 | class BusNandaData(BaseModel): 176 | toward_south_campus_info: BusInfo = Field(..., description="本部往南大區間車資訊") 177 | weekday_bus_schedule_toward_south_campus: list[BusNandaSchedule] = Field( 178 | ..., description="本部往南大區間車時刻表(平日)" 179 | ) 180 | weekend_bus_schedule_toward_south_campus: list[BusNandaSchedule] = Field( 181 | ..., description="本部往南大區間車時刻表(假日)" 182 | ) 183 | toward_main_campus_info: BusInfo = Field(..., description="南大往本部區間車資訊") 184 | weekday_bus_schedule_toward_main_campus: list[BusNandaSchedule] = Field( 185 | ..., description="南大往本部區間車時刻表(平日)" 186 | ) 187 | weekend_bus_schedule_toward_main_campus: list[BusNandaSchedule] = Field( 188 | ..., description="南大往本部區間車時刻表(假日)" 189 | ) 190 | -------------------------------------------------------------------------------- /src/api/schemas/courses.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Union 3 | 4 | from pydantic import BaseModel, Field, RootModel, field_validator 5 | 6 | 7 | class CourseFieldName(str, Enum): 8 | id = "id" 9 | chinese_title = "chinese_title" 10 | english_title = "english_title" 11 | credit = "credit" 12 | size_limit = "size_limit" 13 | freshman_reservation = "freshman_reservation" 14 | object = "object" 15 | ge_type = "ge_type" 16 | language = "language" 17 | note = "note" 18 | suspend = "suspend" 19 | class_room_and_time = "class_room_and_time" 20 | teacher = "teacher" 21 | prerequisite = "prerequisite" 22 | limit_note = "limit_note" 23 | expertise = "expertise" 24 | program = "program" 25 | no_extra_selection = "no_extra_selection" 26 | required_optional_note = "required_optional_note" 27 | 28 | 29 | class CourseLanguage(str, Enum): 30 | Chinese = "中" 31 | English = "英" 32 | 33 | 34 | class CourseCreditOperation(str, Enum): 35 | GreaterThan = "gt" 36 | LessThan = "lt" 37 | GreaterThanOrEqual = "gte" 38 | LessThanOrEqual = "lte" 39 | 40 | 41 | class CourseData(BaseModel): 42 | id: str = Field(..., description="課號") 43 | chinese_title: str = Field(..., description="課程中文名稱") 44 | english_title: str = Field(..., description="課程英文名稱") 45 | credit: str = Field(..., description="學分數") 46 | size_limit: str = Field(..., description="人限:若為空字串表示無人數限制") 47 | freshman_reservation: str = Field( 48 | ..., description="新生保留人數:若為0表示無新生保留人數" 49 | ) 50 | object: str = Field( 51 | ..., 52 | description="通識對象:[代碼說明(課務組)](https://curricul.site.nthu.edu.tw/p/404-1208-11133.php)", 53 | ) 54 | ge_type: str = Field(..., description="通識類別") 55 | language: CourseLanguage = Field(..., description='授課語言:"中"、"英"') 56 | note: str = Field(..., description="備註") 57 | suspend: str = Field(..., description='停開註記:"停開"或空字串') 58 | class_room_and_time: str = Field( 59 | ..., 60 | description="教室與上課時間:一間教室對應一個上課時間,中間以tab分隔;多個上課教室以new line字元分開", 61 | ) 62 | teacher: str = Field( 63 | ..., 64 | description="授課教師:多位教師授課以new line字元分開;教師中英文姓名以tab分開", 65 | ) 66 | prerequisite: str = Field(..., description="擋修說明:會有html entities") 67 | limit_note: str = Field(..., description="課程限制說明") 68 | expertise: str = Field(..., description="第一二專長對應:對應多個專長用tab字元分隔") 69 | program: str = Field(..., description="學分學程對應:用半形/分隔") 70 | no_extra_selection: str = Field(..., description="不可加簽說明") 71 | required_optional_note: str = Field( 72 | ..., description="必選修說明:多個必選修班級用tab字元分隔" 73 | ) 74 | 75 | 76 | class CourseCondition(BaseModel): 77 | row_field: CourseFieldName = Field(..., description="搜尋的欄位名稱") 78 | matcher: str = Field(..., description="搜尋的值") 79 | regex_match: bool = Field(False, description="是否使用正則表達式") 80 | 81 | 82 | class CourseQueryOperation(str, Enum): 83 | and_ = "and" 84 | or_ = "or" 85 | 86 | 87 | class CourseQueryCondition(RootModel): 88 | root: list[ 89 | Union[Union["CourseQueryCondition", CourseCondition], CourseQueryOperation] 90 | ] 91 | 92 | @field_validator("root") 93 | def check_query(cls, v): 94 | POST_ERROR_INFO = " Also, FYI, the structure of query must be like this: [(nested) Condition, Operation, (nested) Condition]." 95 | for i in range(len(v)): 96 | if type(v[i]) is CourseQueryOperation: 97 | if i == 0 or i == len(v) - 1: 98 | raise ValueError( 99 | "The first and last elements of query must be a Condition or another course query." 100 | + POST_ERROR_INFO 101 | ) 102 | elif type(v[i - 1]) not in [CourseQueryCondition, CourseCondition]: 103 | raise TypeError( 104 | "The element before Operation must be a Condition or another course query." 105 | + POST_ERROR_INFO 106 | ) 107 | elif type(v[i + 1]) not in [CourseQueryCondition, CourseCondition]: 108 | raise TypeError( 109 | "The element after Operation must be a Condition or another course query." 110 | + POST_ERROR_INFO 111 | ) 112 | return v 113 | 114 | 115 | class CourseListName(str, Enum): 116 | microcredits = "microcredits" 117 | xclass = "xclass" 118 | -------------------------------------------------------------------------------- /src/api/schemas/departments.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Optional 2 | 3 | from pydantic import BaseModel, BeforeValidator, Field, HttpUrl 4 | 5 | from src.utils.schema import url_corrector 6 | 7 | 8 | class DepartmentContact(BaseModel): 9 | extension: Optional[str] = None 10 | phone: Optional[str] = None 11 | fax: Optional[str] = None 12 | email: Optional[str] = None 13 | website: Optional[Annotated[HttpUrl, BeforeValidator(url_corrector)]] = None 14 | 15 | 16 | class DepartmentPerson(BaseModel): 17 | name: str 18 | title: Optional[str] = None 19 | extension: Optional[str] = None 20 | note: Optional[str] = None 21 | email: Optional[str] = None 22 | 23 | 24 | class DepartmentDetails(BaseModel): 25 | departments: list[dict] = Field(default_factory=list) 26 | contact: DepartmentContact = Field(default_factory=DepartmentContact) 27 | people: list[DepartmentPerson] = Field(default_factory=list) 28 | 29 | 30 | class DepartmentBase(BaseModel): 31 | index: str 32 | name: str 33 | parent_name: Optional[str] = None 34 | url: Optional[Annotated[HttpUrl, BeforeValidator(url_corrector)]] = None 35 | details: DepartmentDetails = Field(default_factory=DepartmentDetails) 36 | 37 | 38 | class Department(DepartmentBase): 39 | pass 40 | -------------------------------------------------------------------------------- /src/api/schemas/dining.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Annotated, Optional 3 | 4 | from pydantic import BaseModel, BeforeValidator, Field, HttpUrl 5 | 6 | from src.utils.schema import url_corrector 7 | 8 | 9 | class DiningBuildingName(str, Enum): 10 | 小吃部 = "小吃部" 11 | 水木生活中心 = "水木生活中心" 12 | 風雲樓 = "風雲樓" 13 | 綜合教學大樓_南大校區 = "綜合教學大樓(南大校區)" 14 | 其他餐廳 = "其他餐廳" 15 | 16 | 17 | class DiningScheduleName(str, Enum): 18 | today = "today" 19 | weekday = "weekday" 20 | saturday = "saturday" 21 | sunday = "sunday" 22 | 23 | 24 | class DiningRestaurant(BaseModel): 25 | area: str = Field(..., description="餐廳所在建築") 26 | image: Optional[Annotated[HttpUrl, BeforeValidator(url_corrector)]] = Field( 27 | ..., description="餐廳圖片" 28 | ) 29 | name: str = Field(..., description="餐廳名稱") 30 | note: str = Field(..., description="餐廳備註") 31 | phone: str = Field(..., description="餐廳電話") 32 | schedule: dict = Field(..., description="餐廳營業時間") 33 | 34 | 35 | class DiningBuilding(BaseModel): 36 | building: str = Field(..., description="建築名稱") 37 | restaurants: list[DiningRestaurant] = Field(..., description="餐廳資料") 38 | 39 | 40 | class DiningScheduleKeyword: 41 | DAY_EN_TO_ZH = { 42 | "weekday": ["平日"], 43 | "saturday": ["週六", "星期六", "禮拜六", "六"], 44 | "sunday": ["週日", "星期日", "禮拜日", "日"], 45 | } 46 | BREAK_KEYWORDS = ["暫停營業", "休息", "休業", "休"] 47 | -------------------------------------------------------------------------------- /src/api/schemas/energy.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class EnergyElectricityInfo(BaseModel): 5 | name: str = Field(..., description="電力名稱") 6 | data: int = Field(..., description="電力使用量") 7 | capacity: int = Field(..., description="電力容量") 8 | unit: str = Field(..., description="單位") 9 | last_updated: str = Field(..., description="最後更新時間") 10 | -------------------------------------------------------------------------------- /src/api/schemas/libraries.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Annotated, Optional 3 | 4 | from pydantic import BaseModel, BeforeValidator, Field, HttpUrl 5 | 6 | from src.utils.schema import url_corrector 7 | 8 | 9 | class LibraryRssImage(BaseModel): 10 | # url 使用 str 而非 HttpUrl,因為有些圖片的 url 並非合法的 url,例如: //www.lib.nthu.edu.tw/image/news/8/20230912.jpg 11 | url: Annotated[HttpUrl, BeforeValidator(url_corrector)] = Field( 12 | ..., description="圖片網址" 13 | ) 14 | title: str = Field(..., description="圖片標題") 15 | link: Optional[Annotated[HttpUrl, BeforeValidator(url_corrector)]] = Field( 16 | ..., description="連結" 17 | ) 18 | 19 | 20 | class LibraryRssItem(BaseModel): 21 | guid: str = Field(..., description="文章 id") 22 | category: str = Field(..., description="文章分類") 23 | title: str = Field(..., description="文章標題") 24 | link: Optional[Annotated[HttpUrl, BeforeValidator(url_corrector)]] = Field( 25 | ..., description="文章連結" 26 | ) 27 | pubDate: str = Field(..., description="文章發布日期") 28 | description: str = Field(..., description="文章內容") 29 | author: str = Field(..., description="文章作者") 30 | image: LibraryRssImage = Field(..., description="文章圖片") 31 | 32 | 33 | class LibraryRssData(BaseModel): 34 | title: Optional[str] = Field(..., description="電子報標題") 35 | link: Annotated[HttpUrl, BeforeValidator(url_corrector)] = Field( 36 | ..., description="電子報網址" 37 | ) 38 | date: Optional[str] = Field(..., description="發布日期") 39 | 40 | 41 | class LibraryName(str, Enum): 42 | mainlib = "mainlib" 43 | hslib = "hslib" 44 | nandalib = "nandalib" 45 | mainlib_moonlight_area = "mainlib_moonlight_area" 46 | 47 | 48 | class LibraryNumberOfGoods(BaseModel): 49 | borrow_quantity: int = Field(..., description="已換證數量") 50 | remaining_18_quantity: int = Field(..., description="18歲以上成人剩餘換證數量") 51 | remaining_15_18_quantity: str = Field(..., description="15~18歲青少年剩餘換證數量") 52 | 53 | 54 | class LibraryRssType(str, Enum): 55 | news = "news" 56 | eresources = "eresources" 57 | exhibit = "exhibit" 58 | branches = "branches" 59 | 60 | 61 | class LibrarySpace(BaseModel): 62 | spacetype: int = Field(..., description="空間類型") 63 | spacetypename: str = Field(..., description="空間類型名稱") 64 | zoneid: str = Field(..., description="區域代號") 65 | zonename: str = Field(..., description="區域名稱") 66 | count: int = Field(..., description="空間剩餘數量") 67 | 68 | 69 | class LibraryLostAndFound(BaseModel): 70 | 序號: str = Field(..., description="序號") 71 | 拾獲時間: str = Field(..., description="拾獲日期") 72 | 拾獲地點: str = Field(..., description="拾獲地點") 73 | 描述: str = Field(..., description="物品描述") 74 | -------------------------------------------------------------------------------- /src/api/schemas/locations.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class LocationDetail(BaseModel): 5 | name: str = Field(..., description="地點名稱") 6 | latitude: str = Field(..., description="地點緯度") 7 | longitude: str = Field(..., description="地點經度") 8 | -------------------------------------------------------------------------------- /src/api/schemas/newsletters.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Annotated, Optional 3 | 4 | from pydantic import BaseModel, BeforeValidator, Field, HttpUrl 5 | 6 | from src.utils.schema import url_corrector 7 | 8 | 9 | class NewsletterArticle(BaseModel): 10 | title: Optional[str] = Field(..., description="電子報標題") 11 | link: Optional[Annotated[HttpUrl, BeforeValidator(url_corrector)]] = Field( 12 | ..., description="電子報網址" 13 | ) 14 | date: Optional[str] = Field(None, description="發布日期") 15 | 16 | 17 | class NewsletterInfo(BaseModel): 18 | name: str = Field(..., description="該電子報名稱") 19 | link: Annotated[HttpUrl, BeforeValidator(url_corrector)] = Field( 20 | ..., description="該電子報網址" 21 | ) 22 | details: dict = Field(..., description="該電子報詳細資訊") 23 | articles: list[NewsletterArticle] = Field(..., description="該電子報文章列表") 24 | 25 | 26 | class NewsletterName(str, Enum): 27 | 藝術文化總中心電子報 = "藝術文化總中心電子報" 28 | 域報_Field_Cast = "域報 Field Cast" 29 | 校長同意權人投票事務委員會 = "校長同意權人投票事務委員會" 30 | 國立清華大學校長遴選委員會 = "國立清華大學校長遴選委員會" 31 | 清華校友總會會務訊息 = "清華校友總會會務訊息" 32 | 築思脈動_Pulse_of_Education = "築思脈動(Pulse of Education)" 33 | 心諮系雙週例講座 = "心諮系雙週例講座" 34 | 愛慾電子報報 = "愛慾電子報報" 35 | 教學發展中心電子報_教師 = "教學發展中心電子報-教師" 36 | 清華校友電子報 = "清華校友電子報" 37 | 清華大學化學系電子報 = "清華大學化學系電子報" 38 | 國立清華大學核工暨工科系友會電子報 = "國立清華大學核工暨工科系友會電子報" 39 | 國立清華大學動機系系友電子報 = "國立清華大學動機系系友電子報" 40 | 清華大學化工系友電子報 = "清華大學化工系友電子報" 41 | 國立清華大學學生會電子報 = "國立清華大學學生會電子報" 42 | 台灣語言學通訊 = "台灣語言學通訊" 43 | 教務處綜合教務組電子報 = "教務處綜合教務組電子報" 44 | 課務電子報 = "課務電子報" 45 | 清華大學工工系電子報 = "清華大學工工系電子報" 46 | 語文中心電子報 = "語文中心電子報" 47 | eecs_students = "eecs-students" 48 | 科管院職涯電子報 = "科管院職涯電子報" 49 | 人事室電子報 = "人事室電子報" 50 | 研發處電子報_教職 = "研發處電子報-教職" 51 | 學生事務報_學生 = "學生事務報-學生" 52 | 數學系電子報 = "數學系電子報" 53 | 電機工程學系電子報_學生 = "電機工程學系電子報(學生)" 54 | 秘書處_全校教職員 = "秘書處-全校教職員" 55 | 住宿書院電子報 = "住宿書院電子報" 56 | 主計室電子報 = "主計室電子報" 57 | 國立清華大學圖書館_學生 = "國立清華大學圖書館-學生" 58 | 諮商中心_心窩報報 = "諮商中心-心窩報報" 59 | NTHU_Newsletter = "NTHU-Newsletter" 60 | NTHU_Division_of_Health_Service = "NTHU-Division of Health Service" 61 | 人社院學士班電子報 = "人社院學士班電子報" 62 | 人文社會學院電子報 = "人文社會學院電子報" 63 | 新聞剪輯電子報 = "新聞剪輯電子報" 64 | 清華簡訊 = "清華簡訊" 65 | 計中_教育訓練 = "計中-教育訓練" 66 | -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from . import nthudata 2 | -------------------------------------------------------------------------------- /src/utils/nthudata.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import time 4 | 5 | import httpx 6 | 7 | _cache = {} # 記憶體快取,儲存資料與 commit hash 8 | _base_url = os.getenv("NTHU_DATA_URL", "https://data.nthusa.tw") 9 | _file_details_url = _base_url + "/file_details.json" 10 | _file_details_cache = { 11 | "data": None, 12 | "last_updated": None, 13 | "expiry": 60 * 5, 14 | } # 快取 file_details.json, 預設 5 分鐘過期 15 | 16 | 17 | async def _fetch_json(url: str) -> dict | None: 18 | """ 19 | 私有函式:使用 httpx AsyncClient session 從 URL 非同步獲取 JSON 資料。 20 | 21 | Args: 22 | url (str): 要獲取的 JSON 資料的 URL。 23 | 24 | Returns: 25 | dict or None: 如果成功獲取並解析 JSON 資料,則返回字典;如果發生錯誤,則返回 None。 26 | """ 27 | async with httpx.AsyncClient(http2=True) as client: # 在函式內部建立 AsyncClient 28 | try: 29 | async with client.stream( 30 | "GET", url 31 | ) as response: # 使用 async with 確保資源釋放 32 | response.raise_for_status() # 檢查 HTTP 錯誤 33 | data = await response.aread() # 非同步讀取 response 內容 34 | return json.loads(data) 35 | except httpx.RequestError as e: 36 | print(f"Error fetching {url}: {e}") 37 | return None 38 | except json.JSONDecodeError as e: 39 | print(f"Error decoding JSON from {url}: {e}") 40 | return None 41 | 42 | 43 | async def _format_file_details(file_details: dict) -> list: 44 | """ 45 | 私有函式:格式化 file_details.json 的資料結構。 46 | 47 | 原始的 file_details.json 結構較為巢狀,此函式將其轉換為扁平的列表結構, 48 | 方便後續查詢和使用。 49 | 50 | Args: 51 | file_details (dict): 從 file_details.json 獲取的原始 JSON 資料。 52 | 53 | Returns: 54 | list: 格式化後的檔案詳細資訊列表,每個元素都是一個字典,包含 'name', 'last_commit', 'last_updated' 鍵。 55 | 例如: [{'name': '/announcements.json', 'last_commit': '...', 'last_updated': '...'}, ...] 56 | """ 57 | formatted_file_details = [] 58 | if file_details.get("file_details"): 59 | file_details = file_details["file_details"] 60 | for section, files in file_details.items(): 61 | if section == "/": 62 | section = "" 63 | for file_info in files: 64 | formatted_file_details.append( 65 | { 66 | "name": section + "/" + file_info["name"], 67 | "last_commit": file_info["last_commit"], 68 | "last_updated": file_info["last_updated"], 69 | } 70 | ) 71 | return formatted_file_details 72 | 73 | 74 | async def _update_file_details(): 75 | """ 76 | 私有函式:非同步更新 file_details.json 快取。 77 | 78 | 此函式檢查 file_details.json 快取是否過期或不存在,如果需要更新, 79 | 則會從遠端伺服器獲取最新的 file_details.json 資料並更新快取。 80 | """ 81 | global _file_details_cache 82 | current_time = time.time() 83 | if ( 84 | _file_details_cache["data"] is None 85 | or _file_details_cache["last_updated"] is None 86 | or ( 87 | current_time - _file_details_cache["last_updated"] 88 | > _file_details_cache["expiry"] 89 | ) 90 | ): 91 | print("Updating file_details.json...") 92 | file_details_data = await _fetch_json( 93 | _file_details_url 94 | ) # 使用 await 調用非同步函式 95 | if file_details_data: # 確保成功獲取資料才進行格式化和更新 96 | formatted_file_details_data = await _format_file_details(file_details_data) 97 | _file_details_cache["data"] = formatted_file_details_data 98 | _file_details_cache["last_updated"] = current_time 99 | print("file_details.json updated.") 100 | else: 101 | print("Failed to update file_details.json.") 102 | else: 103 | print("Using cached file_details.json.") 104 | 105 | 106 | async def get_file_details() -> list | None: 107 | """ 108 | 非同步取得 file_details.json 的資料,會自動檢查快取並更新。 109 | 110 | 公開函式,用於獲取格式化後的 file_details.json 資料。此函式會先檢查快取, 111 | 如果快取有效則直接返回快取資料,否則會非同步更新快取後再返回。 112 | 113 | Returns: 114 | list or None: 格式化後的檔案詳細資訊列表,如果獲取失敗則返回 None。 115 | 例如: [{'name': '/announcements.json', 'last_commit': '...', 'last_updated': '...'}, ...] 116 | """ 117 | await _update_file_details() # 使用 await 調用非同步函式 118 | return _file_details_cache["data"] 119 | 120 | 121 | async def get(endpoint_name: str) -> tuple[str, dict | list] | None: 122 | """ 123 | 公開函式:非同步取得指定 endpoint 的 JSON 資料。 124 | 會檢查快取和 commit hash 以判斷是否需要更新。 125 | 126 | 此函式首先會獲取 file_details.json,然後根據 endpoint_name 找到對應的檔案資訊。 127 | 接著檢查快取中是否有該 endpoint 的資料,並比對 commit hash。 128 | 如果快取有效且 commit hash 一致,則返回快取資料;否則從遠端伺服器獲取最新資料並更新快取。 129 | 130 | Args: 131 | endpoint_name (str): endpoint 名稱,例如 "buses.json" 或 "dining/shops.json"。 132 | 133 | Returns: 134 | tuple[str, dict or list] or None: 指定 endpoint 資料的 commit hash,及其 JSON 資料(可以是字典或列表)。 135 | 如果獲取失敗或 endpoint 不存在則返回 None。 136 | """ 137 | if _base_url.endswith("/") and endpoint_name.startswith("/"): 138 | endpoint_name = endpoint_name[1:] 139 | elif not _base_url.endswith("/") and not endpoint_name.startswith("/"): 140 | endpoint_name = "/" + endpoint_name 141 | 142 | file_details_sections = await get_file_details() # 使用 await 調用非同步函式 143 | if file_details_sections is None: 144 | print("Cannot fetch data because file_details.json is unavailable or invalid.") 145 | return None 146 | 147 | expected_commit_hash = None 148 | for file_info in file_details_sections: 149 | if file_info["name"] == endpoint_name: 150 | expected_commit_hash = file_info["last_commit"] 151 | break # 找到對應檔案後跳出迴圈 152 | 153 | cached_data = _cache.get(endpoint_name) 154 | 155 | if cached_data and cached_data.get("commit_hash") == expected_commit_hash: 156 | print(f"Using cached data for '{endpoint_name}'.") 157 | return (cached_data["commit_hash"], cached_data["data"]) 158 | else: 159 | print(f"Fetching fresh data for '{endpoint_name}'...") 160 | 161 | data_url = _base_url + endpoint_name 162 | fresh_data = await _fetch_json(data_url) # 使用 await 調用非同步函式 163 | if fresh_data: 164 | _cache[endpoint_name] = { 165 | "data": fresh_data, 166 | "commit_hash": expected_commit_hash, 167 | } 168 | print(f"Data for '{endpoint_name}' updated in cache.") 169 | return (expected_commit_hash, fresh_data) 170 | else: 171 | print(f"Failed to fetch fresh data for '{endpoint_name}'.") 172 | # 如果獲取失敗,可以選擇返回舊的快取資料 (如果有的話),或者返回 None 173 | return ( 174 | (cached_data["commit_hash"], cached_data.get("data")) 175 | if cached_data 176 | else None 177 | ) 178 | -------------------------------------------------------------------------------- /src/utils/schema.py: -------------------------------------------------------------------------------- 1 | def url_corrector(url: str) -> str: 2 | """ 3 | Fix the URL under different conditions: 4 | If the URL starts with "//", prepend "https:" to it. 5 | If the URL contains "://" but does not start with "http" or "https", prepend "https://" to it. 6 | 7 | Args: 8 | url (str): The URL to be fixed. 9 | 10 | Returns: 11 | str: The fixed URL. 12 | """ 13 | if url is None: 14 | return url 15 | 16 | url = url.strip() 17 | if url.startswith("//"): 18 | return "https:" + url 19 | else: 20 | split_url = url.split("://") 21 | if len(split_url) > 1 and split_url[0] not in ["http", "https"]: 22 | return "https://" + split_url[1] 23 | 24 | return url 25 | -------------------------------------------------------------------------------- /tests/test_announcements.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from src import app 5 | 6 | client = TestClient(app) 7 | department_list = ["清華公佈欄", "國立清華大學學生會"] 8 | 9 | 10 | def test_announcements_endpoints(): 11 | response = client.get(url="/announcements/") 12 | assert response.status_code == 200 13 | 14 | 15 | @pytest.mark.parametrize("department", department_list) 16 | def test_get_announcements_by_department(department): 17 | params = {"department": department} 18 | response = client.get(url="/announcements", params=params) 19 | assert response.status_code == 200 20 | 21 | 22 | def test_search_announcements(): 23 | params = {"query": "清華"} 24 | response = client.get(url="/announcements/search", params=params) 25 | assert response.status_code == 200 26 | -------------------------------------------------------------------------------- /tests/test_buses.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from src import app 5 | from src.api import schemas 6 | 7 | client = TestClient(app) 8 | 9 | 10 | @pytest.mark.parametrize("campus", ["main", "nanda"]) 11 | def test_buses_endpoints(campus): 12 | response = client.get(url=f"/buses/{campus}") 13 | assert response.status_code == 200 14 | 15 | 16 | @pytest.mark.parametrize("bus_type", ["main", "nanda"]) 17 | @pytest.mark.parametrize("direction", ["up", "down"]) 18 | def test_buses_info(bus_type, direction): 19 | response = client.get(url=f"/buses/info/{bus_type}/{direction}") 20 | assert response.status_code == 200 21 | 22 | 23 | def test_buses_stops_info(): 24 | response = client.get(url="/buses/info/stops") 25 | assert response.status_code == 200 26 | 27 | 28 | @pytest.mark.parametrize("bus_type", [_.value for _ in schemas.buses.BusRouteType]) 29 | @pytest.mark.parametrize("day", [_.value for _ in schemas.buses.BusDayWithCurrent]) 30 | @pytest.mark.parametrize("direction", [_.value for _ in schemas.buses.BusDirection]) 31 | def test_buses_schedules(bus_type, day, direction): 32 | response = client.get( 33 | url=f"/buses/schedules/?bus_type={bus_type}&day={day}&direction={direction}" 34 | ) 35 | assert response.status_code == 200 36 | 37 | 38 | @pytest.mark.parametrize("stop_name", [_.value for _ in schemas.buses.BusStopsName]) 39 | @pytest.mark.parametrize("bus_type", [_.value for _ in schemas.buses.BusRouteType]) 40 | @pytest.mark.parametrize("day", [_.value for _ in schemas.buses.BusDayWithCurrent]) 41 | @pytest.mark.parametrize("direction", [_.value for _ in schemas.buses.BusDirection]) 42 | def test_buses_stops(stop_name, bus_type, day, direction): 43 | response = client.get( 44 | url=f"/buses/stops/{stop_name}/?bus_type={bus_type}&day={day}&direction={direction}" 45 | ) 46 | assert response.status_code == 200 47 | 48 | 49 | @pytest.mark.parametrize("bus_type", [_.value for _ in schemas.buses.BusRouteType]) 50 | @pytest.mark.parametrize("day", [_.value for _ in schemas.buses.BusDayWithCurrent]) 51 | @pytest.mark.parametrize("direction", [_.value for _ in schemas.buses.BusDirection]) 52 | def test_buses_detailed(bus_type, day, direction): 53 | response = client.get( 54 | url=f"/buses/detailed?bus_type={bus_type}&day={day}&direction={direction}" 55 | ) 56 | assert response.status_code == 200 57 | -------------------------------------------------------------------------------- /tests/test_courses.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from src import app 5 | from src.api import schemas 6 | 7 | client = TestClient(app) 8 | one_condition = { 9 | "row_field": "chinese_title", 10 | "matcher": "數統導論", 11 | "regex_match": True, 12 | } 13 | two_conditions = [ 14 | {"row_field": "teacher", "matcher": "黃", "regex_match": True}, 15 | "or", 16 | {"row_field": "teacher", "matcher": "孫", "regex_match": True}, 17 | ] 18 | multiple_conditions = [ 19 | {"row_field": "credit", "matcher": "3", "regex_match": True}, 20 | "and", 21 | [ 22 | [ 23 | {"row_field": "id", "matcher": "STAT", "regex_match": True}, 24 | "or", 25 | {"row_field": "id", "matcher": "MATH", "regex_match": True}, 26 | ], 27 | "and", 28 | [ 29 | { 30 | "row_field": "class_room_and_time", 31 | "matcher": "T3T4", 32 | "regex_match": True, 33 | }, 34 | "or", 35 | { 36 | "row_field": "class_room_and_time", 37 | "matcher": "R3R4", 38 | "regex_match": True, 39 | }, 40 | ], 41 | ], 42 | ] 43 | flatten_multiple_conditions = [ 44 | {"row_field": "chinese_title", "matcher": "微積分", "regex_match": True}, 45 | "and", 46 | {"row_field": "credit", "matcher": "4", "regex_match": True}, 47 | "and", 48 | {"row_field": "class_room_and_time", "matcher": "T", "regex_match": True}, 49 | ] 50 | 51 | 52 | @pytest.mark.parametrize( 53 | "url, status_code", 54 | [ 55 | ("/courses/", 200), 56 | ("/courses/lists/microcredits", 200), 57 | ("/courses/lists/xclass", 200), 58 | ], 59 | ) 60 | def test_courses_endpoints(url, status_code): 61 | response = client.get(url=url) 62 | assert response.status_code == status_code 63 | 64 | 65 | @pytest.mark.parametrize( 66 | "field_name", [_.value for _ in schemas.courses.CourseFieldName] 67 | ) 68 | @pytest.mark.parametrize("value", ["中"]) 69 | def test_courses_search(field_name, value): 70 | response = client.get(url=f"/courses/search?{field_name}={value}") 71 | assert response.status_code == 200 72 | 73 | 74 | @pytest.mark.parametrize( 75 | "body", 76 | [one_condition, two_conditions, multiple_conditions, flatten_multiple_conditions], 77 | ) 78 | def test_courses_search_post(body): 79 | response = client.post(url="/courses/search", json=body) 80 | assert response.status_code == 200 81 | -------------------------------------------------------------------------------- /tests/test_departments.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from src import app 5 | 6 | client = TestClient(app) 7 | search_list = ["校長", "高為元", "總務處"] 8 | 9 | 10 | def test_departments_endpoints(): 11 | response = client.get(url="/departments/") 12 | assert response.status_code == 200 13 | 14 | 15 | def test_departments_index(): 16 | response = client.get(url="/departments/01") 17 | assert response.status_code == 200 18 | 19 | 20 | @pytest.mark.parametrize("query", search_list) 21 | def test_departments_search(query): 22 | params = {"query": query} 23 | response = client.get(url="/departments/search/", params=params) 24 | assert response.status_code == 200 25 | -------------------------------------------------------------------------------- /tests/test_dining.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from src import app 5 | from src.api import schemas 6 | 7 | client = TestClient(app) 8 | search_list = ["麥當勞", "7-ELEVEN", "全家便利商店", "路易莎", "清華水漾"] 9 | 10 | 11 | def test_dining_endpoints(): 12 | response = client.get(url="/dining") 13 | assert response.status_code == 200 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "building_name", [_.value for _ in schemas.dining.DiningBuildingName] 18 | ) 19 | def test_dining_buildings(building_name): 20 | params = {"building_name": building_name} 21 | response = client.get(url="/dining", params=params) 22 | assert response.status_code == 200 23 | 24 | 25 | def test_dining_restaurants(): 26 | response = client.get(url="/dining/restaurants") 27 | assert response.status_code == 200 28 | 29 | 30 | @pytest.mark.parametrize( 31 | "schedule", [_.value for _ in schemas.dining.DiningScheduleName] 32 | ) 33 | def test_dining_schedules(schedule): 34 | params = {"schedule": schedule} 35 | response = client.get(url="/dining/restaurants", params=params) 36 | assert response.status_code == 200 37 | 38 | 39 | @pytest.mark.parametrize("query", search_list) 40 | def test_dining_searches_restaurants(query): 41 | params = {"query": query} 42 | response = client.get(url="/dining/search", params=params) 43 | assert response.status_code == 200 44 | -------------------------------------------------------------------------------- /tests/test_energy.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from src import app 4 | 5 | client = TestClient(app) 6 | 7 | 8 | def test_energy(): 9 | response = client.get(url="/energy/electricity_usage") 10 | assert response.status_code == 200 11 | -------------------------------------------------------------------------------- /tests/test_libraries.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from src import app 5 | from src.api import schemas 6 | 7 | client = TestClient(app) 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "url, status_code", 12 | [ 13 | ("/libraries/space", 200), 14 | ("/libraries/lost_and_found", 200), 15 | ("/libraries/goods", 200), 16 | ], 17 | ) 18 | def test_libraries_endpoints(url, status_code): 19 | response = client.get(url=url) 20 | assert response.status_code == status_code 21 | 22 | 23 | @pytest.mark.parametrize("rss", [_.value for _ in schemas.libraries.LibraryRssType]) 24 | def test_libraries_rss(rss): 25 | response = client.get(url=f"/libraries/rss/{rss}") 26 | assert response.status_code == 200 27 | 28 | 29 | @pytest.mark.parametrize( 30 | "library_name", [_.value for _ in schemas.libraries.LibraryName] 31 | ) 32 | def test_libraries_openinghours(library_name): 33 | response = client.get(url=f"/libraries/openinghours/{library_name}") 34 | assert response.status_code == 200 35 | -------------------------------------------------------------------------------- /tests/test_locations.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from src import app 5 | 6 | client = TestClient(app) 7 | search_list = ["校門", "綜合", "台積", "台達"] 8 | 9 | 10 | def test_locations(): 11 | response = client.get(url="/locations") 12 | assert response.status_code == 200 13 | 14 | 15 | @pytest.mark.parametrize("query", search_list) 16 | def test_locations_name(query): 17 | params = {"query": query} 18 | response = client.get(url="/locations/search", params=params) 19 | assert response.status_code == 200 20 | -------------------------------------------------------------------------------- /tests/test_newsletters.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from src import app 5 | from src.api import schemas 6 | 7 | client = TestClient(app) 8 | 9 | 10 | def test_newsletter(): 11 | response = client.get(url="/newsletters/") 12 | assert response.status_code == 200 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "newsletter_name", [_.value for _ in schemas.newsletters.NewsletterName] 17 | ) 18 | def test_newsletter_searches(newsletter_name): 19 | response = client.get(url=f"/newsletters/{newsletter_name}") 20 | assert response.status_code == 200 21 | --------------------------------------------------------------------------------