From 3d6437be35670109d5fd7e3095c0dfaa34d2ab45 Mon Sep 17 00:00:00 2001 From: tvmon-dev Date: Thu, 16 Apr 2026 11:09:14 +0900 Subject: [PATCH] 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 --- .../tvmon/data/scraper/TvmonScraper.kt | 139 ++---------------- .../example/tvmon/ui/search/SearchActivity.kt | 7 +- .../tvmon/ui/search/SearchResultsAdapter.kt | 63 ++++++-- .../src/main/res/drawable/focus_border.xml | 8 + .../main/res/layout/item_search_result.xml | 79 ++++++---- 5 files changed, 128 insertions(+), 168 deletions(-) create mode 100644 tvmon-app/app/src/main/res/drawable/focus_border.xml diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt index bcfb2cd..68605a7 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt @@ -413,150 +413,39 @@ val episodes = mutableListOf() val videoLinks = mutableListOf() val seenEpisodeIds = mutableSetOf() + 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+") diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchActivity.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchActivity.kt index a559532..cb918f5 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchActivity.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchActivity.kt @@ -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 { diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchResultsAdapter.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchResultsAdapter.kt index 5c10722..2c42f3e 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchResultsAdapter.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchResultsAdapter.kt @@ -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() { - private val presenter = SearchCardPresenter() private var results = mutableListOf() fun updateResults(newResults: List) { @@ -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(R.id.focus_border)?.visibility = View.VISIBLE + } else { + v.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .translationZ(0f) + .setDuration(100) + .start() + v.findViewById(R.id.focus_border)?.visibility = View.GONE + } + } + + itemView.setOnClickListener { onClick(content) } + } + } } diff --git a/tvmon-app/app/src/main/res/drawable/focus_border.xml b/tvmon-app/app/src/main/res/drawable/focus_border.xml new file mode 100644 index 0000000..67802e0 --- /dev/null +++ b/tvmon-app/app/src/main/res/drawable/focus_border.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/tvmon-app/app/src/main/res/layout/item_search_result.xml b/tvmon-app/app/src/main/res/layout/item_search_result.xml index 0b5bfbb..561f99a 100644 --- a/tvmon-app/app/src/main/res/layout/item_search_result.xml +++ b/tvmon-app/app/src/main/res/layout/item_search_result.xml @@ -1,38 +1,57 @@ - + + + + - + android:padding="6dp"> + - + android:id="@+id/thumbnail" + android:layout_width="160dp" + android:layout_height="100dp" + android:scaleType="centerCrop" + android:src="@drawable/default_background" /> + + + - - - - + android:textColor="@android:color/white" + android:textSize="18sp" + android:maxLines="2" + android:ellipsize="end" /> + + - + +