diff options
author | Allan Wang <me@allanwang.ca> | 2017-10-11 01:51:21 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-10-11 01:51:21 -0400 |
commit | fe1df730a180316f76c334879da88515a0150a42 (patch) | |
tree | 49ef0590dfbbb7f01347746a8d1f78e15682346a /app/src/main/kotlin/com/pitchedapps | |
parent | d12e0697ad34c02a8f16143c4bddbc2a02e7b3dc (diff) | |
download | frost-fe1df730a180316f76c334879da88515a0150a42.tar.gz frost-fe1df730a180316f76c334879da88515a0150a42.tar.bz2 frost-fe1df730a180316f76c334879da88515a0150a42.zip |
Search Parsing (#379)
* Update parser interface and add search parsing
* Add custom jsoup method and search parse method
* Bind new searchview
* Add search view cache
Diffstat (limited to 'app/src/main/kotlin/com/pitchedapps')
10 files changed, 173 insertions, 76 deletions
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt index 14ee3904..4e1c31d9 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt @@ -27,7 +27,6 @@ import ca.allanwang.kau.searchview.SearchItem import ca.allanwang.kau.searchview.SearchView import ca.allanwang.kau.searchview.bindSearchView import ca.allanwang.kau.utils.* -import ca.allanwang.kau.xml.showChangelog import co.zsmb.materialdrawerkt.builders.Builder import co.zsmb.materialdrawerkt.builders.accountHeader import co.zsmb.materialdrawerkt.builders.drawer @@ -54,21 +53,23 @@ import com.pitchedapps.frost.facebook.FbCookie.switchUser import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.PROFILE_PICTURE_URL import com.pitchedapps.frost.fragments.WebFragment +import com.pitchedapps.frost.parsers.SearchParser import com.pitchedapps.frost.utils.* import com.pitchedapps.frost.utils.iab.FrostBilling import com.pitchedapps.frost.utils.iab.IABMain import com.pitchedapps.frost.utils.iab.IS_FROST_PRO import com.pitchedapps.frost.views.BadgedIcon import com.pitchedapps.frost.views.FrostViewPager -import com.pitchedapps.frost.web.SearchWebView import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import io.reactivex.subjects.PublishSubject +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.uiThread import org.jsoup.Jsoup import java.util.concurrent.TimeUnit -class MainActivity : BaseActivity(), SearchWebView.SearchContract, +class MainActivity : BaseActivity(), ActivityWebContract, FileChooserContract by FileChooserDelegate(), FrostBilling by IABMain() { @@ -84,19 +85,14 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, var webFragmentObservable = PublishSubject.create<Int>()!! var lastPosition = -1 val headerBadgeObservable = PublishSubject.create<String>() - var hiddenSearchView: SearchWebView? = null var firstLoadFinished = false set(value) { if (field && value) return //both vals are already true L.i("First fragment load has finished") field = value - if (value && hiddenSearchView == null) { - hiddenSearchView = SearchWebView(this, this) - } } var searchView: SearchView? = null - override val isSearchOpened: Boolean - get() = searchView?.isOpen ?: false + val searchViewCache = mutableMapOf<String, List<SearchItem>>() companion object { const val ACTIVITY_SETTINGS = 97 @@ -329,20 +325,6 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, onClick { _ -> onClick(); false } } - - /** - * Something happened where the normal search function won't work - * Fallback to overlay style - */ - override fun disposeHeadlessSearch() { - hiddenSearchView = null - searchView?.config { textCallback = { _, _ -> } } - } - - override fun emitSearchResponse(items: List<SearchItem>) { - searchView?.results = items - } - fun refreshAll() { webFragmentObservable.onNext(WebFragment.REQUEST_REFRESH) } @@ -353,20 +335,25 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, setMenuIcons(menu, Prefs.iconColor, R.id.action_settings to GoogleMaterial.Icon.gmd_settings, R.id.action_search to GoogleMaterial.Icon.gmd_search) - if (Prefs.searchBar) { - if (firstLoadFinished && hiddenSearchView == null) hiddenSearchView = SearchWebView(this, this) - if (searchView == null) searchView = bindSearchView(menu, R.id.action_search, Prefs.iconColor) { - textCallback = { query, _ -> runOnUiThread { hiddenSearchView?.query(query) } } - searchCallback = { query, _ -> launchWebOverlay("${FbItem.SEARCH.url}/?q=$query"); true } - foregroundColor = Prefs.textColor - backgroundColor = Prefs.bgColor.withMinAlpha(200) - openListener = { hiddenSearchView?.pauseLoad = false } - closeListener = { hiddenSearchView?.pauseLoad = true } - onItemClick = { _, key, _, _ -> launchWebOverlay(key) } + if (searchView == null) searchView = bindSearchView(menu, R.id.action_search, Prefs.iconColor) { + textCallback = { query, _ -> + val results = searchViewCache[query] + if (results != null) + runOnUiThread { searchView?.results = results } + else + doAsync { + val data = SearchParser.query(query) ?: return@doAsync + val items = data.map { SearchItem(it.href, it.title, it.description) } + searchViewCache.put(query, items) + uiThread { searchView?.results = items } + } } - } else { - if (searchView != null) disposeHeadlessSearch() - else menu.findItem(R.id.action_search).setOnMenuItemClickListener { _ -> launchWebOverlay(FbItem.SEARCH.url); true } + textDebounceInterval = 300 + searchCallback = { query, _ -> launchWebOverlay("${FbItem.SEARCH.url}/?q=$query"); true } + closeListener = { _ -> searchViewCache.clear() } + foregroundColor = Prefs.textColor + backgroundColor = Prefs.bgColor.withMinAlpha(200) + onItemClick = { _, key, _, _ -> launchWebOverlay(key) } } return true } @@ -438,7 +425,7 @@ class MainActivity : BaseActivity(), SearchWebView.SearchContract, } override fun onBackPressed() { - if (searchView?.onBackPressed() ?: false) return + if (searchView?.onBackPressed() == true) return if (currentFragment.onBackPressed()) return super.onBackPressed() } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt index eecd6b48..cbddc77e 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt @@ -6,6 +6,7 @@ import com.github.pwittchen.reactivenetwork.library.rx2.ReactiveNetwork import com.pitchedapps.frost.facebook.FACEBOOK_COM import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.utils.L +import com.pitchedapps.frost.utils.frostJsoup import com.pitchedapps.frost.utils.logFrostAnswers import com.raizlabs.android.dbflow.annotation.ConflictAction import com.raizlabs.android.dbflow.annotation.Database @@ -71,9 +72,7 @@ fun CookieModel.fetchUsername(callback: (String) -> Unit) { if (!yes) return@subscribe callback("") var result = "" try { - result = Jsoup.connect(FbItem.PROFILE.url) - .cookie(FACEBOOK_COM, cookie) - .get().title() + result = frostJsoup(cookie, FbItem.PROFILE.url).title() L.d("Fetch username found", result) } catch (e: Exception) { if (e !is UnknownHostException) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt index 86b280a8..9e247f1e 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt @@ -1,33 +1,78 @@ package com.pitchedapps.frost.parsers +import org.jsoup.nodes.Document + /** * Created by Allan Wang on 2017-10-06. * * Interface for a given parser * Use cases should be attached as delegates to objects that implement this interface + * + * In all cases, parsing will be done from a JSoup document + * Variants accepting strings are also permitted, and they will be converted to documents accordingly */ interface FrostParser<T> { + /** + * Extracts data from the JSoup document + * In some cases, the document can be created directly from a connection + * In other times, it needs to be created from scripts, which otherwise + * won't be parsed + */ + fun parse(doc: Document): T? + + /** + * Parse a String input + */ fun parse(text: String?): T? + + /** + * Take in doc and emit debug output + */ + fun debug(doc: Document): String + + /** + * Attempts to parse input and emit a debugger + */ fun debug(text: String?): String } internal abstract class FrostParserBase<T> : FrostParser<T> { - override final fun parse(text: String?): T? - = if (text == null) null else parseImpl(text) + override final fun parse(text: String?): T? { + text ?: return null + val doc = textToDoc(text) ?: return null + return parse(doc) + } - protected abstract fun parseImpl(text: String): T? + protected abstract fun textToDoc(text: String): Document? - override final fun debug(text: String?): String { + override fun debug(text: String?): String { val result = mutableListOf<String>() result.add("Testing parser for ${this::class.java.simpleName}") if (text == null) { - result.add("Input is null") + result.add("Null text input") + return result.joinToString("\n") + } + val doc = textToDoc(text) + if (doc == null) { + result.add("Null document from text") return result.joinToString("\n") } - val output = parseImpl(text) + return debug(doc, result) + } + + override final fun debug(doc: Document): String { + val result = mutableListOf<String>() + result.add("Testing parser for ${this::class.java.simpleName}") + return debug(doc, result) + } + + private fun debug(doc: Document, result: MutableList<String>): String { + val output = parse(doc) if (output == null) { result.add("Output is null") return result.joinToString("\n") + } else { + result.add("Output is not null") } debugImpl(output, result) return result.joinToString("\n") diff --git a/app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt index 00ede417..7e6ef4bb 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt @@ -5,6 +5,7 @@ import com.pitchedapps.frost.facebook.formattedFbUrlCss import com.pitchedapps.frost.utils.L import org.apache.commons.text.StringEscapeUtils import org.jsoup.Jsoup +import org.jsoup.nodes.Document import org.jsoup.nodes.Element /** @@ -22,7 +23,7 @@ data class FrostLink(val text: String, val href: String) private class MessageParserImpl : FrostParserBase<Triple<List<FrostThread>, FrostLink?, List<FrostLink>>>() { - override fun parseImpl(text: String): Triple<List<FrostThread>, FrostLink?, List<FrostLink>>? { + override fun textToDoc(text: String): Document? { var content = StringEscapeUtils.unescapeEcmaScript(text) val begin = content.indexOf("id=\"threadlist_rows\"") if (begin <= 0) { @@ -36,11 +37,14 @@ private class MessageParserImpl : FrostParserBase<Triple<List<FrostThread>, Fros return null } content = content.substring(0, end).substringBeforeLast("</div>") - val body = Jsoup.parseBodyFragment("<div $content") - val threadList = body.getElementById("threadlist_rows") + return Jsoup.parseBodyFragment("<div $content") + } + + override fun parse(doc: Document): Triple<List<FrostThread>, FrostLink?, List<FrostLink>>? { + val threadList = doc.getElementById("threadlist_rows") val threads: List<FrostThread> = threadList.getElementsByAttributeValueContaining("id", "thread_fbid_") .mapNotNull { parseMessage(it) } - val seeMore = parseLink(body.getElementById("see_older_threads")) + val seeMore = parseLink(doc.getElementById("see_older_threads")) val extraLinks = threadList.nextElementSibling().select("a") .mapNotNull { parseLink(it) } return Triple(threads, seeMore, extraLinks) @@ -76,9 +80,9 @@ private class MessageParserImpl : FrostParserBase<Triple<List<FrostThread>, Fros } override fun debugImpl(data: Triple<List<FrostThread>, FrostLink?, List<FrostLink>>, result: MutableList<String>) { - result.addAll(data.first.map { it.toString() }) + result.addAll(data.first.map(FrostThread::toString)) result.add("See more link:") result.add("\t${data.second}") - result.addAll(data.third.map { it.toString() }) + result.addAll(data.third.map(FrostLink::toString)) } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/parsers/SearchParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/parsers/SearchParser.kt new file mode 100644 index 00000000..0d542a80 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/parsers/SearchParser.kt @@ -0,0 +1,73 @@ +package com.pitchedapps.frost.parsers + +import ca.allanwang.kau.utils.withMaxLength +import com.pitchedapps.frost.facebook.FbItem +import com.pitchedapps.frost.facebook.formattedFbUrlCss +import com.pitchedapps.frost.utils.L +import com.pitchedapps.frost.utils.frostJsoup +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element + +/** + * Created by Allan Wang on 2017-10-09. + */ +object SearchParser : FrostParser<List<FrostSearch>> by SearchParserImpl() { + fun query(input: String): List<FrostSearch>? { + val url = "${FbItem.SEARCH.url}?q=$input" + L.i(null, "Search Query $url") + return parse(frostJsoup(url)) + } +} + +enum class SearchKeys(val key: String) { + USERS("keywords_users"), + EVENTS("keywords_events") +} + +/** + * As far as I'm aware, all links are independent, so the queries don't matter + * A lot of it is tracking information, which I'll strip away + * Other text items are formatted for safety + */ +class FrostSearch(href: String, title: String, description: String?) { + val href = with(href.indexOf("?")) { if (this == -1) href else href.substring(0, this) } + val title = title.format() + val description = description?.format() + + private fun String.format() = replace("\n", " ").withMaxLength(50) + + override fun toString(): String + = "FrostSearch(href=$href, title=$title, description=$description)" + +} + +private class SearchParserImpl : FrostParserBase<List<FrostSearch>>() { + override fun parse(doc: Document): List<FrostSearch>? { + val container: Element = doc.getElementById("BrowseResultsContainer") + ?: doc.getElementById("root") + ?: return null + val hrefSet = mutableSetOf<String>() + /** + * When mapping items, some links are duplicated because they are nested below a main one + * We will filter out search items whose links are already in the list + * + * Removed [data-store*=result_id] + */ + return container.select("a.touchable.primary[href]").filter(Element::hasText).mapNotNull { + val item = FrostSearch(it.attr("href").formattedFbUrlCss, + it.select("._uok").first()?.text() ?: it.text(), + it.select("._1tcc").first()?.text()) + if (hrefSet.contains(item.href)) return@mapNotNull null + hrefSet.add(item.href) + item + } + } + + override fun textToDoc(text: String): Document? = Jsoup.parse(text) + + override fun debugImpl(data: List<FrostSearch>, result: MutableList<String>) { + result.addAll(data.map(FrostSearch::toString)) + } + +}
\ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt index ac3c89dd..c4ab6161 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt @@ -10,16 +10,14 @@ import com.pitchedapps.frost.R import com.pitchedapps.frost.dbflow.CookieModel import com.pitchedapps.frost.dbflow.lastNotificationTime import com.pitchedapps.frost.dbflow.loadFbCookiesSync -import com.pitchedapps.frost.facebook.FACEBOOK_COM import com.pitchedapps.frost.facebook.FbItem -import com.pitchedapps.frost.facebook.USER_AGENT_BASIC import com.pitchedapps.frost.facebook.formattedFbUrl import com.pitchedapps.frost.parsers.MessageParser import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.frostAnswersCustom +import com.pitchedapps.frost.utils.frostJsoup import org.jetbrains.anko.doAsync -import org.jsoup.Jsoup import org.jsoup.nodes.Element import java.util.concurrent.Future @@ -101,7 +99,7 @@ class NotificationService : JobService() { fun fetchGeneralNotifications(data: CookieModel) { L.d("Notif fetch", data.toString()) - val doc = Jsoup.connect(FbItem.NOTIFICATIONS.url).cookie(FACEBOOK_COM, data.cookie).userAgent(USER_AGENT_BASIC).get() + val doc = frostJsoup(data.cookie, FbItem.NOTIFICATIONS.url) //aclb for unread, acw for read val unreadNotifications = (doc.getElementById("notifications_list") ?: return L.eThrow("Notification list not found")).getElementsByClass("aclb") var notifCount = 0 @@ -149,7 +147,7 @@ class NotificationService : JobService() { fun fetchMessageNotifications(data: CookieModel) { L.d("Notif IM fetch", data.toString()) - val doc = Jsoup.connect(FbItem.MESSAGES.url).cookie(FACEBOOK_COM, data.cookie).userAgent(USER_AGENT_BASIC).get() + val doc = frostJsoup(data.cookie, FbItem.MESSAGES.url) val (threads, _, _) = MessageParser.parse(doc.toString()) ?: return L.e("Could not parse IM") var notifCount = 0 diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Behaviour.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Behaviour.kt index acdd835e..db2eea4b 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Behaviour.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Behaviour.kt @@ -31,10 +31,6 @@ fun SettingsActivity.getBehaviourPrefs(): KPrefAdapterBuilder.() -> Unit = { descRes = R.string.viewpager_swipe_desc } - checkbox(R.string.search_bar, { Prefs.searchBar }, { Prefs.searchBar = it; setFrostResult(MainActivity.REQUEST_SEARCH) }) { - descRes = R.string.search_bar_desc - } - checkbox(R.string.force_message_bottom, { Prefs.messageScrollToBottom }, { Prefs.messageScrollToBottom = it }) { descRes = R.string.force_message_bottom_desc } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt index 5784e2a8..a4f4388f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt @@ -14,10 +14,7 @@ import com.pitchedapps.frost.facebook.USER_AGENT_BASIC import com.pitchedapps.frost.injectors.InjectorContract import com.pitchedapps.frost.injectors.JsActions import com.pitchedapps.frost.injectors.JsAssets -import com.pitchedapps.frost.utils.L -import com.pitchedapps.frost.utils.cleanHtml -import com.pitchedapps.frost.utils.materialDialogThemed -import com.pitchedapps.frost.utils.sendFrostEmail +import com.pitchedapps.frost.utils.* import com.pitchedapps.frost.web.launchHeadlessHtmlExtractor import com.pitchedapps.frost.web.query import io.reactivex.disposables.Disposable @@ -119,11 +116,7 @@ private enum class Debugger(val data: FbItem, val injector: InjectorContract?, v uiThread { it.setContent("Load Jsoup") it.setOnCancelListener(null) - it.debugAsync { - val connection = Jsoup.connect(data.url).cookie(FACEBOOK_COM, FbCookie.webCookie).userAgent(USER_AGENT_BASIC) - val doc = connection.get() - simplifyJsoup(doc) - } + it.debugAsync { simplifyJsoup(frostJsoup(data.url)) } } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Prefs.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Prefs.kt index 70144f9e..46830e65 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Prefs.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Prefs.kt @@ -134,8 +134,6 @@ object Prefs : KPref() { var analytics: Boolean by kpref("analytics", true) - var searchBar: Boolean by kpref("search_bar", true) - var overlayEnabled: Boolean by kpref("overlay_enabled", true) var overlayFullScreenSwipe: Boolean by kpref("overlay_full_screen_swipe", true) @@ -152,4 +150,6 @@ object Prefs : KPref() { val mainActivityLayout: MainActivityLayout get() = MainActivityLayout(mainActivityLayoutType) + + override fun deleteKeys() = arrayOf("search_bar") } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt index 5726409c..112269c1 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt @@ -29,11 +29,9 @@ import com.pitchedapps.frost.BuildConfig import com.pitchedapps.frost.R import com.pitchedapps.frost.activities.* import com.pitchedapps.frost.dbflow.CookieModel -import com.pitchedapps.frost.facebook.FACEBOOK_COM -import com.pitchedapps.frost.facebook.FbCookie -import com.pitchedapps.frost.facebook.FbItem -import com.pitchedapps.frost.facebook.formattedFbUrl +import com.pitchedapps.frost.facebook.* import com.pitchedapps.frost.utils.iab.IS_FROST_PRO +import org.jsoup.Jsoup import java.io.IOException import java.util.* @@ -219,5 +217,9 @@ inline fun Context.sendFrostEmail(subjectId: String, crossinline builder: EmailB addItem("Random Frost ID", "${Prefs.frostId}-$proTag") } +fun frostJsoup(url: String) + = frostJsoup(FbCookie.webCookie, url) +fun frostJsoup(cookie: String?, url: String) + = Jsoup.connect(url).cookie(FACEBOOK_COM, cookie).userAgent(USER_AGENT_BASIC).get()!! |