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:
tvmon-dev
2026-04-16 11:09:14 +09:00
parent 0c8bc6252b
commit 3d6437be35
5 changed files with 128 additions and 168 deletions

View File

@@ -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+")

View File

@@ -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 {

View File

@@ -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) }
}
}
} }

View 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>

View File

@@ -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>