Initial Commit

This commit is contained in:
John Xina 2023-07-11 19:32:13 +08:00
parent 49c767b71c
commit 4599c7796f
9 changed files with 544 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__/
venv/
log/
backup/

69
app.py Normal file
View File

@ -0,0 +1,69 @@
import asyncio
import aiotieba
from aioflask import Flask, render_template, request
from datetime import datetime
from extra import *
app = Flask(__name__)
######################################################################
# Convert a timestamp to its simpliest readable date format.
@app.template_filter('simpledate')
def _jinja2_filter_simpledate(ts):
t = datetime.fromtimestamp(ts)
now = datetime.now()
if t.date() == now.date():
return t.strftime('%H:%m')
elif t.year == now.year:
return t.strftime('%m-%d')
else:
return t.strftime('%Y-%m-%d')
# Convert a timestamp to a humand readable date format.
@app.template_filter('date')
def _jinja2_filter_datetime(ts, fmt='%Y年%m月%d%H点%m分'):
return datetime.fromtimestamp(ts).strftime(fmt)
# Convert a integer to the one with separator like 1,000,000.
@app.template_filter('intsep')
def _jinja2_filter_intsep(i):
return f'{int(i):,}'
# Reduce the text to a shorter form.
@app.template_filter('trim')
def _jinja2_filter_trim(text):
return text[:78] + '……' if len(text) > 78 else text
######################################################################
@app.route('/p/<tid>')
async def thread_view(tid):
tid = int(tid)
pn = int(request.args.get('pn') or 1)
async with aiotieba.Client() as tieba:
# Default to 15 posts per page, confirm to tieba.baidu.com
thread_info = await tieba.get_posts(tid, rn=15, pn=pn)
for fragment in thread_info[0].contents:
print(fragment)
return await render_template('thread.html', info=thread_info)
@app.route('/f')
async def forum_view():
fname = request.args['kw']
pn = int(request.args.get('pn') or 1)
async with aiotieba.Client() as tieba:
forum_info, threads = await asyncio.gather(find_tieba_info(fname),
tieba.get_threads(fname, rn=50, pn=pn))
return await render_template('bar.html', info=forum_info, threads=threads)
if __name__ == '__main__':
app.run(debug=True)

33
extra.py Normal file
View File

@ -0,0 +1,33 @@
'''Extra APIs'''
import requests
import bs4
import re
async def find_tieba_info(tname):
"""Get the tiebat avatar for the forum name.
:param tname: the name of the target forum.
:returns: the internal ID of the corresponding avatar.
"""
info = { 'name': tname }
res = requests.get('https://tieba.baidu.com/f', params={'kw': tname})
soup = bs4.BeautifulSoup(res.text, 'html.parser')
elems = soup.select('#forum-card-head')
match = re.search(r'/(\w+)\.jpg', elems[0]['src'])
info['avatar'] = match.group(1) + '.jpg'
footer = soup.select('.th_footer_l')[0]
stat_elems = footer.findAll('span', {'class': 'red_text'}, recursive=False)
stats = list(map(lambda x: int(x.text), stat_elems))
info |= { 'topic': stats[0], 'thread': stats[1], 'member': stats[2] }
slogan = soup.select('.card_slogan')[0]
info['desc'] = slogan.text
return info

16
filters.py Normal file
View File

@ -0,0 +1,16 @@
from datetime import datetime, timedelta
# Convert a timestamp to a humand readable date format.
@app.template_filter('date')
def _jinja2_filter_datetime(ts, fmt='%Y年%m月%d%H点%m分'):
return datetime.fromtimestamp(ts).strftime(fmt)
# Convert a integer to the one with separator like 1,000,000.
@app.template_filter('intsep')
def _jinja2_filter_intsep(i):
return f'{int(i):,}'
# Convert a duration in seconds to human readable duration.
@app.template_filter('secdur')
def __jinja2_filter_secdur(delta_t):
return str(timedelta(seconds=int(delta_t)))

156
main.py Normal file
View File

