diff --git a/src/api.nim b/src/api.nim
index 2cffc66..3b45b0f 100644
--- a/src/api.nim
+++ b/src/api.nim
@@ -1,2 +1,2 @@
-import api/[media, profile, timeline, tweet, search]
+import api/[profile, timeline, tweet, search, media]
export profile, timeline, tweet, search, media
diff --git a/src/api/search.nim b/src/api/search.nim
index f07a864..21cf9b8 100644
--- a/src/api/search.nim
+++ b/src/api/search.nim
@@ -1,32 +1,56 @@
import httpclient, asyncdispatch, htmlparser
import sequtils, strutils, json, xmltree, uri
-import ".."/[types, parser, parserutils, formatters, search]
+import ".."/[types, parser, parserutils, formatters, query]
import utils, consts, media, timeline
-proc getTimelineSearch*(query: Query; after, agent: string): Future[Timeline] {.async.} =
- let queryParam = genQueryParam(query)
- let queryEncoded = encodeUrl(queryParam, usePlus=false)
+proc getResult[T](json: JsonNode; query: Query; after: string): Result[T] =
+ Result[T](
+ hasMore: json["has_more_items"].to(bool),
+ maxId: json.getOrDefault("max_position").getStr(""),
+ minId: json.getOrDefault("min_position").getStr("").cleanPos(),
+ query: query.some,
+ beginning: after.len == 0
+ )
- let headers = newHttpHeaders({
- "Accept": jsonAccept,
- "Referer": $(base / ("search?f=tweets&vertical=default&q=$1&src=typd" % queryEncoded)),
- "User-Agent": agent,
- "X-Requested-With": "XMLHttpRequest",
- "Authority": "twitter.com",
- "Accept-Language": lang
- })
+proc getSearch*[T](query: Query; after, agent: string): Future[Result[T]] {.async.} =
+ let
+ kind = if query.kind == users: "users" else: "tweets"
+ pos = when T is Tweet: genPos(after) else: after
- let params = {
- "f": "tweets",
- "vertical": "default",
- "q": queryParam,
- "src": "typd",
- "include_available_features": "1",
- "include_entities": "1",
- "max_position": if after.len > 0: genPos(after) else: "0",
- "reset_error_state": "false"
- }
+ param = genQueryParam(query)
+ encoded = encodeUrl(param, usePlus=false)
+
+ headers = newHttpHeaders({
+ "Accept": jsonAccept,
+ "Referer": $(base / ("search?f=$1&q=$2&src=typd" % [kind, encoded])),
+ "User-Agent": agent,
+ "X-Requested-With": "XMLHttpRequest",
+ "Authority": "twitter.com",
+ "Accept-Language": lang
+ })
+
+ params = {
+ "f": kind,
+ "vertical": "default",
+ "q": param,
+ "src": "typd",
+ "include_available_features": "1",
+ "include_entities": "1",
+ "max_position": if pos.len > 0: pos else: "0",
+ "reset_error_state": "false"
+ }
let json = await fetchJson(base / searchUrl ? params, headers)
- result = await finishTimeline(json, some(query), after, agent)
+ if json == nil: return Result[T]()
+
+ result = getResult[T](json, query, after)
+ if not json.hasKey("items_html"): return
+ let html = parseHtml(json["items_html"].to(string))
+
+ when T is Tweet:
+ result = await finishTimeline(json, some(query), after, agent)
+ elif T is Profile:
+ result.hasMore = json["items_html"].to(string) != "\n"
+ for p in html.selectAll(".js-stream-item"):
+ result.content.add parsePopupProfile(p, ".ProfileCard")
diff --git a/src/api/timeline.nim b/src/api/timeline.nim
index e917152..1f56cb7 100644
--- a/src/api/timeline.nim
+++ b/src/api/timeline.nim
@@ -1,7 +1,7 @@
import httpclient, asyncdispatch, htmlparser
import sequtils, strutils, json, xmltree, uri
-import ".."/[types, parser, parserutils, formatters, search]
+import ".."/[types, parser, parserutils, formatters, query]
import utils, consts, media
proc finishTimeline*(json: JsonNode; query: Option[Query]; after, agent: string): Future[Timeline] {.async.} =
diff --git a/src/nitter.nim b/src/nitter.nim
index 7a4f746..41dc3b9 100644
--- a/src/nitter.nim
+++ b/src/nitter.nim
@@ -5,13 +5,14 @@ import jester
import types, config, prefs
import views/[general, about]
-import routes/[preferences, timeline, media, rss]
+import routes/[preferences, timeline, media, search, rss]
const configPath {.strdefine.} = "./nitter.conf"
let cfg = getConfig(configPath)
createPrefRouter(cfg)
createTimelineRouter(cfg)
+createSearchRouter(cfg)
createMediaRouter(cfg)
createRssRouter(cfg)
@@ -27,13 +28,9 @@ routes:
get "/about":
resp renderMain(renderAbout(), Prefs(), cfg.title)
- post "/search":
- if @"query".len == 0:
- resp Http404, showError("Please enter a username.", cfg.title)
- redirect("/" & @"query")
-
extend preferences, ""
extend rss, ""
+ extend search, ""
extend timeline, ""
extend media, ""
diff --git a/src/parser.nim b/src/parser.nim
index 17956df..0285c10 100644
--- a/src/parser.nim
+++ b/src/parser.nim
@@ -23,14 +23,14 @@ proc parseTimelineProfile*(node: XmlNode): Profile =
result.getProfileStats(node.select(".ProfileNav-list"))
-proc parsePopupProfile*(node: XmlNode): Profile =
- let profile = node.select(".profile-card")
+proc parsePopupProfile*(node: XmlNode; selector=".profile-card"): Profile =
+ let profile = node.select(selector)
if profile == nil: return
result = Profile(
fullname: profile.getName(".fullname"),
username: profile.getUsername(".username"),
- bio: profile.getBio(".bio"),
+ bio: profile.getBio(".bio", fallback=".ProfileCard-bio"),
userpic: profile.getAvatar(".ProfileCard-avatarImage"),
verified: isVerified(profile),
protected: isProtected(profile),
diff --git a/src/parserutils.nim b/src/parserutils.nim
index 6de7bdf..90ebd17 100644
--- a/src/parserutils.nim
+++ b/src/parserutils.nim
@@ -86,8 +86,11 @@ proc getName*(profile: XmlNode; selector: string): string =
proc getUsername*(profile: XmlNode; selector: string): string =
profile.selectText(selector).strip(chars={'@', ' ', '\n'})
-proc getBio*(profile: XmlNode; selector: string): string =
- profile.selectText(selector).stripText()
+proc getBio*(profile: XmlNode; selector: string; fallback=""): string =
+ var bio = profile.selectText(selector)
+ if bio.len == 0 and fallback.len > 0:
+ bio = profile.selectText(fallback)
+ stripText(bio)
proc getAvatar*(profile: XmlNode; selector: string): string =
profile.selectAttr(selector, "src").getUserpic()
diff --git a/src/search.nim b/src/query.nim
similarity index 85%
rename from src/search.nim
rename to src/query.nim
index c7b118b..2baa3d9 100644
--- a/src/search.nim
+++ b/src/query.nim
@@ -18,8 +18,7 @@ const
posPrefix = "thGAVUV0VFVBa"
posSuffix = "EjUAFQAlAFUAFQAA"
-proc initQuery*(filters, includes, excludes, separator, text: string;
- name=""): Query =
+proc initQuery*(filters, includes, excludes, separator, text: string; name=""): Query =
var sep = separator.strip().toUpper()
Query(
kind: custom,
@@ -50,6 +49,9 @@ proc genQueryParam*(query: Query): string =
var filters: seq[string]
var param: string
+ if query.kind == users:
+ return query.text
+
for i, user in query.fromUser:
param &= &"from:{user} "
if i < query.fromUser.high:
@@ -67,12 +69,18 @@ proc genQueryParam*(query: Query): string =
result &= " " & query.text
proc genQueryUrl*(query: Query): string =
- if query.kind == multi: return "?"
+ if query.fromUser.len > 0:
+ result = "/" & query.fromUser.join(",")
- result = &"/{query.kind}?"
- if query.kind != custom: return
+ if query.kind == multi:
+ return result & "?"
- var params: seq[string]
+ if query.kind notin {custom, users}:
+ return result & &"/{query.kind}?"
+
+ result &= &"/search?"
+
+ var params = @[&"kind={query.kind}"]
if query.filters.len > 0:
params &= "filter=" & query.filters.join(",")
if query.includes.len > 0:
@@ -84,7 +92,7 @@ proc genQueryUrl*(query: Query): string =
if query.text.len > 0:
params &= "text=" & query.text
if params.len > 0:
- result &= params.join("&") & "&"
+ result &= params.join("&")
proc cleanPos*(pos: string): string =
pos.multiReplace((posPrefix, ""), (posSuffix, ""))
diff --git a/src/routes/search.nim b/src/routes/search.nim
new file mode 100644
index 0000000..b22af77
--- /dev/null
+++ b/src/routes/search.nim
@@ -0,0 +1,30 @@
+import strutils, uri
+
+import jester
+
+import router_utils
+import ".."/[query, types, utils, api, agents]
+import ../views/[general, search]
+
+export search
+
+proc createSearchRouter*(cfg: Config) =
+ router search:
+ get "/search":
+ if @"text".len == 0 or "." in @"text":
+ resp Http404, showError("Please enter a valid username.", cfg.title)
+
+ if "," in @"text":
+ redirect("/" & @"text")
+
+ let query = Query(kind: parseEnum[QueryKind](@"kind", custom), text: @"text")
+
+ case query.kind
+ of users:
+ let users = await getSearch[Profile](query, @"after", getAgent())
+ resp renderMain(renderUserSearch(users, Prefs()), Prefs(), path=getPath())
+ of custom:
+ let tweets = await getSearch[Tweet](query, @"after", getAgent())
+ resp renderMain(renderTweetSearch(tweets, Prefs(), getPath()), Prefs(), path=getPath())
+ else:
+ resp Http404
diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim
index 09c2958..eb2dcb7 100644
--- a/src/routes/timeline.nim
+++ b/src/routes/timeline.nim
@@ -3,14 +3,14 @@ import asyncdispatch, strutils, sequtils, uri
import jester
import router_utils
-import ".."/[api, prefs, types, utils, cache, formatters, agents, search]
-import ../views/[general, profile, timeline, status]
+import ".."/[api, prefs, types, utils, cache, formatters, agents, query]
+import ../views/[general, profile, timeline, status, search]
include "../views/rss.nimf"
export uri, sequtils
export router_utils
-export api, cache, formatters, search, agents
+export api, cache, formatters, query, agents
export profile, timeline, status
type ProfileTimeline = (Profile, Timeline, seq[GalleryPhoto])
@@ -33,7 +33,7 @@ proc fetchSingleTimeline*(name, after, agent: string;
(profile, timeline) = await getProfileAndTimeline(name, agent, after)
cache(profile)
else:
- var timelineFut = getTimelineSearch(get(query), after, agent)
+ var timelineFut = getSearch[Tweet](get(query), after, agent)
if cachedProfile.isNone:
profile = await getCachedProfile(name, agent)
timeline = await timelineFut
@@ -49,7 +49,7 @@ proc fetchMultiTimeline*(names: seq[string]; after, agent: string;
else:
q = some(Query(kind: multi, fromUser: names, excludes: @["replies"]))
- return await getTimelineSearch(get(q), after, agent)
+ return await getSearch[Tweet](get(q), after, agent)
proc showTimeline*(name, after: string; query: Option[Query];
prefs: Prefs; path, title, rss: string): Future[string] {.async.} =
diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss
index 4d4cb49..4e069af 100644
--- a/src/sass/profile/_base.scss
+++ b/src/sass/profile/_base.scss
@@ -43,6 +43,14 @@
top: 50px;
}
+.profile-result .username {
+ margin: 0 !important;
+}
+
+.profile-result .tweet-header {
+ margin-bottom: unset;
+}
+
@media(max-width: 600px) {
.profile-tabs {
width: 100vw;
diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss
index fa536bd..ff8b048 100644
--- a/src/sass/timeline.scss
+++ b/src/sass/timeline.scss
@@ -20,6 +20,14 @@
display: block;
font-weight: bold;
margin-bottom: 5px;
+
+ input[type="text"] {
+ height: 20px;
+ }
+
+ button {
+ float: unset;
+ }
}
.tab {
diff --git a/src/types.nim b/src/types.nim
index 772c8ed..de28cfe 100644
--- a/src/types.nim
+++ b/src/types.nim
@@ -57,7 +57,7 @@ dbFromTypes("cache.db", "", "", "", [Profile, Video])
type
QueryKind* = enum
- replies, media, multi, custom = "search"
+ replies, media, multi, users, custom
Query* = object
kind*: QueryKind
diff --git a/src/views/general.nim b/src/views/general.nim
index b5b1db3..fbbd17e 100644
--- a/src/views/general.nim
+++ b/src/views/general.nim
@@ -60,13 +60,6 @@ proc renderMain*(body: VNode; prefs: Prefs; title="Nitter"; titleText=""; desc="
result = doctype & $node
-proc renderSearch*(): VNode =
- buildHtml(tdiv(class="panel-container")):
- tdiv(class="search-panel"):
- form(`method`="post", action="/search"):
- input(`type`="text", name="query", autofocus="", placeholder="Enter usernames...")
- button(`type`="submit"): icon "search"
-
proc renderError*(error: string): VNode =
buildHtml(tdiv(class="panel-container")):
tdiv(class="error-panel"):
diff --git a/src/views/profile.nim b/src/views/profile.nim
index 7002248..87445ef 100644
--- a/src/views/profile.nim
+++ b/src/views/profile.nim
@@ -98,5 +98,5 @@ proc renderProfile*(profile: Profile; timeline: Timeline;
if profile.protected:
renderProtected(profile.username)
else:
- renderProfileTabs(timeline, profile.username)
+ renderProfileTabs(timeline.query, profile.username)
renderTimelineTweets(timeline, prefs, path)
diff --git a/src/views/search.nim b/src/views/search.nim
new file mode 100644
index 0000000..f3043bf
--- /dev/null
+++ b/src/views/search.nim
@@ -0,0 +1,38 @@
+import strutils, strformat
+import karax/[karaxdsl, vdom, vstyles]
+
+import renderutils, timeline
+import ".."/[types, formatters, query]
+
+proc renderSearch*(): VNode =
+ buildHtml(tdiv(class="panel-container")):
+ tdiv(class="search-panel"):
+ form(`method`="get", action="/search"):
+ verbatim ""
+ input(`type`="text", name="text", autofocus="", placeholder="Enter username...")
+ button(`type`="submit"): icon "search"
+
+proc renderTweetSearch*(timeline: Timeline; prefs: Prefs; path: string): VNode =
+ let users = get(timeline.query).fromUser
+ buildHtml(tdiv(class="timeline-container")):
+ tdiv(class="timeline-header"):
+ text users.join(" | ")
+
+ renderProfileTabs(timeline.query, users.join(","))
+ renderTimelineTweets(timeline, prefs, path)
+
+proc renderUserSearch*(users: Result[Profile]; prefs: Prefs): VNode =
+ let searchText =
+ if users.query.isSome: get(users.query).text
+ else: ""
+
+ buildHtml(tdiv(class="timeline-container")):
+ tdiv(class="timeline-header"):
+ form(`method`="get", action="/search"):
+ verbatim ""
+ verbatim "" % searchText
+ button(`type`="submit"): icon "search"
+
+ renderSearchTabs(users.query)
+
+ renderTimelineUsers(users, prefs)
diff --git a/src/views/timeline.nim b/src/views/timeline.nim
index eccb16f..aabdf53 100644
--- a/src/views/timeline.nim
+++ b/src/views/timeline.nim
@@ -12,27 +12,35 @@ proc getQuery(query: Option[Query]): string =
if result[^1] != '?':
result &= "&"
-proc getTabClass(results: Result; tab: string): string =
+proc getTabClass(query: Option[Query]; tab: string): string =
var classes = @["tab-item"]
- if results.query.isNone or get(results.query).kind == multi:
+ if query.isNone or get(query).kind == multi:
if tab == "posts":
classes.add "active"
- elif $get(results.query).kind == tab:
+ elif $get(query).kind == tab:
classes.add "active"
return classes.join(" ")
-proc renderProfileTabs*(timeline: Timeline; username: string): VNode =
+proc renderProfileTabs*(query: Option[Query]; username: string): VNode =
let link = "/" & username
buildHtml(ul(class="tab")):
- li(class=timeline.getTabClass("posts")):
+ li(class=query.getTabClass("posts")):
a(href=link): text "Tweets"
- li(class=timeline.getTabClass("replies")):
+ li(class=query.getTabClass("replies")):
a(href=(link & "/replies")): text "Tweets & Replies"
- li(class=timeline.getTabClass("media")):
+ li(class=query.getTabClass("media")):
a(href=(link & "/media")): text "Media"
+proc renderSearchTabs*(query: Option[Query]): VNode =
+ var q = if query.isSome: get(query) else: Query()
+
+ buildHtml(ul(class="tab")):
+ li(class=query.getTabClass("users")):
+ q.kind = users
+ a(href=genQueryUrl(q)): text "Users"
+
proc renderNewer(query: Option[Query]): VNode =
buildHtml(tdiv(class="timeline-item show-more")):
a(href=(getQuery(query).strip(chars={'?', '&'}))):
@@ -62,6 +70,34 @@ proc renderThread(thread: seq[Tweet]; prefs: Prefs; path: string): VNode =
proc threadFilter(it: Tweet; tweetThread: string): bool =
it.retweet.isNone and it.reply.len == 0 and it.threadId == tweetThread
+proc renderUser(user: Profile; prefs: Prefs): VNode =
+ buildHtml(tdiv(class="timeline-item")):
+ tdiv(class="tweet-body profile-result"):
+ tdiv(class="tweet-header"):
+ a(class="tweet-avatar", href=("/" & user.username)):
+ genImg(user.getUserpic("_bigger"), class="avatar")
+
+ tdiv(class="tweet-name-row"):
+ tdiv(class="fullname-and-username"):
+ linkUser(user, class="fullname")
+ linkUser(user, class="username")
+
+ tdiv(class="tweet-content media-body"):
+ verbatim linkifyText(user.bio, prefs)
+
+proc renderTimelineUsers*(results: Result[Profile]; prefs: Prefs): VNode =
+ buildHtml(tdiv(class="timeline")):
+ if not results.beginning:
+ renderNewer(results.query)
+
+ if results.content.len > 0:
+ for user in results.content:
+ renderUser(user, prefs)
+ renderOlder(results.query, results.minId)
+ elif results.beginning:
+ renderNoneFound()
+ else:
+ renderNoMore()
proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="timeline")):