Fix episode parsing: use li.searchText[data-ep-idx], add category to URL
- Parse all episodes from li.searchText[data-ep-idx] (1600+ episodes) - Extract category from seriesUrl for correct episode URL construction - Add focus_border drawable and D-pad focus animation in search results - Increase poster size (120dp -> 160dp) and improve item layout - Add try-catch in search for crash protection - Add old_ent and old_drama categories
This commit is contained in:
@@ -413,150 +413,39 @@ val episodes = mutableListOf<Episode>()
|
|||||||
val videoLinks = mutableListOf<VideoLink>()
|
val videoLinks = mutableListOf<VideoLink>()
|
||||||
val seenEpisodeIds = mutableSetOf<String>()
|
val seenEpisodeIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
val category = seriesUrl.substringAfter("$BASE_URL/").substringBefore("/")
|
||||||
val seriesId = seriesUrl.substringAfterLast("/").substringBefore("?")
|
val seriesId = seriesUrl.substringAfterLast("/").substringBefore("?")
|
||||||
|
|
||||||
// Parse episodes from #epListScroll first (ep-item class)
|
val allEpisodeItems = doc.select("#other_list li.searchText[data-ep-idx]")
|
||||||
val epListScroll = doc.select("#epListScroll")
|
|
||||||
val allEpisodeLinks = if (epListScroll.isNotEmpty()) {
|
|
||||||
epListScroll.select("a.ep-item[href*='/$seriesId/']")
|
|
||||||
} else {
|
|
||||||
doc.select("a[href*='/$seriesId/'], a[href*='/${seriesId}/']")
|
|
||||||
}
|
|
||||||
|
|
||||||
var episodeIndex = 0
|
for (item in allEpisodeItems) {
|
||||||
|
val episodeId = item.attr("data-ep-idx")
|
||||||
for (link in allEpisodeLinks) {
|
if (episodeId.isBlank() || episodeId in seenEpisodeIds) continue
|
||||||
val href = link.attr("href")
|
|
||||||
if (href.isBlank()) continue
|
|
||||||
if (!href.contains("/$seriesId/") && !href.contains("/${seriesId}/")) continue
|
|
||||||
|
|
||||||
val fullUrl = resolveUrl(href)
|
|
||||||
|
|
||||||
val episodeIdMatch = Pattern.compile("/$seriesId/(\\d+)").matcher(href)
|
|
||||||
if (!episodeIdMatch.find()) continue
|
|
||||||
|
|
||||||
val episodeId = episodeIdMatch.group(1) ?: ""
|
|
||||||
if (episodeId in seenEpisodeIds) continue
|
|
||||||
seenEpisodeIds.add(episodeId)
|
seenEpisodeIds.add(episodeId)
|
||||||
|
|
||||||
val linkText = link.text().trim()
|
val fullUrl = "$BASE_URL/$category/$seriesId/$episodeId"
|
||||||
if (linkText.isBlank()) continue
|
|
||||||
|
|
||||||
if (linkText.contains("로그인") || linkText.contains("비밀번호") ||
|
val classText = item.className().replace("searchText", "").trim()
|
||||||
linkText.contains("마이페이지") || linkText.contains("전체 목록")) {
|
val episodeNumMatch = Pattern.compile("(\\d+)").matcher(classText)
|
||||||
continue
|
val episodeNum = if (episodeNumMatch.find()) episodeNumMatch.group(1) ?: "" else ""
|
||||||
}
|
|
||||||
|
|
||||||
val cleanLinkText = linkText
|
if (episodeNum.isBlank()) continue
|
||||||
.replace("시청중", "")
|
|
||||||
.replace("NEW", "")
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
val datePattern = Pattern.compile("^(\\d{2})[./](\\d{2})[./](\\d{2,4})\\s+(.+)$|^(\\d{2})[./](\\d{2})[./](\\d{2,4})(.+)$")
|
|
||||||
val dateMatch = datePattern.matcher(linkText)
|
|
||||||
var dateStr = ""
|
|
||||||
var episodeTitleStr = cleanLinkText
|
|
||||||
|
|
||||||
if (dateMatch.find()) {
|
|
||||||
val day = dateMatch.group(1) ?: dateMatch.group(5)
|
|
||||||
val month = dateMatch.group(2) ?: dateMatch.group(6)
|
|
||||||
val year = dateMatch.group(3) ?: dateMatch.group(7)
|
|
||||||
val titlePart = dateMatch.group(4) ?: dateMatch.group(8)
|
|
||||||
|
|
||||||
if (day != null && month != null && year != null) {
|
|
||||||
dateStr = if (year.length == 2) {
|
|
||||||
"20$year/$month/$day"
|
|
||||||
} else {
|
|
||||||
"$year/$month/$day"
|
|
||||||
}
|
|
||||||
episodeTitleStr = titlePart?.trim() ?: cleanLinkText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val episodeNumMatch = Pattern.compile("(\\d+)\\s*화|(\\d+)\\s*회|EP\\.?(\\d+)|제\\s*(\\d+)\\s*부").matcher(episodeTitleStr)
|
|
||||||
val episodeTitle = if (episodeNumMatch.find()) {
|
|
||||||
episodeNumMatch.group(1) ?: episodeNumMatch.group(2) ?: episodeNumMatch.group(3) ?: episodeNumMatch.group(4)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val finalNumber = if (!episodeTitle.isNullOrBlank()) {
|
|
||||||
episodeTitle
|
|
||||||
} else {
|
|
||||||
episodeIndex++.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
episodes.add(Episode(
|
episodes.add(Episode(
|
||||||
number = dateStr.ifBlank { finalNumber },
|
number = episodeNum,
|
||||||
title = episodeTitleStr.ifBlank { finalNumber },
|
title = classText.ifBlank { "${episodeNum}화" },
|
||||||
url = fullUrl,
|
url = fullUrl,
|
||||||
type = "webview",
|
type = "webview",
|
||||||
date = dateStr
|
date = ""
|
||||||
))
|
))
|
||||||
|
|
||||||
videoLinks.add(VideoLink(
|
videoLinks.add(VideoLink(
|
||||||
type = "play_page",
|
type = "play_page",
|
||||||
url = fullUrl,
|
url = fullUrl,
|
||||||
title = linkText.ifBlank { finalNumber }
|
title = "${episodeNum}화"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have 25+ episodes, try to fetch more episodes from the series list page
|
|
||||||
if (episodes.size >= 25) {
|
|
||||||
val category = getCategoryFromUrl(seriesUrl)
|
|
||||||
val listPageUrl = "$BASE_URL/$category/$seriesId"
|
|
||||||
val listHtml = get(listPageUrl)
|
|
||||||
if (listHtml != null) {
|
|
||||||
val listDoc = Jsoup.parse(listHtml)
|
|
||||||
val listEpLinks = listDoc.select("a[href*='/$seriesId/']")
|
|
||||||
for (link in listEpLinks) {
|
|
||||||
val href = link.attr("href")
|
|
||||||
if (href.isBlank()) continue
|
|
||||||
|
|
||||||
val fullUrl = resolveUrl(href)
|
|
||||||
if (seenEpisodeIds.contains(fullUrl)) continue
|
|
||||||
|
|
||||||
val episodeIdMatch = Pattern.compile("/$seriesId/(\\d+)").matcher(href)
|
|
||||||
if (!episodeIdMatch.find()) continue
|
|
||||||
val episodeId = episodeIdMatch.group(1) ?: ""
|
|
||||||
if (episodeId in seenEpisodeIds) continue
|
|
||||||
seenEpisodeIds.add(episodeId)
|
|
||||||
|
|
||||||
val linkText = link.text().trim()
|
|
||||||
if (linkText.isBlank()) continue
|
|
||||||
if (linkText.contains("로그인") || linkText.contains("비밀번호") ||
|
|
||||||
linkText.contains("마이페이지") || linkText.contains("전체 목록")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val cleanLinkText = linkText.replace("시청중", "").replace("NEW", "").trim()
|
|
||||||
|
|
||||||
val episodeNumMatch = Pattern.compile("(\\d+)\\s*화|(\\d+)\\s*회|EP\\.?(\\d+)|제\\s*(\\d+)\\s*부").matcher(cleanLinkText)
|
|
||||||
val episodeTitle = if (episodeNumMatch.find()) {
|
|
||||||
episodeNumMatch.group(1) ?: episodeNumMatch.group(2) ?: episodeNumMatch.group(3) ?: episodeNumMatch.group(4)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val finalNumber = episodeTitle ?: (episodes.size + 1).toString()
|
|
||||||
|
|
||||||
episodes.add(Episode(
|
|
||||||
number = finalNumber,
|
|
||||||
title = cleanLinkText.ifBlank { finalNumber },
|
|
||||||
url = fullUrl,
|
|
||||||
type = "webview",
|
|
||||||
date = ""
|
|
||||||
))
|
|
||||||
|
|
||||||
videoLinks.add(VideoLink(
|
|
||||||
type = "play_page",
|
|
||||||
url = fullUrl,
|
|
||||||
title = linkText
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
episodes.sortByDescending { episode ->
|
episodes.sortByDescending { episode ->
|
||||||
val numberStr = episode.number
|
val numberStr = episode.number
|
||||||
val pattern = Pattern.compile("\\d+")
|
val pattern = Pattern.compile("\\d+")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import android.view.View
|
|||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.example.tvmon.R
|
import com.example.tvmon.R
|
||||||
import com.example.tvmon.data.model.Content
|
import com.example.tvmon.data.model.Content
|
||||||
@@ -20,7 +20,6 @@ class SearchActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "TVMON_SEARCH"
|
private const val TAG = "TVMON_SEARCH"
|
||||||
private const val NUM_COLUMNS = 4
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val scraper: TvmonScraper by inject()
|
private val scraper: TvmonScraper by inject()
|
||||||
@@ -48,9 +47,9 @@ class SearchActivity : AppCompatActivity() {
|
|||||||
openDetail(content)
|
openDetail(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
recyclerView.layoutManager = GridLayoutManager(this, NUM_COLUMNS)
|
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||||
recyclerView.adapter = adapter
|
recyclerView.adapter = adapter
|
||||||
recyclerView.setHasFixedSize(true)
|
recyclerView.setHasFixedSize(false)
|
||||||
|
|
||||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
package com.example.tvmon.ui.search
|
package com.example.tvmon.ui.search
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.leanback.widget.Presenter
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.example.tvmon.R
|
||||||
import com.example.tvmon.data.model.Content
|
import com.example.tvmon.data.model.Content
|
||||||
import com.example.tvmon.ui.presenter.SearchCardPresenter
|
|
||||||
|
|
||||||
class SearchResultsAdapter(
|
class SearchResultsAdapter(
|
||||||
private val onItemClick: (Content) -> Unit
|
private val onItemClick: (Content) -> Unit
|
||||||
) : RecyclerView.Adapter<SearchResultsAdapter.ViewHolder>() {
|
) : RecyclerView.Adapter<SearchResultsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
private val presenter = SearchCardPresenter()
|
|
||||||
private var results = mutableListOf<Content>()
|
private var results = mutableListOf<Content>()
|
||||||
|
|
||||||
fun updateResults(newResults: List<Content>) {
|
fun updateResults(newResults: List<Content>) {
|
||||||
@@ -20,18 +23,60 @@ class SearchResultsAdapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
return ViewHolder(presenter.onCreateViewHolder(parent))
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_search_result, parent, false)
|
||||||
|
return ViewHolder(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
val content = results[position]
|
val content = results[position]
|
||||||
presenter.onBindViewHolder(holder.viewHolder, content)
|
holder.bind(content, onItemClick)
|
||||||
holder.viewHolder.view.setOnClickListener { onItemClick(content) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
override fun getItemCount(): Int = results.size
|
override fun getItemCount(): Int = results.size
|
||||||
|
|
||||||
class ViewHolder(val viewHolder: Presenter.ViewHolder) : RecyclerView.ViewHolder(viewHolder.view)
|
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
private val thumbnailView: ImageView = view.findViewById(R.id.thumbnail)
|
||||||
|
private val titleView: TextView = view.findViewById(R.id.title)
|
||||||
|
private val categoryView: TextView = view.findViewById(R.id.category)
|
||||||
|
private val rootView: ViewGroup = view.findViewById(R.id.item_root)
|
||||||
|
|
||||||
|
fun bind(content: Content, onClick: (Content) -> Unit) {
|
||||||
|
titleView.text = content.title
|
||||||
|
categoryView.text = content.category
|
||||||
|
|
||||||
|
if (content.thumbnail.isNotBlank()) {
|
||||||
|
Glide.with(itemView.context)
|
||||||
|
.load(content.thumbnail)
|
||||||
|
.placeholder(R.drawable.default_background)
|
||||||
|
.error(R.drawable.default_background)
|
||||||
|
.centerCrop()
|
||||||
|
.into(thumbnailView)
|
||||||
|
} else {
|
||||||
|
thumbnailView.setImageResource(R.drawable.default_background)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootView.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus ->
|
||||||
|
if (hasFocus) {
|
||||||
|
v.animate()
|
||||||
|
.scaleX(1.08f)
|
||||||
|
.scaleY(1.08f)
|
||||||
|
.translationZ(10f)
|
||||||
|
.setDuration(100)
|
||||||
|
.start()
|
||||||
|
v.findViewById<View>(R.id.focus_border)?.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
v.animate()
|
||||||
|
.scaleX(1.0f)
|
||||||
|
.scaleY(1.0f)
|
||||||
|
.translationZ(0f)
|
||||||
|
.setDuration(100)
|
||||||
|
.start()
|
||||||
|
v.findViewById<View>(R.id.focus_border)?.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemView.setOnClickListener { onClick(content) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
tvmon-app/app/src/main/res/drawable/focus_border.xml
Normal file
8
tvmon-app/app/src/main/res/drawable/focus_border.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<stroke
|
||||||
|
android:width="3dp"
|
||||||
|
android:color="#00D4FF" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
@@ -1,38 +1,57 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/item_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="true"
|
||||||
|
android:padding="6dp">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/focus_border"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/focus_border"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:padding="2dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:padding="8dp">
|
android:padding="6dp">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/thumbnail"
|
android:id="@+id/thumbnail"
|
||||||
android:layout_width="120dp"
|
android:layout_width="160dp"
|
||||||
android:layout_height="80dp"
|
android:layout_height="100dp"
|
||||||
android:scaleType="centerCrop"
|
android:scaleType="centerCrop"
|
||||||
android:src="@drawable/default_background" />
|
android:src="@drawable/default_background" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center_vertical"
|
android:textColor="@android:color/white"
|
||||||
android:layout_marginStart="16dp"
|
android:textSize="18sp"
|
||||||
android:layout_weight="1"
|
android:maxLines="2"
|
||||||
android:orientation="vertical">
|
android:ellipsize="end" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/title"
|
android:id="@+id/category"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="@android:color/white"
|
android:layout_marginTop="6dp"
|
||||||
android:textSize="16sp" />
|
android:textColor="@android:color/darker_gray"
|
||||||
|
android:textSize="14sp" />
|
||||||
<TextView
|
|
||||||
android:id="@+id/category"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="4dp"
|
|
||||||
android:textColor="@android:color/darker_gray"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user