mirror of
https://0xacab.org/johnxina/rat.git
synced 2024-12-23 13:09:08 +00:00
Initial Commit
This commit is contained in:
parent
49c767b71c
commit
4599c7796f
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
venv/
|
||||||
|
log/
|
||||||
|
backup/
|
69
app.py
Normal file
69
app.py
Normal 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
33
extra.py
Normal 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
16
filters.py
Normal 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
156
main.py
Normal 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
5
requirements.txt
Normal 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
166
static/css/main.css
Normal 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
53
templates/bar.html
Normal 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
42
templates/thread.html
Normal 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>
|
Loading…
Reference in New Issue
Block a user