diff --git a/.gitignore b/.gitignore index 3ed3100..3cd9bba 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ tmp .vscode .DS_Store -*.html *yml *.log *.sh diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..cd0d007 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1 @@ +*.sh \ No newline at end of file diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..fdf1331 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-alpine + +RUN apk add tzdata && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo Asia/Shanghai > /etc/timezone && apk add ca-certificates + +RUN apk add --update --no-cache python3 py3-pip nginx \ + && mkdir -p /run/nginx \ + && rm -rf /var/cache/apk/* + +WORKDIR /app + +COPY ./requirements.txt /app/requirements.txt + +RUN pip config set global.index-url http://mirrors.cloud.tencent.com/pypi/simple \ + && pip config set global.trusted-host mirrors.cloud.tencent.com \ + && pip install --upgrade pip --break-system-packages \ + && pip install --user -r requirements.txt --break-system-packages + +COPY nginx.conf /etc/nginx/nginx.conf + +COPY . /app + +CMD ["sh", "-c", "python3 app.py & nginx -g 'daemon off;'"] \ No newline at end of file diff --git a/web/Dockerfile.zh b/web/Dockerfile.zh new file mode 100644 index 0000000..1956c4c --- /dev/null +++ b/web/Dockerfile.zh @@ -0,0 +1,23 @@ +FROM docker.proxy.yangrucheng.top/python:3.12-alpine + +RUN apk add tzdata && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo Asia/Shanghai > /etc/timezone && apk add ca-certificates + +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tencent.com/g' /etc/apk/repositories \ + && apk add --update --no-cache python3 py3-pip nginx \ + && mkdir -p /run/nginx \ + && rm -rf /var/cache/apk/* + +WORKDIR /app + +COPY ./requirements.txt /app/requirements.txt + +RUN pip config set global.index-url http://mirrors.cloud.tencent.com/pypi/simple \ + && pip config set global.trusted-host mirrors.cloud.tencent.com \ + && pip install --upgrade pip --break-system-packages \ + && pip install --user -r requirements.txt --break-system-packages + +COPY nginx.conf /etc/nginx/nginx.conf + +COPY . /app + +CMD ["sh", "-c", "python3 app.py & nginx -g 'daemon off;'"] \ No newline at end of file diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..207ee3c --- /dev/null +++ b/web/README.md @@ -0,0 +1,3 @@ +# 小程序的网页入口 + +> 为了减少小程序被封风险而添加的网页入口,没有任何功能 \ No newline at end of file diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..2df0795 --- /dev/null +++ b/web/app.py @@ -0,0 +1,140 @@ + +from fastapi.responses import JSONResponse, RedirectResponse, Response +from fastapi import FastAPI, Body, APIRouter, Request +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager +import datetime +import uvicorn +import socket +import httpx + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + +app = FastAPI(lifespan=lifespan, redoc_url=None, docs_url=None) + + +@app.get("/api/login") +async def login(request: Request, username: str, password: str): + """ 登录 """ + async with httpx.AsyncClient() as client: + resp = await client.get("https://passport2-api.chaoxing.com/v11/loginregister", params={ + "cx_xxt_passport": "json", + "roleSelect": "true", + "uname": username, + "code": password, + "loginType": "1", + }) + resp2 = JSONResponse(resp.json()) + for key, value in resp.cookies.items(): + resp2.set_cookie(key, value, max_age=3600*24*7) + return resp2 + + +@app.get("/api/get_courses") +async def get_courses(request: Request): + """ 获取课程列表 """ + async with httpx.AsyncClient(cookies=dict(request.cookies)) as client: + resp = await client.get("https://mooc1-api.chaoxing.com/mycourse/backclazzdata", params={ + 'view': 'json', + 'rss': '1', + }) + return JSONResponse(resp.json()) + + +app.mount("/", StaticFiles(directory="public"), name="public") + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + if exc.status_code == 404: + return RedirectResponse("/index.html", status_code=302) + else: + return JSONResponse({ + "status": -1, + "msg": exc.detail, + "data": None, + "time": int(datetime.datetime.now().timestamp()), + }, status_code=exc.status_code) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + return JSONResponse({ + "status": -1, + "msg": "参数错误, 请检查参数", + "data": { + "body": exc.body, + "query": { + "raw": str(request.query_params), + "parsed": dict(request.query_params), + }, + "error": exc.errors(), + }, + "time": int(datetime.datetime.now().timestamp()), + }, status_code=422) + + +@app.exception_handler(Exception) +async def exception_handler(request: Request, exc: Exception): + return JSONResponse({ + "status": -1, + "msg": "服务器内部错误, 请联系管理员! 邮箱: admin@misaka-network.top", + "time": int(datetime.datetime.now().timestamp()), + }, status_code=500) + + +@app.middleware("http") +async def add_process_time_header(request: Request, call_next): + starttime = datetime.datetime.now() + response: Response = await call_next(request) + endtime = datetime.datetime.now() + response.headers["X-Process-Time"] = str( + int((endtime - starttime).total_seconds())) + response.headers["X-Client-Host"] = request.client.host + return response + + +def get_localhost(): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + except Exception as e: + return None + else: + return ip + finally: + s.close() + + +if __name__ == "__main__": + starttime = datetime.datetime.now().strftime(r"%Y-%m-%d %H:%M:%S") + from utils.logger import set_log_formatter + set_log_formatter() + import logging as lg + logging = lg.getLogger("uvicorn") + try: + logging.info(f"服务已启动, 请访问 http://{get_localhost()}:8080") + uvicorn.run( + app="app:app", + host="0.0.0.0", + port=8080, + reload=False, + forwarded_allow_ips="*", + log_config=None, + workers=1, + headers=[ + ("Server", "Misaka Network Distributed Server"), + ("X-Powered-By", "Misaka Network Studio"), + ("X-Statement", "This service is provided by Misaka Network Studio. For complaints/cooperation, please email admin@misaka-network.top"), + ("X-Copyright", "© 2024 Misaka Network Studio. All rights reserved."), + ("X-Server-Start-Time", starttime), + ], + ) + except KeyboardInterrupt: + logging.info("Ctrl+C 终止服务") diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..d38faaf --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,42 @@ +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /dev/null main; + + sendfile on; + keepalive_timeout 65; + + include /etc/nginx/conf.d/*.conf; + + server { + listen 80; + server_name localhost; + + location / { + root /app/public; + index index.html; + } + + location /api { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + } +} diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000..2c96a16 Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/public/index.html b/web/public/index.html new file mode 100644 index 0000000..6dafdd4 --- /dev/null +++ b/web/public/index.html @@ -0,0 +1,265 @@ + + + + + + + + 学习通签到 · 御坂网络Misaka + + + + +
+

XXT登录

+ +
如有侵权,请发邮件至 admin@micono.eu.org
+
打击盗卖,受骗者请立即退款投诉!
+
+ 本网页由公众号【御坂网络Misaka】免费提供! +
+
点击此处进入官方网页版
+ + + + +
+
+ + + + + + + \ No newline at end of file diff --git a/web/public/utils/api.js b/web/public/utils/api.js new file mode 100644 index 0000000..d6fe55b --- /dev/null +++ b/web/public/utils/api.js @@ -0,0 +1,109 @@ +import http from './http.js'; +import util from './util.js'; + +class API { + constructor(username, password = "") { + this.updatetime = util.getStorage(`cookies-updatetime-${username}`, 0) + this.uid = ""; + this.username = username; + this.password = password; + } + + /** + * 检查登录 + */ + checkLogin = async () => { + if (this.updatetime + 7 * 24 * 3600 * 1000 <= new Date().getTime()) { + console.debug("自动登录 登录过期") + await this.login(); + } + } + + /** + * 登录 + */ + login = async () => { + if (!this.username || !this.password) + return; + const res = await http.get('/api/login', { + "username": this.username, + "password": this.password, + }); + this.updatetime = new Date().getTime(); + util.setStorage(`cookies-updatetime-${this.username}`, this.updatetime); + console.info("登录", res); + await this.getUID(); + return res; + } + + /** + * 获取UID + */ + getUID = () => { + this.uid = ""; + return this.uid; + } + + /** + * 获取课程列表 + */ + getCourses = async () => { + await this.checkLogin(); + const url = '/api/get_courses'; + const res = await http.get(url) + let data = res.channelList.filter(item => item.cataName == '课程').map(item => { + return { + 'courseName': item.content.course ? item.content.course.data[0]?.name : item.content.name, + 'className': item.content.course ? item.content.name : item.content.clazz[0]?.clazzName, + 'teacherName': item.content.course ? item.content.course.data[0]?.teacherfactor : item.content.teacherfactor, + 'courseId': item.content.course ? item.content.course.data[0]?.id : item.content.id, + 'classId': item.content.course ? item.key : item.content.clazz[0]?.clazzId, + 'folder': (res.channelList.find(i => i.catalogId == item.cfid) || {}).content?.folderName || null, // 所在的文件夹 + 'isTeach': !item.content.course, // 是否自己教的课 + 'img': item.content.course ? item.content.course.data[0].imageurl : item.content.imageurl, + }; + }); + data.sort((a, b) => b.isShow - a.isShow); + console.info("获取课程", res, data) + return data; + } + + /** + * 获取小程序链接 + * @param {*} courseId + * @param {*} classId + */ + getWechatUrl = (courseId, classId) => { + const query = Object.entries({ + 'username': this.username, + 'password': this.password, + 'courseId': courseId, + 'classId': classId, + 'package': 'sign', + 'path': '/activity/activity', + }) + .map(([key, value]) => `${key}=${value}`) + .join('&') + + const url = `weixin://dl/business/?appid=wxb42fe32e6e071916&path=pages/share/share&query=${encodeURIComponent(query)}`; + return url; + } + + /** + * 小程序入口 + * @returns + */ + getMiniProgram = () => { + const query = Object.entries({ + 'path': '/packages/sign-package/pages/home/home', + 'appid': 'wx0ba7981861be3afc', + }) + .map(([key, value]) => `${key}=${value}`) + .join('&') + + const url = `weixin://dl/business/?appid=wxb42fe32e6e071916&path=pages/share/share&query=${encodeURIComponent(query)}`; + return url; + } +} + +export default API; \ No newline at end of file diff --git a/web/public/utils/http.js b/web/public/utils/http.js new file mode 100644 index 0000000..232be36 --- /dev/null +++ b/web/public/utils/http.js @@ -0,0 +1,22 @@ +class HTTP { + get = (url, data = {}) => { + return new Promise((resolve, reject) => { + const _url = `${url}?${Object.keys(data).map(key => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`).join('&')}`; + fetch(_url, { + method: "GET", + headers: { + 'Content-Type': 'application/json', + }, + }).then(res => res.json()).then(res => { + resolve(res); + }).catch(err => { + reject(err); + }); + }); + } + +} + +const http = new HTTP(); + +export default http; \ No newline at end of file diff --git a/web/public/utils/util.js b/web/public/utils/util.js new file mode 100644 index 0000000..160d3e6 --- /dev/null +++ b/web/public/utils/util.js @@ -0,0 +1,8 @@ +export default { + getStorage: (key, defaultValue = null) => { + return localStorage.getItem(key) || defaultValue; + }, + setStorage: (key, value) => { + localStorage.setItem(key, value); + }, +} \ No newline at end of file diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..79ed6d5 --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,5 @@ +httpx +httpx[http2] +starlette +uvicorn +fastapi \ No newline at end of file diff --git a/web/utils/logger.py b/web/utils/logger.py new file mode 100644 index 0000000..593dc48 --- /dev/null +++ b/web/utils/logger.py @@ -0,0 +1,32 @@ +import sys + +def set_log_formatter(): + import logging + " ANSI 转义码设置颜色 " + TIME_COLOR = "\033[32m" + LEVEL_COLOR = "\033[33m" + RESET = "\033[0m" + LOG_FORMATE = \ + f"{TIME_COLOR}%(asctime)s{RESET} - {LEVEL_COLOR}%(levelname)s{RESET} - %(message)s" + + logging.basicConfig( + level=logging.INFO, format=LOG_FORMATE, stream=sys.stdout) + + # uvicorn 日志 + # logger = logging.getLogger("uvicorn") + # logger.handlers = [] + # console_handler = logging.StreamHandler() + # console_handler.setFormatter(logging.Formatter(LOG_FORMATE)) + # logger.addHandler(console_handler) + + # access_logger = logging.getLogger("uvicorn.access") + # access_logger.handlers = [] + # access_handler = logging.StreamHandler() + # access_handler.setFormatter(logging.Formatter(LOG_FORMATE)) + # access_logger.addHandler(access_handler) + + # httpx 日志 + logger = logging.getLogger("httpx") + logger.setLevel(logging.WARNING) + +set_log_formatter() \ No newline at end of file