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 seenEpisodeIds = mutableSetOf<String>()
|
||||
|
||||
val category = seriesUrl.substringAfter("$BASE_URL/").substringBefore("/")
|
||||
val seriesId = seriesUrl.substringAfterLast("/").substringBefore("?")
|
||||
|
||||
// Parse episodes from #epListScroll first (ep-item class)
|
||||
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}/']")
|
||||
}
|
||||
val allEpisodeItems = doc.select("#other_list li.searchText[data-ep-idx]")
|
||||
|
||||
var episodeIndex = 0
|
||||
|
||||
for (link in allEpisodeLinks) {
|
||||
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
|
||||
for (item in allEpisodeItems) {
|
||||
val episodeId = item.attr("data-ep-idx")
|
||||
if (episodeId.isBlank() || episodeId in seenEpisodeIds) continue
|
||||
seenEpisodeIds.add(episodeId)
|
||||
|
||||
val linkText = link.text().trim()
|
||||
if (linkText.isBlank()) continue
|
||||
val fullUrl = "$BASE_URL/$category/$seriesId/$episodeId"
|
||||
|
||||
if (linkText.contains("로그인") || linkText.contains("비밀번호") ||
|
||||
linkText.contains("마이페이지") || linkText.contains("전체 목록")) {
|
||||
continue
|
||||
}
|
||||
val classText = item.className().replace("searchText", "").trim()
|
||||
val episodeNumMatch = Pattern.compile("(\\d+)").matcher(classText)
|
||||
val episodeNum = if (episodeNumMatch.find()) episodeNumMatch.group(1) ?: "" else ""
|
||||
|
||||
val cleanLinkText = linkText
|
||||
.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()
|
||||
}
|
||||
if (episodeNum.isBlank()) continue
|
||||
|
||||
episodes.add(Episode(
|
||||
number = dateStr.ifBlank { finalNumber },
|
||||
title = episodeTitleStr.ifBlank { finalNumber },
|
||||
number = episodeNum,
|
||||
title = classText.ifBlank { "${episodeNum}화" },
|
||||
url = fullUrl,
|
||||
type = "webview",
|
||||
date = dateStr
|
||||
date = ""
|
||||
))
|
||||
|
||||
videoLinks.add(VideoLink(
|
||||
type = "play_page",
|
||||
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 ->
|
||||
val numberStr = episode.number
|
||||
val pattern = Pattern.compile("\\d+")
|
||||
|
||||
@@ -6,7 +6,7 @@ import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.example.tvmon.R
|
||||
import com.example.tvmon.data.model.Content
|
||||
@@ -20,7 +20,6 @@ class SearchActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TVMON_SEARCH"
|
||||
private const val NUM_COLUMNS = 4
|
||||
}
|
||||
|
||||
private val scraper: TvmonScraper by inject()
|
||||
@@ -48,9 +47,9 @@ class SearchActivity : AppCompatActivity() {
|
||||
openDetail(content)
|
||||
}
|
||||
|
||||
recyclerView.layoutManager = GridLayoutManager(this, NUM_COLUMNS)
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
recyclerView.adapter = adapter
|
||||
recyclerView.setHasFixedSize(true)
|
||||
recyclerView.setHasFixedSize(false)
|
||||
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package com.example.tvmon.ui.search
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.leanback.widget.Presenter
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
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.ui.presenter.SearchCardPresenter
|
||||
|
||||
class SearchResultsAdapter(
|
||||
private val onItemClick: (Content) -> Unit
|
||||
) : RecyclerView.Adapter<SearchResultsAdapter.ViewHolder>() {
|
||||
|
||||
private val presenter = SearchCardPresenter()
|
||||
private var results = mutableListOf<Content>()
|
||||
|
||||
fun updateResults(newResults: List<Content>) {
|
||||
@@ -20,18 +23,60 @@ class SearchResultsAdapter(
|
||||
}
|
||||
|
||||
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) {
|
||||
val content = results[position]
|
||||
presenter.onBindViewHolder(holder.viewHolder, content)
|
||||
holder.viewHolder.view.setOnClickListener { onItemClick(content) }
|
||||
holder.bind(content, onItemClick)
|
||||
}
|
||||
|
||||
|
||||
|
||||
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"?>
|
||||
<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_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
|
||||
android:padding="6dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thumbnail"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="80dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/default_background" />
|
||||
|
||||
android:id="@+id/thumbnail"
|
||||
android:layout_width="160dp"
|
||||
android:layout_height="100dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/default_background" />
|
||||
|
||||
<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_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:textColor="@android:color/white"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<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" />
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="18sp"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/category"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
Reference in New Issue
Block a user