@ -0,0 +1,156 @@
import multiprocessing
from app import app
from urllib.parse import quote as urlquote, urlparse, urlunparse
from twisted.web.http import _QUEUED_SENTINEL, HTTPChannel, HTTPClient, Request
from twisted.web.resource import Resource
from twisted.web import proxy, server
from twisted.internet.protocol import ClientFactory
from twisted.internet import reactor, utils
plain_cookies = {}
################################################################################
# Modified Dynamic Proxy (from twisted)
################################################################################
class ProxyClient(HTTPClient):
_finished = False
def __init__(self, command, rest, version, headers, data, father):
self.father = father
self.command = command
self.rest = rest
if b"proxy-connection" in headers:
del headers[b"proxy-connection"]
headers[b"connection"] = b"close"
headers.pop(b"keep-alive", None)
self.headers = headers
self.data = data
def connectionMade(self):
self.sendCommand(self.command, self.rest)
for header, value in self.headers.items():
self.sendHeader(header, value)
self.endHeaders()
self.transport.write(self.data)
def handleStatus(self, version, code, message):
self.father.setResponseCode(int(code), message)
def handleHeader(self, key, value):
if key.lower() in [b"server", b"date", b"content-type"]:
self.father.responseHeaders.setRawHeaders(key, [value])
else:
self.father.responseHeaders.addRawHeader(key, value)
def handleResponsePart(self, buffer):
self.father.write(buffer)
def handleResponseEnd(self):
if not self._finished:
self._finished = True
self.father.notifyFinish().addErrback(lambda x: None)
self.transport.loseConnection()
class ProxyClientFactory(ClientFactory):
protocol = ProxyClient
def __init__(self, command, rest, version, headers, data, father):
self.father = father
self.command = command
self.rest = rest
self.headers = headers
self.data = data
self.version = version
def buildProtocol(self, addr):
return self.protocol(
self.command, self.rest, self.version, self.headers, self.data, self.father
)
def clientConnectionFailed(self, connector, reason):
self.father.setResponseCode(501, b"Gateway error")
self.father.responseHeaders.addRawHeader(b"Content-Type", b"text/html")
self.father.write(b"<H1>Could not connect</H1>")
self.father.finish()
class ReverseProxyResource(Resource):
def __init__(self, path, reactor=reactor):
Resource.__init__(self)
self.path = path
self.reactor = reactor
def getChild(self, path, request):
return ReverseProxyResource(
self.path + b'/' + urlquote(path, safe=b'').encode("utf-8"),
self.reactor
)
def render_proxy_avatar(self, request, req_path):
portrait = req_path[14:]
request.requestHeaders.setRawHeaders(b'host', [b'tb.himg.baidu.com'])
request.content.seek(0, 0)
clientFactory = ProxyClientFactory(
b'GET', ('http://tb.himg.baidu.com/sys/portraith/item/' + portrait).encode('utf-8'),
request.clientproto,
request.getAllHeaders(),
request.content.read(),
request,
)
self.reactor.connectTCP('tb.himg.baidu.com', 80, clientFactory)
return server.NOT_DONE_YET
def render_proxy_pic(self, request, req_path):
pic = req_path[11:]
request.requestHeaders.setRawHeaders(b'host', [b'imgsa.baidu.com'])
request.content.seek(0, 0)
clientFactory = ProxyClientFactory(
b'GET', ('http://imgsa.baidu.com/forum/pic/item/' + pic).encode('utf-8'),
request.clientproto,
request.getAllHeaders(),
request.content.read(),
request,
)
self.reactor.connectTCP('imgsa.baidu.com', 80, clientFactory)
return server.NOT_DONE_YET
def render(self, request):
# Justify the request path.
req_path = self.path.decode('utf-8')
if req_path.startswith('/proxy/avatar/'):
return self.render_proxy_avatar(request, req_path)
elif req_path.startswith('/proxy/pic/'):
return self.render_proxy_pic(request, req_path)
else:
request.setResponseCode(418, b'I\'m a teapot')
return
################################################################################
# To start this function for testing: python -c 'import main; main.twisted_start()'
def twisted_start():
flask_res = proxy.ReverseProxyResource('127.0.0.1', 5000, b'')
flask_res.putChild(b'proxy', ReverseProxyResource(b'/proxy'))
site = server.Site(flask_res)
reactor.listenTCP(5001, site)
reactor.run()
# To start this function for testing: python -c 'import main; main.flask_start()'
def flask_start():
app.run(port=5000+1)
# If we're executed directly, also start the flask daemon.
if __name__ == '__main__':
flask_task = multiprocessing.Process(target=flask_start)
flask_task.daemon = True # Exit the child if the parent was killed :-(
flask_task.start()
twisted_start()

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
aioflask==0.4.0
flask==2.1.3
aiotieba==3.4.5
beautifulsoup4
requests

166
static/css/main.css Normal file
View File

