diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e9bcc88 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +end_of_line = lf +trim_trailing_whitespace = true + +[*.py] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..628d064 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: Build Docker Image + +on: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: jerray/setup-aliyun-cli-action@v1.0.2 + with: + aliyun-cli-version: "3.0.198" + mode: AK + access-key-id: ${{ secrets.ALIBABA_CLOUD_ACCESS_KEY_ID }} + access-key-secret: ${{ secrets.ALIBABA_CLOUD_ACCESS_KEY_SECRET }} + region: cn-beijing + - id: aliyun-cr-token + name: Obtain Aliyun Container Registry credentials + run: | + aliyun cr --force --version 2016-06-07 GET /tokens > cr_token.json + TOKEN=$(jq -r '.data.authorizationToken' cr_token.json) + echo "::add-mask::$TOKEN" + echo "json<> $GITHUB_OUTPUT + cat cr_token.json >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + rm -f cr_token.json + - name: Login to Aliyun Container Registry + uses: docker/login-action@v2 + with: + registry: registry.cn-beijing.aliyuncs.com + username: ${{ fromJson(steps.aliyun-cr-token.outputs.json).data.tempUserName }} + password: ${{ fromJson(steps.aliyun-cr-token.outputs.json).data.authorizationToken }} + - id: meta + uses: docker/metadata-action@v5 + with: + images: | + registry.cn-beijing.aliyuncs.com/jingbh/bjut-electricity-bill + tags: | + type=edge + type=ref,event=tag + type=sha + - uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0649138 --- /dev/null +++ b/.gitignore @@ -0,0 +1,279 @@ +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,pycharm+all,python +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,pycharm+all,python + +### PyCharm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# Support for Project snippet scope +.vscode/*.code-snippets + +# Ignore code-workspaces +*.code-workspace + +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,pycharm+all,python diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9763146 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM debian:12-slim AS build + +RUN apt-get update && \ + apt-get install --no-install-suggests --no-install-recommends --yes git python3-venv gcc libpython3-dev && \ + python3 -m venv /venv && \ + /venv/bin/pip install --upgrade pip setuptools wheel + +FROM build AS build-venv + +WORKDIR /app + +COPY requirements.txt ./ + +RUN /venv/bin/pip install --disable-pip-version-check --no-cache-dir --upgrade -r requirements.txt + +FROM gcr.io/distroless/python3-debian12 + +WORKDIR /app + +COPY --from=build-venv /venv /venv +COPY main.py ./ + +ENTRYPOINT ["/venv/bin/python3", "main.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9fc652d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 bjut.tech + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/main.py b/main.py new file mode 100644 index 0000000..909029c --- /dev/null +++ b/main.py @@ -0,0 +1,101 @@ +import sys +import time + +from aliyun.log import LogClient, PutLogsRequest, LogItem +from environs import Env +from httpx import Client + + +class YdappClient: + def __init__(self, openid: str): + self.openid = openid + self.client = Client(base_url='https://ydapp.bjut.edu.cn', params={ + 'openid': openid, + 'orgid': 2 + }, headers={ + 'User-Agent': 'Mozilla/5.0' + }) + self.authenticate() + + def authenticate(self): + self.client.get('/home/openHomePageApp') # initialize cookies + + def query_balance(self, room_id: int) -> float: + response = self.client.get('/channel/querySydl', params={ + 'group_id': room_id, + 'factorycode': 'N002' + }, follow_redirects=False) + if response.has_redirect_location and 'error' in response.headers['Location'].lower(): + self.authenticate() + response = self.client.get('/channel/querySydl', params={ + 'group_id': room_id, + 'factorycode': 'N002' + }, follow_redirects=False) + response.raise_for_status() + return float(response.json()['resultData']['MeterBalance']) + + +class MetricClient: + def __init__(self, access_key_id: str, access_key_secret: str, endpoint: str, project: str, store: str): + self.client = LogClient(endpoint, access_key_id, access_key_secret) + self.project = project + self.store = store + self.entries = [] + + def __del__(self): + if len(self.entries) > 0: + self.flush() + + def write(self, name: str, labels: dict, value: float): + nano = 10 ** 9 + now = time.time_ns() + self.entries.append(LogItem( + timestamp=now // nano, + time_nano_part=now % nano, + contents=[ + ('__name__', name), + ('__labels__', '|'.join([f'{k}#$#{v}' for k, v in labels.items() if v])), + ('__time_nano__', str(now)), + ('__value__', str(value)) + ] + )) + print(f'[{now // nano}] meter balance: {value}') + if len(self.entries) >= 10: + self.flush() + + def flush(self): + items = self.entries.copy() + self.entries.clear() + if len(items) == 0: + return + print(f'flushing {len(items)} entries') + req = PutLogsRequest(self.project, self.store, logitems=items) + self.client.put_logs(req) + + +def main(): + env = Env() + env.read_env() + + client = YdappClient(env.str('YDAPP_OPENID')) + metric_client = MetricClient( + env.str('ALIBABA_CLOUD_ACCESS_KEY_ID'), + env.str('ALIBABA_CLOUD_ACCESS_KEY_SECRET'), + env.str('ALIBABA_CLOUD_SLS_ENDPOINT'), + env.str('ALIBABA_CLOUD_SLS_PROJECT'), + env.str('ALIBABA_CLOUD_SLS_STORE') + ) + room_id = env.int('YDAPP_ROOM_ID') + while True: + try: + balance = client.query_balance(room_id) + metric_client.write('ac_meter_balance', { + 'room_id': room_id + }, balance) + except Exception as e: + print(e, file=sys.stderr) + time.sleep(30) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6379517 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +aliyun-log-python-sdk +environs +httpx