├── .dockerignore
├── .flake8
├── .gitignore
├── Dockerfile
├── LICENSE
├── app_configs_tmpl.py
├── biligank_flask
├── __init__.py
├── jinja_filters.py
├── kvdb.py
├── logger.py
├── logging.py
├── main.py
├── mongodb.py
├── templates
│ ├── 404.html
│ ├── _maintain
│ │ ├── QA.tmpl.html
│ │ └── live_index_notice.tmpl.html
│ ├── about.html
│ ├── base.html
│ ├── feedback.tmpl.html
│ ├── live
│ │ ├── ablive_dm.tmpl.html
│ │ ├── ablive_en.tmpl.html
│ │ ├── ablive_gf.tmpl.html
│ │ ├── ablive_sc.tmpl.html
│ │ ├── index.html
│ │ ├── pagination.html
│ │ └── tp.tmpl.html
│ └── macro.html
├── utils.py
└── views
│ ├── __init__.py
│ ├── general.py
│ └── live
│ ├── __init__.py
│ ├── ablive_searcher.py
│ ├── livedm_searcher.py
│ ├── liveroom_searcher.py
│ └── view.py
├── dev-2.1.flaskenv
├── dev-2.2.flaskenv
├── mypy.ini
├── requirements.txt
├── run.bat
└── serve.sh
/.dockerignore:
--------------------------------------------------------------------------------
1 |
2 | **/__pycache__*
3 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 88
3 | extend-ignore = E251
4 | exclude =
5 | .*,
6 | __pycache__,
7 | utils.py,
8 | __init__.py,
9 | app_configs*,
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
105 | __pypackages__/
106 |
107 | # Celery stuff
108 | celerybeat-schedule
109 | celerybeat.pid
110 |
111 | # SageMath parsed files
112 | *.sage.py
113 |
114 | # Environments
115 | .env
116 | .venv
117 | env/
118 | venv/
119 | ENV/
120 | env.bak/
121 | venv.bak/
122 |
123 | # Spyder project settings
124 | .spyderproject
125 | .spyproject
126 |
127 | # Rope project settings
128 | .ropeproject
129 |
130 | # mkdocs documentation
131 | /site
132 |
133 | # mypy
134 | .mypy_cache/
135 | .dmypy.json
136 | dmypy.json
137 |
138 | # Pyre type checker
139 | .pyre/
140 |
141 | # pytype static type analyzer
142 | .pytype/
143 |
144 | # Cython debug symbols
145 | cython_debug/
146 |
147 |
148 | .flaskenv
149 |
150 | app_configs.py
151 | logs/*
152 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.10-slim-buster
2 |
3 | COPY requirements.txt /tmp/requirements.txt
4 | RUN pip install --no-cache-dir -r /tmp/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
5 |
6 | WORKDIR /app
7 | VOLUME /app/logs
8 |
9 | COPY biligank_flask biligank_flask/
10 |
11 | COPY serve.sh app_configs.py ./
12 | RUN chmod +x ./serve.sh
13 |
14 | EXPOSE 7771
15 |
16 | CMD ["bash", "./serve.sh"]
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Grvzard
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app_configs_tmpl.py:
--------------------------------------------------------------------------------
1 |
2 | class Config:
3 | ERROR_TEXT = "出错了。"
4 | ABLIVE = {
5 | "MONGO_CONFIG": "mongodb://localhost:27017/",
6 | "ROADS": ["ablive_dm", "ablive_en", "ablive_gf", "ablive_sc", "tp"],
7 | "LIMITS": {
8 | 'ablive_dm': 2,
9 | 'ablive_en': 5,
10 | 'ablive_gf': 2,
11 | 'ablive_sc': 1,
12 | 'tp': 5,
13 | 'livedm': 9,
14 | },
15 | }
16 | # https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/
17 | SQLALCHEMY_TRACK_MODIFICATIONS = False
18 | SQLALCHEMY_DATABASE_URI = "mysql+pymysql://localhost:3306/tp"
19 | SQLALCHEMY_BINDS = {
20 | "ablive_dm": "mysql+pymysql://localhost:3306/ablive_dm",
21 | "ablive_en": "mysql+pymysql://localhost:3306/ablive_en",
22 | "ablive_gf": "mysql+pymysql://localhost:3306/ablive_gf",
23 | "ablive_sc": "mysql+pymysql://localhost:3306/ablive_sc",
24 | "tp": "mysql+pymysql://localhost:3306/tp",
25 | }
26 | SEARCH_LOGGER = {
27 | "json": "search_log.json",
28 | }
29 | FEEDBACK_LOGGER = {
30 | "json": "feedback.json",
31 | }
32 | KV_DB = {
33 | "config": "",
34 | }
35 |
--------------------------------------------------------------------------------
/biligank_flask/__init__.py:
--------------------------------------------------------------------------------
1 | from gevent import monkey
2 |
3 | monkey.patch_all()
4 |
5 | from .main import app
6 |
--------------------------------------------------------------------------------
/biligank_flask/jinja_filters.py:
--------------------------------------------------------------------------------
1 |
2 | from .utils import ts2clock, ts2date, ts2date_2
3 |
4 |
5 | def register_jinja_filters(app):
6 | app.jinja_env.filters['strftime'] = ts2date
7 | app.jinja_env.filters['strftime_2'] = ts2date_2
8 | app.jinja_env.filters['ts2clock'] = ts2clock
9 |
--------------------------------------------------------------------------------
/biligank_flask/kvdb.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | import pymongo
4 |
5 |
6 | class KvDb:
7 | def __init__(self, app = None):
8 | if app is not None:
9 | self.init_app(app)
10 |
11 | def init_app(self, app) -> None:
12 | config = app.config['KV_DB'] and app.config['KV_DB']['config']
13 | if config:
14 | client = pymongo.MongoClient(config)
15 | self.coll = client['biligank_web']['var']
16 |
17 | self._available = bool(config)
18 |
19 | app.extensions['kvdb'] = self
20 |
21 | def get(self, key: str) -> Any:
22 | if not self._available:
23 | return None
24 |
25 | var = self.coll.find_one({'key': key})
26 | if var:
27 | return var['value']
28 | else:
29 | return None
30 |
31 | def set(self, key: str, value: Any) -> None:
32 | ...
33 |
--------------------------------------------------------------------------------
/biligank_flask/logger.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 | from typing import Final, Dict
4 |
5 | import pymongo
6 | import httpx
7 | from pydantic import BaseModel
8 |
9 | from .utils import get_date
10 |
11 | __all__ = 'MultiLogger', 'JsonLogger', 'MongoLogger', 'TgbotLogger'
12 |
13 |
14 | class MultiLogger:
15 | def __init__(self, **loggers):
16 | self.loggers = []
17 | for type_, setting in loggers.items():
18 | if type_.startswith('mongo'):
19 | h = MongoLogger(setting)
20 | elif type_.startswith('json'):
21 | h = JsonLogger(setting)
22 | elif type_.startswith('tgbot'):
23 | h = TgbotLogger(setting)
24 | else:
25 | raise Exception('unknown logger type')
26 | self.loggers.append(h)
27 |
28 | def log(self, log_info):
29 | for logger in self.loggers:
30 | logger.log(log_info)
31 |
32 |
33 | class JsonLogger:
34 | def __init__(self, file_name):
35 | Path('logs').mkdir(exist_ok=True)
36 | self.file = Path('logs') / file_name
37 |
38 | def log(self, log_info):
39 | with self.file.open('a', encoding='utf-8') as f:
40 | json.dump(log_info, f, ensure_ascii=False, indent=4)
41 | f.write(',')
42 |
43 |
44 | class MongoLogger:
45 | def __init__(self, setting):
46 | self.mongo_client: pymongo.MongoClient = pymongo.MongoClient(setting['config'])
47 | self.db = self.mongo_client[setting['db']]
48 |
49 | def log(self, log_info):
50 | self.db[get_date()].insert_one(log_info)
51 |
52 |
53 | class TgbotConfig(BaseModel):
54 | token: str
55 | chat_id: str
56 | tag_prefix: str = 'biligank'
57 | proxy: str = ''
58 |
59 |
60 | class TgbotLogger:
61 | HTML_ENTITIES: Final[Dict[str, str]] = {
62 | '<': '<',
63 | '>': '>',
64 | '&': '&',
65 | }
66 |
67 | def __init__(self, setting: dict):
68 | self._config = TgbotConfig(**setting)
69 | self._proxies = {}
70 | if self._config.proxy:
71 | self._proxies['https://'] = self._config.proxy
72 |
73 | def log(self, log_info):
74 | url = f"https://api.telegram.org/bot{self._config.token}/sendMessage"
75 |
76 | word_list = []
77 | for info in log_info.values():
78 | i = str(info)
79 | for c in i:
80 | if (r := self.HTML_ENTITIES.get(c, None)):
81 | word_list.append(r)
82 | else:
83 | word_list.append(c)
84 | word_list.append('\n')
85 |
86 | text = f"#{self._config.tag_prefix}\n" + ''.join(word_list)
87 |
88 | payload = {
89 | "chat_id": self._config.chat_id,
90 | "text": text,
91 | "parse_mode": "HTML",
92 | 'disable_web_page_preview': True,
93 | }
94 |
95 | resp = httpx.post(url, json=payload, timeout=10, proxies=self._proxies).json()
96 |
97 | if resp['ok']:
98 | return True
99 | else:
100 | raise Exception(
101 | 'tgbot send msg failed: [%s] %s'
102 | % (resp['result']['error_code'], resp['result']['description'])
103 | )
104 |
--------------------------------------------------------------------------------
/biligank_flask/logging.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from pathlib import Path
3 |
4 | from flask import has_request_context, request
5 |
6 |
7 | class RequestFormatter(logging.Formatter):
8 | def format(self, record):
9 | if has_request_context():
10 | record.path = request.full_path
11 | record.remote_addr = request.remote_addr
12 | else:
13 | record.path = None
14 | record.remote_addr = None
15 |
16 | return super().format(record)
17 |
18 |
19 | def configure_logging(app):
20 | Path('logs').mkdir(exist_ok=True)
21 | fmt = RequestFormatter(
22 | '[%(asctime)s] [%(remote_addr)s] %(path)s\n -- %(message)s'
23 | )
24 | error_handler = logging.FileHandler('logs/error.log')
25 | error_handler.setLevel(logging.ERROR)
26 | error_handler.setFormatter(fmt)
27 |
28 | app.logger.addHandler(error_handler)
29 |
--------------------------------------------------------------------------------
/biligank_flask/main.py:
--------------------------------------------------------------------------------
1 |
2 | from flask import Flask, render_template, current_app
3 |
4 |
5 | app = Flask(__name__)
6 |
7 |
8 | if app.debug:
9 | app.config.from_object('app_configs.DevConfig')
10 | else:
11 | app.config.from_object('app_configs.ProdConfig')
12 |
13 | from flask_sqlalchemy import SQLAlchemy
14 | from .mongodb import MongoDB
15 | from .kvdb import KvDb
16 | sqldb = SQLAlchemy(app)
17 | mongodb = MongoDB(app)
18 | kvdb = KvDb(app)
19 |
20 | with app.app_context():
21 | from .views import general, live
22 | app.register_blueprint(general.bp)
23 | app.register_blueprint(live.bp)
24 |
25 | from .jinja_filters import register_jinja_filters
26 | register_jinja_filters(app)
27 |
28 | from .logging import configure_logging
29 | configure_logging(app)
30 |
31 |
32 | @app.errorhandler(Exception)
33 | def default_error(e):
34 | current_app.logger.error(str(e))
35 | return {
36 | 'html': current_app.config['ERROR_TEXT']
37 | }
38 |
39 |
40 | @app.errorhandler(404)
41 | def page_notfound(error):
42 | return render_template('404.html'), 404
43 |
--------------------------------------------------------------------------------
/biligank_flask/mongodb.py:
--------------------------------------------------------------------------------
1 |
2 | import pymongo
3 | from flask import Flask
4 |
5 |
6 | class MongoDB:
7 | client = None
8 |
9 | def __init__(self, app: Flask = None):
10 | if app is not None:
11 | self.init_app(app)
12 |
13 | def init_app(self, app) -> None:
14 | config = app.config['ABLIVE']['MONGO_CONFIG']
15 | self.client = pymongo.MongoClient(config)
16 |
17 | app.extensions['mongo_client'] = self.client
18 |
--------------------------------------------------------------------------------
/biligank_flask/templates/404.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block content %}
4 |
5 | 404 Page Not Found
6 |
7 | {% endblock content %}
8 |
--------------------------------------------------------------------------------
/biligank_flask/templates/_maintain/QA.tmpl.html:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Grvzard/biligank-flask/44248ee56fae138d705374e77cc66b8af921323c/biligank_flask/templates/_maintain/QA.tmpl.html
--------------------------------------------------------------------------------
/biligank_flask/templates/_maintain/live_index_notice.tmpl.html:
--------------------------------------------------------------------------------
1 | {% from "macro.html" import card%}
2 |
3 | {{ card(
4 | 'Github: biligank web flask' |safe,
5 | '',)
6 | }}
7 |
--------------------------------------------------------------------------------
/biligank_flask/templates/about.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "macro.html" as m %}
3 |
4 |
5 | {% macro log(date, content, checked='') -%}
6 |
7 |
8 |
10 |
11 | {%- endmacro %}
12 |
13 |
14 | {% block content %}
15 |
16 |
17 |
18 | -
19 |
关于查弹幕功能:
20 |
21 |
22 |
23 | ⚙️ 原理
24 |
25 |
26 | 与主播用的弹幕姬类似,就是录制直播间的弹幕/礼物消息。
27 | 然后同时录制许多直播间,根据用户uid查找消息。
28 |
29 |
30 |
31 |
32 |
33 | 📑 日志
34 |
35 |
36 | {{ log('2023-07-28', '关机。', 'checked') }}
37 | {{ log('2023-07-27', 'b站弹幕服务器对高并发ws连接做限制。', 'checked') }}
38 | {{ log('2023-06-30', 'b站弹幕服务器对用户弹幕数据做脱敏处理。', 'checked') }}
39 | {{ log('2023-06-10', '添加守护圣殿记录', 'checked') }}
40 | {{ log('2023-06-10', '更改数据库 schema', 'checked') }}
41 | {{ log('2023-06-06', '增加同时录制上限', 'checked') }}
42 | {{ log('2023-05-24', '更改房间调度分区权重。', 'checked') }}
43 | {{ log('2022-11-25', '硬盘重置。11月直播记录丢失。', 'checked') }}
44 | {{ log('2022-10-27', '更改房间调度策略 (分区加权)。', 'checked') }}
45 | {{ log('2022-09-20', '15点到21点数据丢失。直播记录中断。', 'checked') }}
46 | {{ log('2022-09-04', '增加同时录制上限', 'checked') }}
47 | {{ log('2022-08-20', '增加同时录制上限', 'checked') }}
48 | {{ log('2022-08-19', '出bug产生了许多重复数据。', 'checked') }}
49 | {{ log('2022-08-12', '更改录制策略 (blive -> ablive)', 'checked') }}
50 | {{ log('2022-07-04', '之前礼物数量会不正确。', 'checked') }}
51 | {{ log('2022-06-09', '开机。', 'checked') }}
52 |
53 |
54 |
55 |
56 |
57 | 直播记录
58 |
59 |
60 | ① 都能查到,但不一定会录到。
61 | ② 并非准确时间,正常会比开播晚&比下播早几分钟。
62 |
63 |
64 | {#
65 |
66 |
67 | 🚦 监控
68 |
69 | {% if status %}
70 | {% for module in status.values() %}
71 | {{module.name}} -- {{module.value}}
72 | {% endfor %}
73 | {% if status.live_monitor.ok %}
74 |
75 | ok
76 | {% else %}
77 |
78 | warning
79 | {% endif %}
80 | {% endif %}
81 |
82 | #}
83 |
84 |
85 |
86 | -
87 | 🦉 project-src: {{ m.link("https://github.com/Grvzard/biligank-flask", "web") }}
88 |
89 |
90 | -
91 | 📧 support: s@biligank.com
92 |
93 |
94 | -
95 | 友链~
96 | 🐟 {{ m.link("https://rec.koifish.fans/", "奶粉の录播站") }}
97 | 🍵 {{ m.link("https://laplace.live/", "LAPLACE ✽") }}
98 | 🎃 {{ m.link("https://danmakus.com/", "Danmakus 弹幕站") }}
99 | 🔯 {{ m.link("https://zeroroku.com", "ZeroRoku 数据观测")}}
100 |
101 |
102 |
103 |
104 | {{ m.card("有问题会在这里解释👇") }}
105 | {% include "_maintain/QA.tmpl.html" %}
106 |
107 |
108 |
109 |
110 | {# include "donate.html" #}
111 |
112 |
113 | {% endblock %}
114 |
--------------------------------------------------------------------------------
/biligank_flask/templates/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | biligank
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {% block scripts %}{% endblock %}
22 |
23 |
24 |
25 |
26 | {% block navbar %}
27 |
28 |
38 |
39 | {% include "feedback.tmpl.html" %}
40 |
41 |
42 | {% endblock navbar %}
43 |
44 |
45 |
46 | {% block content %}
47 | {% endblock content %}
48 |
49 |
50 | {% block footer %}
51 |
52 |
53 |
54 |
55 | {% endblock footer %}
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/biligank_flask/templates/feedback.tmpl.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
11 |
12 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
65 |
--------------------------------------------------------------------------------
/biligank_flask/templates/live/ablive_dm.tmpl.html:
--------------------------------------------------------------------------------
1 | {% from "macro.html" import space %}
2 |
3 |
4 | {% block result %}
5 |
6 |
7 | {% block danmaku_filter %}
8 | {# if first_time #}
9 | {% if False %}
10 |
22 |
23 |
41 | {% endif %}
42 | {% endblock danmaku_filter %}
43 |
44 |
45 | {% for card in data %}
46 | {% set date, liverid, danmakus = card.values() %}
47 | {% set liver_uname = rooms_dict[liverid].uname %}
48 |
49 |
53 |
54 | {% for danmaku in danmakus %}
55 |
56 | {{ danmaku[0] |ts2clock }} -> {{ danmaku[1] }}
57 |
58 | {% endfor %}
59 |
60 |
61 | {% endfor %}
62 |
63 |
64 | {% include "live/pagination.html" %}
65 |
66 |
67 | {##}
82 |
83 |
84 | {% endblock result %}
85 |
--------------------------------------------------------------------------------
/biligank_flask/templates/live/ablive_en.tmpl.html:
--------------------------------------------------------------------------------
1 | {% from "macro.html" import space %}
2 |
3 |
4 | {% block result %}
5 |
6 |
7 | {% if first_time %}
8 |
9 |
10 |
11 | 时间 |
12 | 主播 |
13 | 分区 |
14 |
15 |
16 |
17 |
18 | {% endif %}
19 |
20 |
21 |
22 | {% for entry in data %}
23 | {% set room_info = rooms_dict[entry.liverid] %}
24 |
25 | {{ entry.ts |strftime }} |
26 | {{ space(entry.liverid, room_info.uname) }} |
27 | {{ room_info.area_name }} |
28 |
29 | {% endfor %}
30 |
31 |
32 |
33 |
38 |
39 | {% include "live/pagination.html" %}
40 |
41 |
42 | {% endblock result %}
43 |
--------------------------------------------------------------------------------
/biligank_flask/templates/live/ablive_gf.tmpl.html:
--------------------------------------------------------------------------------
1 | {% from "macro.html" import space %}
2 |
3 |
4 | {% block result %}
5 |
6 |
7 | {% if first_time %}
8 |
9 |
10 |
11 | 时间 |
12 | 主播 |
13 | 礼物 |
14 | 花费(¥) |
15 |
16 |
17 |
18 |
19 | {% endif %}
20 |
21 |
22 |
23 | {% for gift in data %}
24 | {% set liver_uname = rooms_dict[gift.liverid].uname %}
25 |
26 | {{ gift.ts |strftime }} |
27 | {{ space(gift.liverid, liver_uname) }} |
28 | {{ gift.gift_info }} |
29 | {{ gift.gift_cost }} |
30 |
31 | {% endfor %}
32 |
33 |
34 |
35 |
40 |
41 | {% include "live/pagination.html" %}
42 |
43 |
44 | {% endblock %}
45 |
--------------------------------------------------------------------------------
/biligank_flask/templates/live/ablive_sc.tmpl.html:
--------------------------------------------------------------------------------
1 | {% from "macro.html" import space %}
2 |
3 |
4 | {% block result %}
5 |
6 |
7 |
8 | {{next_offset}}
9 |
10 |
11 | 时间 |
12 | 用户 |
13 | 花费(¥) |
14 | 留言 |
15 |
16 |
17 |
18 | {% for superchat in data %}
19 |
20 | {{ superchat.ts |ts2clock }} |
21 | {{ space(superchat.uid, superchat.uname) }} |
22 | {{ superchat.sc_price |int }} |
23 | {{ superchat.text }} |
24 |
25 | {% endfor %}
26 |
27 |
28 |
29 |
36 |
37 |
38 | {% include "live/pagination.html" %}
39 |
40 |
41 | {% endblock result %}
--------------------------------------------------------------------------------
/biligank_flask/templates/live/index.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import 'macro.html' as m %}
3 |
4 |
5 | {% block content %}
6 |
7 | {% block search_bar %}
8 |
9 |
10 |
13 |
14 |
15 |
16 |
26 |
27 |
28 |
79 |
80 | {% endblock search_bar %}
81 |
82 | {% block result %}
83 |
84 | {% for info in notice %}
85 |
86 |
87 | {{info['date']}}
88 | {{info['content']}}
89 |
90 | {% endfor %}
91 |
92 | {% include "_maintain/live_index_notice.tmpl.html" %}
93 |
94 |
96 |
97 |
98 |
99 |
100 |
103 |
104 |
105 | {% endblock result %}
106 |
107 | {% endblock %}
108 |
109 |
110 |
--------------------------------------------------------------------------------
/biligank_flask/templates/live/pagination.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/biligank_flask/templates/live/tp.tmpl.html:
--------------------------------------------------------------------------------
1 | {% from "macro.html" import link %}
2 |
3 |
4 | {% block result %}
5 |
6 | {% if first_time %}
7 |
8 |
9 |
10 | 封面 |
11 | 标题 |
12 | 分区 |
13 | 时间 |
14 | 人气 |
15 |
16 |
17 |
18 | {% endif %}
19 |
20 |
21 |
22 | {% for tp in data %}
23 |
24 | {{ link(tp.cover, '链接') }} |
25 | {{ tp.title }} |
26 | {{ tp.area_name }} |
27 | {{ tp.c_ts |strftime_2 }} — {{ tp.last_update |strftime_2 }} |
28 | {{ tp.watched_num }} |
29 |
30 | {% endfor %}
31 |
32 |
33 |
34 |
39 |
40 |
41 | {% include "live/pagination.html" %}
42 |
43 | {% endblock result %}
--------------------------------------------------------------------------------
/biligank_flask/templates/macro.html:
--------------------------------------------------------------------------------
1 | {% macro card() -%}
2 |
3 |
4 | {% for text in varargs %}
5 | {{ text }}
6 | {% endfor %}
7 |
8 |
9 | {%- endmacro %}
10 |
11 |
12 | {% macro space(uid, uname) -%}
13 | {{ uname }}
14 | {%- endmacro %}
15 |
16 |
17 | {% macro link(site, display) -%}
18 | {{ display }}
19 | {%- endmacro %}
20 |
--------------------------------------------------------------------------------
/biligank_flask/utils.py:
--------------------------------------------------------------------------------
1 | import json
2 | import time
3 | import timeit
4 |
5 |
6 | def write_json(data_dict, filename):
7 | with open('./%s.json' %filename, 'a', encoding='utf-8') as f:
8 | json.dump(data_dict, f, ensure_ascii=False, indent=4)
9 | f.write(',')
10 |
11 |
12 | class Timer:
13 | __slots__ = ('st', 'et')
14 |
15 | def __enter__(self):
16 | self.tick()
17 |
18 | def __exit__(self, exc_type, exc_value, exc_tb):
19 | self.tock()
20 |
21 | def tick(self):
22 | self.st = timeit.default_timer()
23 |
24 | def tock(self):
25 | self.et = timeit.default_timer()
26 |
27 | @property
28 | def result(self):
29 | return round(self.et - self.st, 3)
30 |
31 |
32 | def get_date():
33 | return time.strftime("%Y_%m_%d", time.localtime())
34 |
35 | def get_clock():
36 | return time.strftime("%H:%M:%S", time.localtime())
37 |
38 | def ts2date(time_stamp):
39 | struct_time = time.localtime(time_stamp)
40 | return time.strftime("%Y-%m-%d %H:%M:%S", struct_time)
41 |
42 | def ts2date_2(time_stamp):
43 | struct_time = time.localtime(time_stamp)
44 | return time.strftime("%m-%d %H:%M", struct_time)
45 |
46 | def ts2clock(time_stamp):
47 | struct_time = time.localtime(time_stamp)
48 | return time.strftime("%H:%M:%S", struct_time)
49 |
50 | def get_time():
51 | return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
52 |
--------------------------------------------------------------------------------
/biligank_flask/views/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Grvzard/biligank-flask/44248ee56fae138d705374e77cc66b8af921323c/biligank_flask/views/__init__.py
--------------------------------------------------------------------------------
/biligank_flask/views/general.py:
--------------------------------------------------------------------------------
1 |
2 | from flask import Blueprint, current_app, render_template, request
3 |
4 | from ..logger import MultiLogger
5 | from ..utils import get_time
6 | from . import live
7 |
8 | bp = Blueprint(
9 | name='general',
10 | import_name=__name__,
11 | )
12 |
13 |
14 | bp.add_url_rule(
15 | rule='/',
16 | view_func=live.index,
17 | )
18 |
19 |
20 | feedback_logger = MultiLogger(**current_app.config['FEEDBACK_LOGGER'])
21 |
22 |
23 | @bp.route('/feedback', methods=['POST'])
24 | def feedback():
25 | content_type = request.headers.get("content_type")
26 | assert content_type is not None
27 | if content_type == 'application/json':
28 | assert request.json is not None
29 | text = request.json.get('text')
30 | elif content_type.startswith('application/x-www-form-urlencoded'):
31 | text = request.form['text']
32 | else:
33 | raise Exception('unknown content type')
34 |
35 | data = {
36 | 'time': get_time(),
37 | 'text': text,
38 | 'ip': request.headers.get('x-real-ip'),
39 | }
40 | feedback_logger.log(data)
41 | return 'ok'
42 |
43 |
44 | @bp.route('/about')
45 | def about():
46 | return render_template('about.html', status={})
47 |
48 |
49 | @bp.route('/metrics')
50 | def metrics():
51 | kvdb = current_app.extensions['kvdb']
52 | return kvdb.get('status') or {}
53 |
--------------------------------------------------------------------------------
/biligank_flask/views/live/__init__.py:
--------------------------------------------------------------------------------
1 |
2 | from flask import Blueprint, current_app, render_template
3 |
4 | from ...logger import MultiLogger
5 | from .ablive_searcher import AbliveSearcher
6 | from .livedm_searcher import LivedmSearcher
7 | from .view import AbliveView
8 |
9 | __all__ = 'bp',
10 |
11 |
12 | bp = Blueprint(
13 | name='live',
14 | import_name=__name__,
15 | url_prefix='/live',
16 | )
17 |
18 | ROADS = current_app.config['ABLIVE']['ROADS']
19 | LIMITS = current_app.config['ABLIVE']['LIMITS']
20 |
21 | search_logger = MultiLogger(
22 | **current_app.config['SEARCH_LOGGER']
23 | )
24 |
25 | for road in ROADS:
26 | if road == 'livedm':
27 | searcher = LivedmSearcher(
28 | limits=LIMITS['livedm'],
29 | )
30 | else:
31 | searcher = AbliveSearcher(
32 | road=f'{road}',
33 | limits=LIMITS[f'{road}'],
34 | )
35 |
36 | bp.add_url_rule(
37 | rule=f"/{road}",
38 | view_func=AbliveView.as_view(
39 | name=f'{road}',
40 | road=f'{road}',
41 | searcher=searcher,
42 | search_logger=search_logger,
43 | ),
44 | )
45 |
46 |
47 | @bp.route('/')
48 | def index():
49 | kvdb = current_app.extensions['kvdb']
50 | notice = kvdb.get('notice') or []
51 | return render_template(
52 | 'live/index.html',
53 | notice=notice,
54 | )
55 |
56 |
57 | @bp.route('/data')
58 | def data():
59 | raise Exception('deprecated route')
60 |
--------------------------------------------------------------------------------
/biligank_flask/views/live/ablive_searcher.py:
--------------------------------------------------------------------------------
1 | from collections import defaultdict
2 | from typing import Any, Optional
3 |
4 | from flask import current_app
5 |
6 | from ...utils import get_date
7 |
8 | __all__ = 'AbliveSearcher',
9 |
10 |
11 | DailyAbliveData = tuple[list[dict[str, Any]], set[Any]]
12 |
13 |
14 | class AbliveSearcher:
15 | def __init__(self, road: str, limits: int) -> None:
16 | self.road = road
17 | self.limits = limits
18 | self.tables: list = []
19 | self.last_table = ''
20 |
21 | def update_tables(self, db) -> None:
22 | # error: get_bind() got an unexpected keyword argument 'bind'
23 | # https://github.com/pallets-eco/flask-sqlalchemy/issues/953
24 | # resolved in flask-sqlalchemy 3.0.0 released on 2022.10.04
25 | rs_tup = db.session.execute(
26 | 'show tables;',
27 | bind=db.get_engine(bind_key=f'{self.road}')
28 | ).all()
29 | tables = [rs[0] for rs in rs_tup]
30 | tables.sort(reverse=False)
31 | if self.road in ('tp',):
32 | self.tables = tables
33 | else:
34 | self.tables = tables[-7:]
35 | self.last_table = tables[-1]
36 |
37 | def more(self, uid: int, offset: str) -> tuple[list[Optional[Any]], str, bool, set[Optional[int]]]: # noqa
38 | if uid <= 0:
39 | raise Exception(f"invalid uid: {uid}")
40 | road = self.road
41 | db = current_app.extensions['sqlalchemy']
42 |
43 | if get_date() != self.last_table or not self.tables:
44 | self.update_tables(db)
45 |
46 | try:
47 | if offset == '0':
48 | offset = self.last_table
49 | table_idx = self.tables.index(offset) + 1
50 | else:
51 | table_idx = self.tables.index(offset)
52 | except Exception as e:
53 | raise e
54 |
55 | data = []
56 | liverids = set()
57 | while table_idx > 0:
58 | table_idx -= 1
59 | table = self.tables[table_idx]
60 |
61 | f = getattr(self, f'daily_{road}')
62 | _data, _liverids = f(db, table, uid)
63 | data.extend(_data)
64 | liverids |= _liverids
65 |
66 | if len(data) >= self.limits:
67 | break
68 |
69 | db.session.commit()
70 |
71 | next_offset = table
72 | has_more = False if table_idx == 0 else True
73 |
74 | return data, next_offset, has_more, liverids
75 |
76 | def daily_tp(self, db, table: str, uid: int) -> DailyAbliveData:
77 | date_tp_list = []
78 |
79 | rs_tup = db.session.execute(
80 | f'select title, c_ts, last_update, watched_num, area_name, cover from `{table}` where uid = :uid ORDER BY _id DESC', # noqa
81 | {'uid': uid},
82 | bind=db.get_engine(bind_key='tp')
83 | ).all()
84 | for tp in rs_tup:
85 | date_tp_list.append(tp)
86 |
87 | return date_tp_list, set()
88 |
89 | def daily_ablive_dm(self, db, table: str, uid: int) -> DailyAbliveData:
90 | date_danmaku_cards = []
91 | _livers = set()
92 |
93 | rs_tup = db.session.execute(
94 | f'select ts, liverid, text from `{table}` where uid = :uid ORDER BY _id DESC', # noqa
95 | {'uid': uid},
96 | bind=db.get_engine(bind_key='ablive_dm')
97 | ).all()
98 |
99 | last_liverid = 0
100 | for ts, liverid, text in rs_tup:
101 | if liverid != last_liverid:
102 | last_liverid = liverid
103 | _livers.add(liverid)
104 | card = {
105 | 'date': table,
106 | 'liverid': liverid,
107 | 'danmakus': [],
108 | }
109 | date_danmaku_cards.append(card)
110 | date_danmaku_cards[-1]['danmakus'].append((ts, text))
111 |
112 | return date_danmaku_cards, _livers
113 |
114 | def daily_ablive_en(self, db, table: str, uid: int) -> DailyAbliveData:
115 | date_entry_list = []
116 | _livers = set()
117 |
118 | rs_tup = db.session.execute(
119 | f'select ts, liverid from `{table}` where uid = :uid ORDER BY _id DESC', # noqa
120 | {'uid': uid},
121 | bind=db.get_engine(bind_key='ablive_en')
122 | ).all()
123 | for entry in rs_tup:
124 | date_entry_list.append(dict(entry))
125 | _livers.add(entry['liverid'])
126 |
127 | return date_entry_list, _livers
128 |
129 | def daily_ablive_gf(self, db, table: str, uid: int) -> DailyAbliveData:
130 | date_gift_list = []
131 | _livers = set()
132 |
133 | rs_tup = db.session.execute(
134 | f'select ts, liverid, gift_info, gift_cost from `{table}` where uid = :uid ORDER BY _id DESC', # noqa
135 | {'uid': uid},
136 | bind=db.get_engine(bind_key='ablive_gf')
137 | ).all()
138 | for gift in rs_tup:
139 | date_gift_list.append(dict(gift))
140 | _livers.add(gift['liverid'])
141 |
142 | return date_gift_list, _livers
143 |
144 | def daily_ablive_sc(self, db, table: str, uid: int) -> DailyAbliveData:
145 | date_sc_list = []
146 | liverid = uid
147 |
148 | rs_tup = db.session.execute(
149 | f'select ts, uid, uname, text, sc_price from `{table}` where liverid = :liverid ORDER BY _id DESC', # noqa
150 | {'liverid': liverid},
151 | bind=db.get_engine(bind_key='ablive_sc')
152 | ).all()
153 | for superchat in rs_tup:
154 | date_sc_list.append(dict(superchat))
155 |
156 | return date_sc_list, set()
157 |
--------------------------------------------------------------------------------
/biligank_flask/views/live/livedm_searcher.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Optional
2 |
3 | from flask import current_app
4 |
5 |
6 | class LivedmSearcher:
7 | def __init__(self, limits: int) -> None:
8 | self.limits = limits
9 | self.update_colls(current_app.extensions['mongo_client']['livedm'])
10 |
11 | def update_colls(self, db) -> None:
12 | colls = db.list_collection_names()
13 | colls.sort(reverse=False)
14 | self.colls = colls
15 | self.last_coll = colls[-1]
16 |
17 | def more(self, uid: int, offset: str) -> tuple[Optional[list[Any]], str, bool, set[Optional[int]]]: # noqa
18 | db = current_app.extensions['mongo_client']['livedm']
19 |
20 | try:
21 | if offset == '0':
22 | offset = self.last_coll
23 | table_idx = self.colls.index(offset) + 1
24 | else:
25 | table_idx = self.colls.index(offset)
26 | except Exception as e:
27 | raise e
28 |
29 | data = []
30 | liverids = set()
31 | while table_idx > 0:
32 | table_idx -= 1
33 | table = self.colls[table_idx]
34 |
35 | _data, _liverids = self.daily_docs(db, table, uid)
36 | data.extend(_data)
37 | liverids |= _liverids
38 |
39 | if len(data) >= self.limits:
40 | break
41 |
42 | next_offset = table
43 | has_more = False if table_idx == 0 else True
44 |
45 | return data, next_offset, has_more, liverids
46 |
47 | def daily_docs(self, db, date: str, uid: int) -> tuple[list, set]:
48 | docs = db[date].find({
49 | 'uid': uid,
50 | }, {
51 | '_id': 0,
52 | },
53 | )
54 | date_dm_cards = []
55 | _liverids = set()
56 | for doc in docs:
57 | doc['dm'].sort(reverse=False)
58 |
59 | dm_card = {
60 | 'date': date,
61 | 'liverid': doc['liverid'],
62 | 'danmakus': doc['dm'],
63 | }
64 | _liverids.add(doc['liverid'])
65 | date_dm_cards.append(dm_card)
66 |
67 | return date_dm_cards, _liverids
68 |
69 | def get_doc(self, uid: int, liverid: int, date: str) -> Optional[dict[str, Any]]:
70 | part = date[:7]
71 | doc = self.mongo_client[f'livedm_{part}'][date].find_one({
72 | 'uid': uid,
73 | 'liverid': int(liverid),
74 | }, {
75 | '_id': 0,
76 | },
77 | )
78 | if doc:
79 | doc['dm'].sort(reverse=False)
80 |
81 | return doc
82 |
--------------------------------------------------------------------------------
/biligank_flask/views/live/liveroom_searcher.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Sequence
2 | from typing import Union
3 |
4 |
5 | def get_livers_info(db, liverids: Sequence[int]):
6 | rooms_dict = {}
7 | for coll in ('all', 'rooms_state'):
8 | rs = db[coll].find({
9 | 'uid': {'$in': list(liverids)},
10 | }, {
11 | 'uid': 1, 'uname': 1, 'area_name': 1, '_id': 0,
12 | },
13 | )
14 | for room_info in rs:
15 | liverid = int(room_info['uid'])
16 | rooms_dict[liverid] = room_info
17 |
18 | return rooms_dict
19 |
20 |
21 | def get_liver_info(db, liverid: Union[int, str]):
22 | for coll in ('all', 'rooms_state'):
23 | room_info = db[coll].find_one({
24 | 'uid': int(liverid)
25 | }, {
26 | 'uname': 1, 'area_name': 1, '_id': 0
27 | },
28 | )
29 | if room_info:
30 | break
31 | else:
32 | room_info = {
33 | 'uname': 'bug',
34 | 'area_name': 'bug',
35 | }
36 |
37 | return room_info
38 |
--------------------------------------------------------------------------------
/biligank_flask/views/live/view.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | from flask import current_app, render_template, request
4 | from flask.views import View
5 |
6 | from ...utils import Timer
7 | from . import liveroom_searcher as liveroom
8 |
9 | __all__ = 'AbliveView',
10 |
11 |
12 | class AbliveView(View):
13 | init_every_request = False
14 | methods = ["GET"]
15 |
16 | def __init__(self, road, searcher, search_logger):
17 | self.road = road
18 | self.template = f'live/{road}.tmpl.html'
19 | if road == 'livedm':
20 | self.template = 'live/ablive_dm.tmpl.html'
21 | self.searcher = searcher
22 | self.search_logger = search_logger
23 |
24 | def dispatch_request(self):
25 | road = self.road
26 | uid = int(request.args.get('uid'))
27 | offset = request.args.get('offset')
28 | not_render = bool(request.args.get('not_render'))
29 | first_time = True if offset == '0' else False
30 |
31 | timer = Timer()
32 | with timer:
33 | data, next_offset, has_more, liverids = self.searcher.more(uid, offset)
34 |
35 | rooms_dict = liveroom.get_livers_info(
36 | db=current_app.extensions['mongo_client']['bili_liveroom'],
37 | liverids=liverids,
38 | )
39 |
40 | self.search_logger.log({
41 | 'uid': uid,
42 | 'road': road,
43 | 'offset': offset,
44 | 'loadtime': timer.result,
45 | 'ip': request.headers.get('x-real-ip'),
46 | 'not_render': not_render,
47 | 'ts': int(time.time()),
48 | })
49 |
50 | resp = {
51 | "road": road,
52 | "uid": uid,
53 | "next_offset": next_offset,
54 | "first_time": first_time,
55 | "data": data,
56 | "rooms_dict": rooms_dict,
57 | "has_more": has_more,
58 | }
59 |
60 | if not_render:
61 | return resp
62 |
63 | return {
64 | "html": render_template(self.template, **resp)
65 | }
66 |
--------------------------------------------------------------------------------
/dev-2.1.flaskenv:
--------------------------------------------------------------------------------
1 | FLASK_APP = biligank_flask:app
2 | FLASK_ENV = development
--------------------------------------------------------------------------------
/dev-2.2.flaskenv:
--------------------------------------------------------------------------------
1 | FLASK_APP = biligank_flask:app
2 | FLASK_DEBUG = True
--------------------------------------------------------------------------------
/mypy.ini:
--------------------------------------------------------------------------------
1 | [mypy]
2 | disallow_untyped_defs = False
3 | ignore_missing_imports = True
4 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | pymysql == 1.0.2
2 | Flask >= 2.2.0, < 2.3.0
3 | Flask-SQLAlchemy == 3.0.0
4 | pymongo >= 4.2.0, < 4.3.0
5 | python-dotenv == 0.21.0
6 | gunicorn == 20.1.0
7 | gevent == 21.12.0
8 | pydantic >= 1.10.0, < 2.0.0
9 | httpx[socks] >= 0.24.0, < 1.0.0
10 |
--------------------------------------------------------------------------------
/run.bat:
--------------------------------------------------------------------------------
1 | python -m flask --app biligank_flask:app --debug run
2 |
--------------------------------------------------------------------------------
/serve.sh:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env sh
2 | set -e
3 |
4 | exec gunicorn -w 2 -k gevent --bind 0.0.0.0:7771 biligank_flask:app
5 |
--------------------------------------------------------------------------------