@ -0,0 +1,166 @@
:root {
--bg-color: #eeeecc;
--fg-color: #ffffdd;
--replies-color: #f0f0d0;
--text-color: #663333;
--primary-color: #0066cc;
--important-color: #ff0000;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #000033;
--fg-color: #202044;
--replies-color: #16163a;
--text-color: #cccccc;
--primary-color: #6699ff;
--important-color: #ff0000;
}
}
body {
max-width: 48rem;
margin: auto;
background-color: var(--bg-color);
color: var(--text-color);
font-family: sans-serif;
}
a[href] {
color: var(--primary-color);
text-decoration: none;
}
a[href]:hover {
text-decoration: underline;
}
img {
max-width: 100%;
}
.bar-nav, .thread-nav {
background-color: var(--fg-color);
display: flex;
flex-wrap: wrap;
gap: 0 1rem;
padding: 1rem;
margin-bottom: 1rem;
align-items: center;
}
.thread-nav > .title {
font-size: 1.2rem;
flex: 1 0 70%;
}
.thread-nav > .from {
font-size: 1.2rem;
}
.bar-nav > img {
width: 5rem;
height: 5rem;
}
.bar-nav .title {
font-size: 1.5rem;
}
.bar-nav .stats small {
margin-right: .5rem;
}
.list {
background-color: var(--fg-color);
margin-bottom: 1rem;
}
.post {
display: flex;
flex-wrap: wrap;
gap: 0 1rem;
border-bottom: 1px solid var(--text-color);
padding: 1rem;
}
.post .avatar {
width: 4rem;
height: 4rem;
}
.post > div {
flex: 1;
}
.post .userinfo {
display: flex;
gap: .5rem;
align-items: center;
}
.tag {
border-radius: .4rem;
font-size: .8rem;
padding: .1rem .4rem;
margin-right: .2rem;
color: white;
}
.tag-blue {
background-color: var(--primary-color);
}
.tag-red {
background-color: var(--important-color);
}
.post .permalink {
float: right;
}
.thread {
display: flex;
gap: 1rem;
padding: 1rem;
}
.thread .stats {
flex: 0 0 4rem;
text-align: center;
}
.thread .replies {
font-size: .8rem;
padding: .5rem .2rem;
background-color: var(--replies-color);
}
.thread .summary {
flex: 1 1 auto;
}
.thread .title {
font-size: 1.1rem;
margin-bottom: .5rem;
}
.thread .participants {
font-size: .8rem;
flex: 0 0 6rem;
}
.thread .participants a {
padding-left: .3rem;
color: var(--text-color);
}
footer {
display: flex;
gap: 2rem;
padding: 1rem;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}

53
templates/bar.html Normal file
View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<head>
<title>{{ info['name'] }}吧 - RAT</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/css/main.css" rel="stylesheet">
</head>
<body>
<header class="bar-nav">
<img src="/proxy/pic/{{ info['avatar'] }}"></nav>
<div>
<div class="title">{{ info['name'] }}吧</div>
<div class="description">{{ info['desc'] }}</div>
<div class="stats">
<small>关注: {{ info['member']|intsep }}</small>
<small>主题: {{ info['topic']|intsep }}</small>
<small>帖子: {{ info['thread']|intsep }}</small>
</div>
</div>
</header>
<div class="list">
{% for t in threads %}
<div class="thread">
<div class="stats">
<div class="replies">{{ t.reply_num }}</div>
<small>{{ t.last_time|simpledate }}</small>
</div>
<div class="summary">
<div class="title">
{% if t.is_top or t.is_livepost
%}<span class="tag tag-blue">置顶</span>{%
endif %}{% if t.is_good
%}<span class="tag tag-red"></span>{% endif
%}<a href="/p/{{ t.tid }}">{{ t.title }} </a>
</div>
<div>{{ t.text[(t.title|length):]|trim }}</div>
</div>
<div class="participants">
<div>🧑<a href="">{{ t.user.user_name }}</a></div>
<!-- <div>💬<a href=""> API UNAVAILABLE </a></div> -->
</div>
</div>
{% endfor %}
</div>
<footer>
<div><a href="#">RAT Ain't Tieba</a></div>
<div><a href="#">自豪地以 AGPLv3 释出</a></div>
<div><a href="#">源代码</a></div>
</footer>
</body>

42
templates/thread.html Normal file
View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<head>
<title>{{ info.thread.text }} - 自由软件吧 - RAT</title>
<meta charset="utf-8">
<meta name="referrer" content="never">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/css/main.css" rel="stylesheet">
</head>
<body>
<header class="thread-nav">
<div class="title">{{ info.thread.title }}</div>
<div class="from"><a href="/f?kw={{ info.forum.fname }}">{{ info.forum.fname }}吧</a></div>
</header>
<div class="list">
{% for p in info %}
<div class="post" id="{{ p.floor }}">
<img class="avatar" src="/proxy/avatar/{{ p.user.portrait }}">
<div>
<div class="userinfo">
<a href="/home/main?id={{ p.user.user_id }}">{{ p.user.user_name }}</a>
{% if p.is_thread_author %}
<span class="tag tag-blue">楼主</span>
{% endif %}
</div>
<div class="content">
{{ p['text'] }}
</div>
<small class="date">{{ p.create_time|date }}</small>
<small class="permalink"><a href="#1">{{ p.floor }}</a></small>
</div>
</div>
{% endfor %}
</div>
<footer>
<div><a href="/">RAT Ain't Tieba</a></div>
<div><a href="#">自豪地以 AGPLv3 释出</a></div>
<div><a href="#">源代码</a></div>
</footer>
</body>