mirror of https://github.com/zedeus/nitter
Initial commit
This commit is contained in:
commit
cea5cc0523
|
@ -0,0 +1,2 @@
|
|||
nitter
|
||||
*.html
|
|
@ -0,0 +1,14 @@
|
|||
# Package
|
||||
|
||||
version = "0.1.0"
|
||||
author = "zedeus"
|
||||
description = "An alternative front-end for Twitter"
|
||||
license = "AGPL-3.0"
|
||||
srcDir = "src"
|
||||
bin = @["nitter"]
|
||||
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires "nim >= 0.19.9"
|
||||
requires "regex", "nimquery", "nimcrypto", "norm", "jester"
|
|
@ -0,0 +1,561 @@
|
|||
body {
|
||||
background-color: #121212;
|
||||
color: #f8f8f2;
|
||||
margin: 0;
|
||||
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
}
|
||||
|
||||
#tweets {
|
||||
background-color: #161616;
|
||||
}
|
||||
|
||||
#user {
|
||||
background-color: #242424;
|
||||
width: 100%;
|
||||
padding: 5pt;
|
||||
}
|
||||
|
||||
#user > h1 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ff6c60;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status-el {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
border-left-width: 0;
|
||||
min-width: 0;
|
||||
padding: .75em;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.timeline-tweet {
|
||||
border-bottom: 1px solid #3e3e35;
|
||||
}
|
||||
|
||||
.status-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-heading a {
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-el .media-heading {
|
||||
padding: 0;
|
||||
vertical-align: bottom;
|
||||
flex-basis: 100%;
|
||||
margin-bottom: .2em;
|
||||
}
|
||||
|
||||
.status-el .media-heading .heading-name-row {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.status-el .media-heading .heading-name-row .name-and-account-name {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-el .media-heading .heading-name-row .username {
|
||||
flex-shrink: 1;
|
||||
margin-right: .4em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-el .username {
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
||||
.status-el .media-heading .heading-right {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-el .media-heading .heading-name-row .account-name {
|
||||
min-width: 1.6em;
|
||||
margin-right: .4em;
|
||||
flex: 1 1 0;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-el .media-heading a {
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-el .status-content {
|
||||
font-family: sans-serif;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.status-el .media-body {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container, .item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.container {
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#content {
|
||||
box-sizing: border-box;
|
||||
padding-top: 50px;
|
||||
margin: auto;
|
||||
min-height: 100vh;
|
||||
background-color: rgba(0,0,0,.15);
|
||||
}
|
||||
|
||||
nav {
|
||||
z-index: 1000;
|
||||
background-color: #1f1f1f;
|
||||
color: hsla(240,1%,73%,.5);
|
||||
box-shadow: 0 0 4px rgba(0,0,0,.6);
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.nav-bar .inner-nav {
|
||||
margin: auto;
|
||||
box-sizing: border-box;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item {
|
||||
flex: 1;
|
||||
line-height: 50px;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
margin-top: .5em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-height: 600px;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.gallery-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
max-height: 379.5px;
|
||||
max-width: 506px;
|
||||
}
|
||||
|
||||
.gallery-row .attachment, .gallery-row .attachments {
|
||||
margin: 0 .25em 0 0;
|
||||
flex-grow: 1;
|
||||
box-sizing: border-box;
|
||||
min-width: 2em;
|
||||
}
|
||||
|
||||
.gallery-row .attachment:last-child, .gallery-row .attachments:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.attachments .attachment {
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
border-color: #222;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-row .image-attachment {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attachments .image-attachment {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.still-image {
|
||||
max-height: 379.5px;
|
||||
max-width: 506px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.still-image img {
|
||||
object-fit: cover;
|
||||
max-width: 506px;
|
||||
max-height: 379.5px;
|
||||
border-color: #222;
|
||||
flex-basis: 300px;
|
||||
}
|
||||
|
||||
.status-body {
|
||||
margin-left: 58px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
float: left;
|
||||
margin-top: 3px;
|
||||
margin-left: -58px;
|
||||
position: absolute;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.retweet, .pinned, .tweet-stats {
|
||||
align-content: center;
|
||||
color: hsla(240,1%,73%,.7);
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
margin: 0;
|
||||
max-width: 85%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tweet-stat {
|
||||
padding-top: 5px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.show-more {
|
||||
text-align: center;
|
||||
padding: .75em 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.show-more.status-el {
|
||||
border-bottom: 1px solid #3e3e35;
|
||||
}
|
||||
|
||||
.show-more a {
|
||||
background-color: #242424;
|
||||
display: inline-block;
|
||||
height: 2em;
|
||||
padding: 0 2em;
|
||||
line-height: 2em;
|
||||
}
|
||||
.show-more a:hover {
|
||||
background-color: #282828;
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
max-width: 846px;
|
||||
margin: 0 auto;
|
||||
float: none;
|
||||
border-radius: 0;
|
||||
position: relative;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.timeline-tab {
|
||||
float: right;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
width: 70% !important;
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
width: 30% !important;
|
||||
}
|
||||
|
||||
.profile-banner {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.profile-banner img {
|
||||
width: 100%;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.profile-banner-color {
|
||||
width: 100%;
|
||||
padding-bottom: 25%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
float: left;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0;
|
||||
background: #161616;
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.profile-card-tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.profile-card-tabs-name {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.profile-card-name, .profile-card-username {
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
||||
.profile-card-name {
|
||||
font-size: 16px;
|
||||
word-wrap: break-word;
|
||||
text-shadow: none;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.profile-card-username {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-card-avatar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding-bottom: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.profile-card-avatar img {
|
||||
display: block;
|
||||
width: calc(100% - 4px);
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: 4px solid #282828;
|
||||
background: #040404;
|
||||
}
|
||||
|
||||
.profile-card-extra {
|
||||
display: block;
|
||||
flex: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.profile-bio {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
margin-right: -5px
|
||||
}
|
||||
|
||||
.profile-description {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
word-break: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.conversation {
|
||||
max-width: 580px;
|
||||
margin: 0 auto;
|
||||
float: none;
|
||||
border-radius: 0;
|
||||
position: relative;
|
||||
width: auto;
|
||||
background-color: #0f0f0f !important;
|
||||
}
|
||||
|
||||
.main-thread {
|
||||
margin-bottom: 20px;
|
||||
background-color: #161616;
|
||||
}
|
||||
|
||||
.main-tweet .status-content {
|
||||
font-size: 22px;
|
||||
line-height: 30px;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
|
||||
.thread {
|
||||
background-color: #161616;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin: auto;
|
||||
font-size: 130%;
|
||||
}
|
||||
|
||||
.error-panel {
|
||||
background-color: #420a05 !important;
|
||||
}
|
||||
|
||||
.error-panel, .search-panel > form {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
background: #222222;
|
||||
box-shadow: 0 0 15px rgba(0,0,0,.2);
|
||||
margin: auto;
|
||||
margin-top: -50px;
|
||||
}
|
||||
|
||||
.search-panel > form > button {
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
display: block;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #2f2f2f;
|
||||
color: #f8f8f2;
|
||||
outline: 0;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
width: 37px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-panel > form > input {
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
outline: 0;
|
||||
font-family: inherit;
|
||||
background: #131419;
|
||||
color: #f8f8f2;
|
||||
border: 1px solid #0a0b0e;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.profile-card-extra-links {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-statlist {
|
||||
vertical-align: bottom;
|
||||
table-layout: fixed;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-statlist > li {
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-statlist .tweets {
|
||||
flex-shrink: 2;
|
||||
}
|
||||
|
||||
.profile-statlist .followers {
|
||||
flex-grow: 2;
|
||||
}
|
||||
|
||||
.profile-statlist .following {
|
||||
flex-shrink: 1.5;
|
||||
}
|
||||
|
||||
.profile-stat-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.timeline-protected {
|
||||
max-width: 550px;
|
||||
margin: 0 auto;
|
||||
padding: 6px 0px;
|
||||
}
|
||||
|
||||
.timeline-protected-header {
|
||||
color: #d0564c;
|
||||
font-size: 21px;
|
||||
font-weight: bold;
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
import httpclient, asyncdispatch, htmlparser, times
|
||||
import sequtils, strutils, strformat, json, xmltree, uri
|
||||
import nimquery, regex
|
||||
|
||||
import ./types, ./parser
|
||||
|
||||
const base = parseUri("https://twitter.com/")
|
||||
const agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"
|
||||
|
||||
const timelineUrl = "i/profiles/show/$1/timeline/tweets?include_available_features=1&include_entities=1&include_new_items_bar=true"
|
||||
const profileUrl = "i/profiles/popup"
|
||||
const tweetUrl = "i/status/"
|
||||
|
||||
proc getProfile*(username: string): Future[Profile] {.async.} =
|
||||
let client = newAsyncHttpClient()
|
||||
defer: client.close()
|
||||
|
||||
client.headers = newHttpHeaders({
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9",
|
||||
"Referer": $(base / username),
|
||||
"User-Agent": agent,
|
||||
"X-Twitter-Active-User": "yes",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Accept-Language": "en-US,en;q=0.9"
|
||||
})
|
||||
|
||||
let params = {
|
||||
"screen_name": username,
|
||||
"wants_hovercard": "true",
|
||||
"_": $(epochTime().int)
|
||||
}
|
||||
|
||||
let url = base / profileUrl ? params
|
||||
var resp = ""
|
||||
|
||||
try:
|
||||
resp = await client.getContent($url)
|
||||
except:
|
||||
return Profile()
|
||||
|
||||
let
|
||||
json = parseJson(resp)["html"].str
|
||||
html = parseHtml(json)
|
||||
|
||||
result = parseProfile(html)
|
||||
|
||||
proc getTimeline*(username: string; after=""): Future[Tweets] {.async.} =
|
||||
let client = newAsyncHttpClient()
|
||||
defer: client.close()
|
||||
|
||||
client.headers = newHttpHeaders({
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"Referer": $(base / username),
|
||||
"User-Agent": agent,
|
||||
"X-Twitter-Active-User": "yes",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Accept-Language": "en-US,en;q=0.9"
|
||||
})
|
||||
|
||||
var url = timelineUrl % username
|
||||
if after != "":
|
||||
url &= "&max_position=" & after
|
||||
|
||||
var resp = ""
|
||||
try:
|
||||
resp = await client.getContent($(base / url))
|
||||
except:
|
||||
return
|
||||
|
||||
var json: string = ""
|
||||
var html: XmlNode
|
||||
json = parseJson(resp)["items_html"].str
|
||||
html = parseHtml(json)
|
||||
|
||||
writeFile("epic.html", $html)
|
||||
|
||||
result = parseTweets(html)
|
||||
|
||||
proc getTweet*(id: string): Future[Conversation] {.async.} =
|
||||
let client = newAsyncHttpClient()
|
||||
defer: client.close()
|
||||
|
||||
client.headers = newHttpHeaders({
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"Referer": $base,
|
||||
"User-Agent": agent,
|
||||
"X-Twitter-Active-User": "yes",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"pragma": "no-cache",
|
||||
"x-previous-page-name": "profile"
|
||||
})
|
||||
|
||||
let url = base / tweetUrl / id
|
||||
|
||||
var resp: string = ""
|
||||
try:
|
||||
resp = await client.getContent($url)
|
||||
except:
|
||||
return Conversation()
|
||||
|
||||
var html: XmlNode
|
||||
html = parseHtml(resp)
|
||||
|
||||
result = parseConversation(html)
|
|
@ -0,0 +1,74 @@
|
|||
import sharedtables, times, hashes
|
||||
import types, api
|
||||
|
||||
# var
|
||||
# profileCache: SharedTable[int, Profile]
|
||||
# profileCacheTime = initDuration(seconds=10)
|
||||
|
||||
# profileCache.init()
|
||||
|
||||
proc getCachedProfile*(username: string; force=false): Profile =
|
||||
return getProfile(username)
|
||||
# let index = username.hash
|
||||
|
||||
# try:
|
||||
# result = profileCache.mget(index)
|
||||
# # if force or getTime() - result.lastUpdated > profileCacheTime:
|
||||
# # result = getProfile(username)
|
||||
# # profileCache[username.hash] = deepCopy(result)
|
||||
# # return
|
||||
# except KeyError:
|
||||
# # result = getProfile(username)
|
||||
# # profileCache.add(username.hash, deepCopy(result))
|
||||
|
||||
|
||||
|
||||
# var profile: Profile
|
||||
# profileCache.withKey(index) do (k: int, v: var Profile, pairExists: var bool):
|
||||
# v = getProfile(username)
|
||||
# profile = v
|
||||
# echo v
|
||||
# pairExists = true
|
||||
# echo profile.username
|
||||
# return profile
|
||||
|
||||
# profileCache.withValue(hash(username), value) do:
|
||||
# if getTime() - value.lastUpdated > profileCacheTime or force:
|
||||
# result = getProfile(username)
|
||||
# value = result
|
||||
# else:
|
||||
# result = value
|
||||
# do:
|
||||
# result = getProfile(username)
|
||||
# value = result
|
||||
|
||||
# var profile: Profile
|
||||
|
||||
# profileCache.withKey(username.hash) do (k: int, v: var Profile, pairExists: var bool):
|
||||
# if pairExists and getTime() - v.lastUpdated < profileCacheTime and not force:
|
||||
# profile = deepCopy(v)
|
||||
# echo "cached"
|
||||
# else:
|
||||
# profile = getProfile(username)
|
||||
# v = deepCopy(profile)
|
||||
# pairExists = true
|
||||
# echo "fetched"
|
||||
|
||||
# return profile
|
||||
|
||||
# try:
|
||||
# result = profileCache.mget(username.hash)
|
||||
# if force or getTime() - result.lastUpdated > profileCacheTime:
|
||||
# result = getProfile(username)
|
||||
# profileCache[username.hash] = deepCopy(result)
|
||||
# return
|
||||
# except KeyError:
|
||||
# result = getProfile(username)
|
||||
# profileCache.add(username.hash, deepCopy(result))
|
||||
|
||||
# if not result.isNil or force or
|
||||
# getTime() - result.lastUpdated > profileCacheTime:
|
||||
# result = getProfile(username)
|
||||
# profileCache[username] = result
|
||||
# return
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import strutils, strformat, htmlgen, xmltree
|
||||
import regex
|
||||
|
||||
import ./types, ./utils
|
||||
|
||||
const
|
||||
urlRegex = re"((https?|ftp)://(-\.)?([^\s/?\.#]+\.?)+(/[^\s]*)?)"
|
||||
emailRegex = re"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
|
||||
usernameRegex = re"(^|[^\S\n]|\.)@([A-z0-9_]+)"
|
||||
picRegex = re"pic.twitter.com/[^ ]+"
|
||||
cardRegex = re"(https?://)?cards.twitter.com/[^ ]+"
|
||||
ellipsisRegex = re" ?…"
|
||||
|
||||
proc shortLink*(text: string; length=28): string =
|
||||
result = text.replace(re"https?://(www.)?", "")
|
||||
if result.len > length:
|
||||
result = result[0 ..< length] & "…"
|
||||
|
||||
proc toLink*(url, text: string; class="timeline-link"): string =
|
||||
htmlgen.a(text, class=class, href=url)
|
||||
|
||||
proc reUrlToLink*(m: RegexMatch; s: string): string =
|
||||
let url = s[m.group(0)[0]]
|
||||
toLink(url, " " & shortLink(url))
|
||||
|
||||
proc reEmailToLink*(m: RegexMatch; s: string): string =
|
||||
let url = s[m.group(0)[0]]
|
||||
toLink("mailto://" & url, url)
|
||||
|
||||
proc reUsernameToLink*(m: RegexMatch; s: string): string =
|
||||
var
|
||||
username = ""
|
||||
pretext = ""
|
||||
|
||||
let
|
||||
pre = m.group(0)
|
||||
match = m.group(1)
|
||||
|
||||
username = s[match[0]]
|
||||
|
||||
if pre.len > 0:
|
||||
pretext = s[pre[0]]
|
||||
|
||||
pretext & toLink("/" & username, "@" & username)
|
||||
|
||||
proc linkifyText*(text: string): string =
|
||||
result = text.replace("\n", "<br>")
|
||||
result = result.replace(ellipsisRegex, "")
|
||||
result = result.replace(usernameRegex, reUsernameToLink)
|
||||
result = result.replace(emailRegex, reEmailToLink)
|
||||
result = result.replace(urlRegex, reUrlToLink)
|
||||
|
||||
proc stripTwitterUrls*(text: string): string =
|
||||
result = text
|
||||
result = result.replace(picRegex, "")
|
||||
result = result.replace(cardRegex, "")
|
||||
result = result.replace(ellipsisRegex, "")
|
||||
|
||||
proc getUserpic*(userpic: string; style=""): string =
|
||||
let pic = userpic.replace(re"_(normal|bigger|mini|200x200)(\.[A-z]+)$", "$2")
|
||||
pic.replace(re"(\.[A-z]+)$", style & "$1")
|
||||
|
||||
proc getUserpic*(profile: Profile; style=""): string =
|
||||
getUserPic(profile.userpic, style)
|
||||
|
||||
proc getGifSrc*(tweet: Tweet): string =
|
||||
fmt"https://video.twimg.com/tweet_video/{tweet.gif}.mp4"
|
||||
|
||||
proc getGifThumb*(tweet: Tweet): string =
|
||||
fmt"https://pbs.twimg.com/tweet_video_thumb/{tweet.gif}.jpg"
|
||||
|
||||
proc formatName(profile: Profile): string =
|
||||
result = profile.fullname
|
||||
if profile.verified:
|
||||
result &= " 🔹"
|
||||
elif profile.protected:
|
||||
result &= " 🔒"
|
||||
result = xmltree.escape(result)
|
||||
|
||||
proc linkUser*(profile: Profile; h: string; username=true; class=""): string =
|
||||
let text =
|
||||
if username: "@" & profile.username
|
||||
else: formatName(profile)
|
||||
|
||||
if h == "":
|
||||
return htmlgen.a(text, href = &"/{profile.username}", class=class)
|
||||
|
||||
let element = &"<{h} class=\"{class}\">{text}</{h}>"
|
||||
htmlgen.a(element, href = &"/{profile.username}")
|
|
@ -0,0 +1,75 @@
|
|||
import asyncdispatch, httpclient, times, strutils, hashes, random, uri
|
||||
import jester, regex
|
||||
|
||||
import api, utils, types
|
||||
import views/[user, general, conversation]
|
||||
|
||||
proc showTimeline(name: string; num=""): Future[string] {.async.} =
|
||||
let
|
||||
username = name.strip(chars={'/'})
|
||||
profileFut = getProfile(username)
|
||||
tweetsFut = getTimeline(username, after=num)
|
||||
|
||||
let profile = await profileFut
|
||||
if profile.username == "":
|
||||
return ""
|
||||
|
||||
return renderMain(renderProfile(profile, await tweetsFut, num == ""))
|
||||
|
||||
routes:
|
||||
get "/":
|
||||
resp renderMain(renderSearchPanel())
|
||||
|
||||
post "/search":
|
||||
if @"query".len == 0:
|
||||
resp Http404, showError("Please enter a username.")
|
||||
|
||||
redirect("/" & @"query")
|
||||
|
||||
get "/@name/?":
|
||||
cond '.' notin @"name"
|
||||
let timeline = await showTimeline(@"name", @"after")
|
||||
if timeline == "":
|
||||
resp Http404, showError("User \"" & @"name" & "\" not found")
|
||||
|
||||
resp timeline
|
||||
|
||||
get "/@name/status/@id":
|
||||
cond '.' notin @"name"
|
||||
let conversation = await getTweet(@"id")
|
||||
if conversation.tweet.id == "":
|
||||
resp Http404, showError("Tweet not found")
|
||||
|
||||
resp renderMain(renderConversation(conversation))
|
||||
|
||||
get "/pic/@sig/@url":
|
||||
cond "http" in @"url"
|
||||
cond "twimg" in @"url"
|
||||
let url = decodeUrl(@"url")
|
||||
|
||||
if getHmac(url) != @"sig":
|
||||
resp showError("Failed to verify signature")
|
||||
|
||||
let
|
||||
client = newAsyncHttpClient()
|
||||
pic = await client.getContent(url)
|
||||
|
||||
defer: client.close()
|
||||
resp pic, mimetype(url)
|
||||
|
||||
get "/video/@sig/@url":
|
||||
cond "http" in @"url"
|
||||
cond "video.twimg" in @"url"
|
||||
let url = decodeUrl(@"url")
|
||||
|
||||
if getHmac(url) != @"sig":
|
||||
resp showError("Failed to verify signature")
|
||||
|
||||
let
|
||||
client = newAsyncHttpClient()
|
||||
pic = await client.getContent(url)
|
||||
|
||||
defer: client.close()
|
||||
resp pic, mimetype(url)
|
||||
|
||||
runForever()
|
|
@ -0,0 +1,100 @@
|
|||
import xmltree, sequtils, strtabs, strutils, strformat, json, times
|
||||
import nimquery, regex
|
||||
|
||||
import ./types, ./formatters
|
||||
|
||||
proc getAttr(node: XmlNode; attr: string; default=""): string =
|
||||
if node.isNIl or node.attrs.isNil: return default
|
||||
return node.attrs.getOrDefault(attr)
|
||||
|
||||
proc selectAttr(node: XmlNode; selector: string; attr: string; default=""): string =
|
||||
let res = node.querySelector(selector)
|
||||
return res.getAttr(attr, default)
|
||||
|
||||
proc selectText(node: XmlNode; selector: string): string =
|
||||
let res = node.querySelector(selector)
|
||||
result = if res == nil: "" else: res.innerText()
|
||||
|
||||
proc parseProfile*(node: XmlNode): Profile =
|
||||
let profile = node.querySelector(".profile-card")
|
||||
result.fullname = profile.selectText(".fullname")
|
||||
result.username = profile.selectText(".username").strip(chars={'@', ' '})
|
||||
result.description = profile.selectText(".bio")
|
||||
result.verified = profile.selectText("li.verified").len > 0
|
||||
result.protected = profile.selectText(".Icon.Icon--protected").len > 0
|
||||
result.userpic = profile.selectAttr(".ProfileCard-avatarImage", "src").getUserpic()
|
||||
result.banner = profile.selectAttr("svg > image", "xlink:href").replace("600x200", "1500x500")
|
||||
if result.banner == "":
|
||||
result.banner = profile.selectAttr(".ProfileCard-bg", "style")
|
||||
|
||||
let stats = profile.querySelectorAll(".ProfileCardStats-statLink")
|
||||
for s in stats:
|
||||
let text = s.getAttr("title").split(" ")[0]
|
||||
case s.getAttr("href").split("/")[^1]
|
||||
of "followers": result.followers = text
|
||||
of "following": result.following = text
|
||||
else: result.tweets = text
|
||||
|
||||
proc parseTweetProfile*(tweet: XmlNode): Profile =
|
||||
result = Profile(
|
||||
fullname: tweet.getAttr("data-name"),
|
||||
username: tweet.getAttr("data-screen-name"),
|
||||
userpic: tweet.selectAttr(".avatar", "src").getUserpic(),
|
||||
verified: tweet.selectText(".Icon.Icon--verified").len > 0
|
||||
)
|
||||
|
||||
proc parseTweet*(tweet: XmlNode): Tweet =
|
||||
result.id = tweet.getAttr("data-item-id")
|
||||
result.link = tweet.getAttr("data-permalink-path")
|
||||
result.text = tweet.selectText(".tweet-text").stripTwitterUrls()
|
||||
result.retweetBy = tweet.selectText(".js-retweet-text > a > b")
|
||||
result.pinned = "pinned" in tweet.getAttr("class")
|
||||
result.profile = parseTweetProfile(tweet)
|
||||
|
||||
let time = tweet.querySelector(".js-short-timestamp")
|
||||
result.time = fromUnix(parseInt(time.getAttr("data-time", "0")))
|
||||
result.shortTime = time.innerText()
|
||||
|
||||
result.replies = "0"
|
||||
result.likes = "0"
|
||||
result.retweets = "0"
|
||||
|
||||
for action in tweet.querySelectorAll(".ProfileTweet-actionCountForAria"):
|
||||
let
|
||||
text = action.innerText.split()
|
||||
num = text[0]
|
||||
act = text[1]
|
||||
|
||||
case act
|
||||
of "replies": result.replies = num
|
||||
of "likes": result.likes = num
|
||||
of "retweets": result.retweets = num
|
||||
else: discard
|
||||
|
||||
for photo in tweet.querySelectorAll(".AdaptiveMedia-photoContainer"):
|
||||
result.photos.add photo.attrs["data-image-url"]
|
||||
|
||||
let gif = tweet.selectAttr(".PlayableMedia-player", "style")
|
||||
if gif != "":
|
||||
result.gif = gif.replace(re".+thumb/([^\.']+)\.jpg.+", "$1")
|
||||
|
||||
proc parseTweets*(node: XmlNode): Tweets =
|
||||
if node.isNil: return
|
||||
node.querySelectorAll(".tweet").map(parseTweet)
|
||||
|
||||
template selectTweets*(node: XmlNode; class: string): untyped =
|
||||
parseTweets(node.querySelector(class))
|
||||
|
||||
proc parseConversation*(node: XmlNode): Conversation =
|
||||
result.tweet = parseTweet(node.querySelector(".permalink-tweet-container > .tweet"))
|
||||
result.before = node.selectTweets(".in-reply-to")
|
||||
|
||||
let replies = node.querySelector(".replies-to")
|
||||
if replies.isNil: return
|
||||
|
||||
result.after = replies.selectTweets(".ThreadedConversation--selfThread")
|
||||
|
||||
for reply in replies.querySelectorAll("li > .stream-items"):
|
||||
let thread = parseTweets(reply)
|
||||
if not thread.anyIt(it in result.after):
|
||||
result.replies.add thread
|
|
@ -0,0 +1,40 @@
|
|||
import times, sequtils
|
||||
|
||||
type
|
||||
Profile* = object
|
||||
username*: string
|
||||
fullname*: string
|
||||
description*: string
|
||||
userpic*: string
|
||||
banner*: string
|
||||
following*: string
|
||||
followers*: string
|
||||
tweets*: string
|
||||
verified*: bool
|
||||
protected*: bool
|
||||
|
||||
Tweet* = object
|
||||
id*: string
|
||||
profile*: Profile
|
||||
link*: string
|
||||
text*: string
|
||||
time*: Time
|
||||
shortTime*: string
|
||||
replies*: string
|
||||
retweets*: string
|
||||
likes*: string
|
||||
retweetBy*: string
|
||||
pinned*: bool
|
||||
photos*: seq[string]
|
||||
gif*: string
|
||||
|
||||
Tweets* = seq[Tweet]
|
||||
|
||||
Conversation* = object
|
||||
tweet*: Tweet
|
||||
before*: Tweets
|
||||
after*: Tweets
|
||||
replies*: seq[Tweets]
|
||||
|
||||
proc contains*(thread: Tweets; tweet: Tweet): bool =
|
||||
thread.anyIt(it.id == tweet.id)
|
|
@ -0,0 +1,23 @@
|
|||
import strutils, strformat, uri
|
||||
import nimcrypto
|
||||
|
||||
const key = "supersecretkey"
|
||||
|
||||
proc mimetype*(filename: string): string =
|
||||
if ".png" in filename:
|
||||
return "image/" & "png"
|
||||
elif ".jpg" in filename or ".jpeg" in filename:
|
||||
return "image/" & "jpg"
|
||||
elif ".mp4" in filename:
|
||||
return "video/" & "mp4"
|
||||
else:
|
||||
return "text/plain"
|
||||
|
||||
proc getHmac*(data: string): string =
|
||||
($hmac(sha256, key, data))[0 .. 12]
|
||||
|
||||
proc getSigUrl*(link: string; path: string): string =
|
||||
let
|
||||
sig = getHmac(link)
|
||||
url = encodeUrl(link)
|
||||
&"/{path}/{sig}/{url}"
|
|
@ -0,0 +1,38 @@
|
|||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
#import xmltree, strutils, uri
|
||||
#import ../types, ../formatters, ./tweet
|
||||
#
|
||||
#proc renderConversation*(conversation: Conversation): string =
|
||||
<div class="conversation" id="tweets">
|
||||
<div class="main-thread">
|
||||
#if conversation.before.len > 0:
|
||||
<div class="before-tweet">
|
||||
#for tweet in conversation.before:
|
||||
${renderTweet(tweet)}
|
||||
#end for
|
||||
</div>
|
||||
#end if
|
||||
<div class="main-tweet">
|
||||
${renderTweet(conversation.tweet)}
|
||||
</div>
|
||||
#if conversation.after.len > 0:
|
||||
<div class="after-tweet">
|
||||
#for tweet in conversation.after:
|
||||
${renderTweet(tweet)}
|
||||
#end for
|
||||
</div>
|
||||
#end if
|
||||
</div>
|
||||
#if conversation.replies.len > 0:
|
||||
<div class="replies">
|
||||
#for thread in conversation.replies:
|
||||
<div class="thread">
|
||||
#for tweet in thread:
|
||||
${renderTweet(tweet)}
|
||||
#end for
|
||||
</div>
|
||||
#end for
|
||||
</div>
|
||||
#end if
|
||||
</div>
|
||||
#end proc
|
|
@ -0,0 +1,50 @@
|
|||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
#import user
|
||||
#import xmltree
|
||||
#
|
||||
#proc renderMain*(body: string): string =
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Nitter</title>
|
||||
<link rel="stylesheet" type="text/css" href="/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav id="nav" class="nav-bar container">
|
||||
<div class="inner-nav">
|
||||
<div class="item">
|
||||
<a href="/" class="site-name">twatter</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="content" class="container">
|
||||
${body}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
#end proc
|
||||
#
|
||||
#proc renderSearchPanel*(): string =
|
||||
<div class="panel">
|
||||
<div class="search-panel">
|
||||
<form action="search" method="post">
|
||||
<input type="text" name="query" placeholder="Enter username...">
|
||||
<button type="submit" name="button">🔎</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
#end proc
|
||||
#
|
||||
#proc renderError*(error: string): string =
|
||||
<div class="panel">
|
||||
<div class="error-panel">
|
||||
<span>${error}</span>
|
||||
</div>
|
||||
</div>
|
||||
#end proc
|
||||
#
|
||||
#proc showError*(error: string): string =
|
||||
${renderMain(renderError(error))}
|
||||
#end proc
|
|
@ -0,0 +1,99 @@
|
|||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
#import xmltree, strutils, times, sequtils, uri
|
||||
#import ../types, ../formatters, ../utils
|
||||
#
|
||||
#proc renderHeading(tweet: Tweet): string =
|
||||
#if tweet.retweetBy != "":
|
||||
<div class="retweet">
|
||||
<span>🔄 ${tweet.retweetBy} retweeted</span>
|
||||
</div>
|
||||
#end if
|
||||
#if tweet.pinned:
|
||||
<div class="pinned">
|
||||
<span>📌 Pinned Tweet</span>
|
||||
</div>
|
||||
#end if
|
||||
<div class="media-heading">
|
||||
<div class="heading-name-row">
|
||||
<img class="avatar" src=${tweet.profile.getUserpic("_bigger").getSigUrl("pic")}>
|
||||
<div class="name-and-account-name">
|
||||
${linkUser(tweet.profile, "h4", class="username", username=false)}
|
||||
${linkUser(tweet.profile, "", class="account-name")}
|
||||
</div>
|
||||
<span class="heading-right">
|
||||
<a href="${tweet.link}" class="timeago faint-link">
|
||||
<time title="${tweet.time.format("d/M/yyyy', ' HH:mm:ss")}">${tweet.shortTime}</time>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
#end proc
|
||||
#
|
||||
#proc renderMediaGroup(tweet: Tweet): string =
|
||||
#let groups = if tweet.photos.len > 2: tweet.photos.distribute(2) else: @[tweet.photos]
|
||||
#let groupStyle = if groups.len == 1 and groups[0].len < 2: "" else: "background-color: #0f0f0f;"
|
||||
#var first = true
|
||||
<div class="attachments media-body" style="${groupStyle}">
|
||||
#for photos in groups:
|
||||
#let style = if first: "" else: "margin-top: .25em;"
|
||||
<div class="gallery-row cover-fit" style="${style}">
|
||||
#for photo in photos:
|
||||
<div class="attachment image">
|
||||
##TODO: why doesn't this work?
|
||||
<a href=${getSigUrl(photo & ":large", "pic")} target="_blank" class="image-attachment">
|
||||
#let style = if photos.len > 1 or groups.len > 1: "display: flex;" else: ""
|
||||
#let istyle = if photos.len > 1 or groups.len > 1: "" else: "border-radius: 7px;"
|
||||
<div class="still-image" style="${style}">
|
||||
<img src=${getSigUrl(photo, "pic")} referrerpolicy="" style="${istyle}">
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
#end for
|
||||
</div>
|
||||
#first = false
|
||||
#end for
|
||||
</div>
|
||||
#end proc
|
||||
#
|
||||
#proc renderGif(tweet: Tweet): string =
|
||||
#let thumbUrl = getGifThumb(tweet).getSigUrl("pic")
|
||||
#let videoUrl = getGifSrc(tweet).getSigUrl("video")
|
||||
<div class="attachments media-body">
|
||||
<div class="gallery-row" style="max-height: unset;">
|
||||
<div class="attachment image">
|
||||
<video poster=${thumbUrl} style="width: 100%; height: 100%;" autoplay muted loop>
|
||||
<source src=${videoUrl} type="video/mp4">
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
#end proc
|
||||
#
|
||||
#proc renderStats(tweet: Tweet): string =
|
||||
<div class="tweet-stats">
|
||||
<span class="tweet-stat">💬 ${$tweet.replies}</span>
|
||||
<span class="tweet-stat">🔄 ${$tweet.retweets}</span>
|
||||
<span class="tweet-stat">👍 ${$tweet.likes}</span>
|
||||
</div>
|
||||
#end proc
|
||||
#
|
||||
#proc renderTweet*(tweet: Tweet; class=""): string =
|
||||
<div class="${class}">
|
||||
<div class="status-el">
|
||||
<div class="status-body">
|
||||
${renderHeading(tweet)}
|
||||
<div class="status-content-wrapper">
|
||||
<div class="status-content media-body">
|
||||
${linkifyText(tweet.text)}
|
||||
</div>
|
||||
</div>
|
||||
#if tweet.photos.len > 0:
|
||||
${renderMediaGroup(tweet)}
|
||||
#elif tweet.gif.len > 0:
|
||||
${renderGif(tweet)}
|
||||
#end if
|
||||
${renderStats(tweet)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
#end proc
|
|
@ -0,0 +1,100 @@
|
|||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
#import xmltree, strutils, uri, htmlgen
|
||||
#import ../types, ../formatters, ../utils
|
||||
#import ./tweet
|
||||
#
|
||||
#proc renderProfileCard*(profile: Profile): string =
|
||||
#let pic = profile.getUserpic().getSigUrl("pic")
|
||||
#let smallPic = profile.getUserpic("_200x200").getSigUrl("pic")
|
||||
<div class="profile-card">
|
||||
<a class="profile-card-avatar" href="${pic}">
|
||||
<img src="${smallPic}">
|
||||
</a>
|
||||
<div class="profile-card-tabs">
|
||||
<div class="profile-card-tabs-name">
|
||||
${linkUser(profile, "h1", class="profile-card-name", username=false)}
|
||||
${linkUser(profile, "h2", class="profile-card-username")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-card-extra">
|
||||
<div class="profile-bio">
|
||||
#if profile.description.len > 0:
|
||||
<div class="profile-description">
|
||||
<p>${linkifyText(xmltree.escape(profile.description))}</p>
|
||||
</div>
|
||||
#end if
|
||||
</div>
|
||||
|
||||
<div class="profile-card-extra-links">
|
||||
<ul class="profile-statlist">
|
||||
<li class="tweets">
|
||||
<span class="profile-stat-header">Tweets</span>
|
||||
<span>${$profile.tweets}</span>
|
||||
</li>
|
||||
<li class="followers">
|
||||
<span class="profile-stat-header">Followers</span>
|
||||
<span>${$profile.followers}</span>
|
||||
</li>
|
||||
<li class="following">
|
||||
<span class="profile-stat-header">Following</span>
|
||||
<span>${$profile.following}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
#end proc
|
||||
#
|
||||
#proc renderBanner(profile: Profile): string =
|
||||
#if "#" in profile.banner:
|
||||
<div style="${profile.banner}" class="profile-banner-color"></div>
|
||||
#else:
|
||||
#let url = getSigUrl(profile.banner, "pic")
|
||||
<a href="${url}">
|
||||
<img src="${url}">
|
||||
</a>
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc renderTimeline*(tweets: Tweets; profile: Profile; beginning: bool): string =
|
||||
<div id="tweets">
|
||||
#if profile.protected:
|
||||
<div class="timeline-protected">
|
||||
<h2 class="timeline-protected-header">This account's Tweets are protected.</h2>
|
||||
<p class="timeline-protected-explanation">Only confirmed followers have access to @${profile.username}'s Tweets.
|
||||
</div>
|
||||
#end if
|
||||
#if not beginning:
|
||||
<div class="show-more status-el">
|
||||
<a href="/${profile.username}">Load newest tweets</a>
|
||||
</div>
|
||||
#end if
|
||||
#var retweets: Tweets
|
||||
#for tweet in tweets:
|
||||
#if tweet in retweets: continue
|
||||
#end if
|
||||
#if tweet.retweetBy.len > 0: retweets.add tweet
|
||||
#end if
|
||||
${renderTweet(tweet, "timeline-tweet")}
|
||||
#end for
|
||||
#if tweets.len > 0:
|
||||
<div class="show-more">
|
||||
<a href="/${profile.username}?after=${$tweets[^1].id}">Load older tweets</a>
|
||||
</div>
|
||||
#end if
|
||||
</div>
|
||||
#end proc
|
||||
#
|
||||
#proc renderProfile*(profile: Profile; tweets: Tweets; beginning: bool): string =
|
||||
<div class="profile-tabs">
|
||||
<div class="profile-banner">
|
||||
${renderBanner(profile)}
|
||||
</div>
|
||||
<div class="profile-tab">
|
||||
${renderProfileCard(profile)}
|
||||
</div>
|
||||
<div class="timeline-tab">
|
||||
${renderTimeline(tweets, profile, beginning)}
|
||||
</div>
|
||||
</div>
|
||||
#end proc
|
Loading…
Reference in New Issue