From 45ff5b860c5916f2f2466469e251977f1b8429af Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 16 Mar 2026 11:57:12 +0900 Subject: [PATCH] Improve result modal and Envato previews --- TODO.md | 32 ++++++++++++++++++-- backend/handlers/api.go | 1 + backend/services/cse.go | 57 +++++++++++++++++++++++++++++++++++- backend/services/cse_test.go | 11 +++++++ backend/services/ranker.go | 17 +++++++++++ frontend/app.js | 45 ++++++++++++++++++++++++---- frontend/index.html | 20 +++++++++---- 7 files changed, 167 insertions(+), 16 deletions(-) diff --git a/TODO.md b/TODO.md index bba65fd..8130224 100644 --- a/TODO.md +++ b/TODO.md @@ -25,7 +25,7 @@ - Upload / direct download flow is implemented and broadly usable. - Search is implemented end-to-end and now refactored into source-specific collectors. - Search remains the main unstable subsystem. -- Envato metadata and preview extraction are much stronger than before. +- Envato metadata and preview extraction are much stronger than before, including additional hydration-data preview fallback. - Artgrid metadata fidelity is improved, but stable public hover-video preview extraction is still not solved. - Frontend now logs more useful API and debug information than earlier versions. - A local self-test workflow now exists and should be run before container builds or pushes. @@ -182,10 +182,13 @@ - detach source on `mouseleave` - Added in-app result viewer modal for search results: - results now open in a modal instead of directly opening a new tab - - modal shows embedded site iframe, external open button, source summary, and full AI note + - modal now prefers internal preview media over embedded third-party iframes to avoid embed blocking + - external open button remains available - Google Video results can now jump directly into the existing direct-download preview / crop flow from the result viewer - Gemini reason generation is now intended to be Korean-first for readability - Gemini Vision evaluation now covers all ranked results instead of only a top subset +- Search results now prioritize AI note text visually ahead of source summary +- Search query order and final top results now include light randomness so repeated searches are less static ## Current Features Implemented - [x] Project folder structure @@ -212,7 +215,7 @@ ## Important Current Constraints / Known Problems - Search backend quality is still the most fragile subsystem. - Search relevance is still heuristic-heavy and not yet benchmarked against a durable real-query set. -- Embedded result viewer uses an iframe, so some third-party sites may still block embedding with `X-Frame-Options` / CSP. +- Embedded third-party result viewing is no longer relied on because many providers block iframe embedding with `X-Frame-Options` / CSP. - Artgrid hover-video preview is still partial / unresolved: - provided Artgrid HTML snapshots and downloaded asset bundles did not expose a stable public preview mp4/m3u8 URL - public HTML often only exposes title / description / thumbnail / canonical URL @@ -251,6 +254,29 @@ - promise rejections - backend debug broadcasts +## Recent Change Log +- Date: `2026-03-16` +- What changed: + - Envato preview extraction now also inspects `INITIAL_HYDRATION_DATA` when direct page meta / JSON-LD preview URLs are missing. + - Search result cards and result modal now surface AI note before source summary text. + - Google Video direct download action moved into the AI note area and now seeds Zone C input before opening the shared preview/download modal. + - Result modal no longer depends on third-party iframe embedding and instead shows internal preview media plus external-open fallback. + - Search flow now shuffles collector query order and lightly randomizes the top merged results to reduce identical repeated outputs. +- Why it changed: + - Some Envato items still missed preview URLs. + - Third-party iframe embedding was failing for blocked sites and creating a poor modal experience. + - The user wanted AI note to be the primary explanatory text and Google Video download action to be more obvious. + - Repeated searches returning the same ordering made the discovery experience feel too static. +- How it was verified: + - `go test ./...` + - `python3 -m py_compile worker/downloader.py scripts/mock_searxng.py` + - `bash scripts/selftest.sh` + - added unit coverage for Envato hydration preview extraction +- What is still risky or incomplete: + - Envato hydration structure could change again, so this fallback is still heuristic. + - Full browser-level validation of the revised result modal and button placement was not fully reproducible in this environment. + - Search randomness currently changes ordering and query traversal, but does not guarantee materially different source pools if upstream SearXNG returns a narrow candidate set. + ## Current Environment Variables - `APP_ROOT` - `APP_ADDR` diff --git a/backend/handlers/api.go b/backend/handlers/api.go index d9ea5ad..77c949b 100644 --- a/backend/handlers/api.go +++ b/backend/handlers/api.go @@ -333,6 +333,7 @@ func (a *App) searchMedia(c *gin.Context) { } merged := services.MergeRecommendations(recommended, scored, 20) + merged = services.RandomizeTopRecommendations(merged, 8) warning := "" if geminiErr != nil { warning = geminiErr.Error() diff --git a/backend/services/cse.go b/backend/services/cse.go index 888cbd1..18bc764 100644 --- a/backend/services/cse.go +++ b/backend/services/cse.go @@ -1,9 +1,11 @@ package services import ( + "encoding/base64" "encoding/json" "fmt" "io" + "math/rand" "net/http" "net/url" "os/exec" @@ -63,6 +65,7 @@ func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[strin var lastErr error baseQueries := limitQueries(queries, 6) + shuffleStrings(baseQueries) primaryQueries := baseQueries[:minInt(len(baseQueries), 3)] runSearchPass := func(bases []string, onlyMissing bool) { for _, base := range bases { @@ -80,7 +83,9 @@ func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[strin if onlyMissing && sourceCounts[collector.Name()] > 0 { continue } - for _, searchQuery := range collector.BuildQueries(base) { + searchQueries := collector.BuildQueries(base) + shuffleStrings(searchQueries) + for _, searchQuery := range searchQueries { if sourceCounts[collector.Name()] >= collector.MaxResults() { break } @@ -201,6 +206,7 @@ func (s *SearchService) enrichEnvato(result SearchResult) SearchResult { extractJSONLDValue(html, "contentUrl"), extractMetaContent(html, "twitter:player:stream"), extractVideoPreviewURL(html), + extractEnvatoPreviewFromHydration(html), deriveEnvatoPreviewFromThumbnail(pageThumbnail), deriveEnvatoPreviewFromThumbnail(result.ThumbnailURL), ) @@ -801,6 +807,45 @@ func deriveEnvatoPreviewFromThumbnail(thumbnail string) string { return "" } +func extractEnvatoPreviewFromHydration(html string) string { + encoded := extractWindowAssignedValue(html, "INITIAL_HYDRATION_DATA") + if encoded == "" { + return "" + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "" + } + urls := collectURLs(string(decoded)) + return pickBestEnvatoPreviewURL(urls) +} + +func extractWindowAssignedValue(html, variable string) string { + pattern := regexp.MustCompile(`window\.` + regexp.QuoteMeta(variable) + `\s*=\s*"([^"]+)"`) + matches := pattern.FindStringSubmatch(html) + if len(matches) == 2 { + return matches[1] + } + return "" +} + +func pickBestEnvatoPreviewURL(urls []string) string { + for _, item := range urls { + lower := strings.ToLower(item) + if strings.Contains(lower, "video-previews.elements.envatousercontent.com") && strings.Contains(lower, "watermarked_preview") && strings.HasSuffix(lower, ".mp4") { + return item + } + } + for _, item := range urls { + lower := strings.ToLower(item) + if strings.Contains(lower, "envatousercontent.com") && strings.Contains(lower, "watermarked_preview") && strings.HasSuffix(lower, ".mp4") { + return item + } + } + return "" +} + func newBrowserRequest(method, target, accept string) (*http.Request, error) { req, err := http.NewRequest(method, target, nil) if err != nil { @@ -867,6 +912,16 @@ func limitQueries(queries []string, limit int) []string { return filtered } +func shuffleStrings(values []string) { + if len(values) < 2 { + return + } + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + rng.Shuffle(len(values), func(i, j int) { + values[i], values[j] = values[j], values[i] + }) +} + func htmlUnescape(text string) string { replacer := strings.NewReplacer("&", "&", """, `"`, "'", "'", "<", "<", ">", ">") return replacer.Replace(text) diff --git a/backend/services/cse_test.go b/backend/services/cse_test.go index fc06c52..e19f073 100644 --- a/backend/services/cse_test.go +++ b/backend/services/cse_test.go @@ -1,6 +1,7 @@ package services import ( + "encoding/base64" "strings" "testing" ) @@ -23,6 +24,16 @@ func TestDeriveEnvatoPreviewFromThumbnail(t *testing.T) { } } +func TestExtractEnvatoPreviewFromHydration(t *testing.T) { + payload := `{"contentUrl":"https://video-previews.elements.envatousercontent.com/example/watermarked_preview/watermarked_preview.mp4"}` + html := `` + got := extractEnvatoPreviewFromHydration(html) + want := "https://video-previews.elements.envatousercontent.com/example/watermarked_preview/watermarked_preview.mp4" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + func TestIsUsefulGoogleVideoResultRejectsMusicResults(t *testing.T) { result := SearchResult{ Title: "Couple Friendly Sad Bgm Movie Best Bgm", diff --git a/backend/services/ranker.go b/backend/services/ranker.go index d288882..d9b3d97 100644 --- a/backend/services/ranker.go +++ b/backend/services/ranker.go @@ -2,9 +2,11 @@ package services import ( "fmt" + "math/rand" "sort" "strings" "sync" + "time" ) const GeminiFallbackReason = "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다." @@ -193,6 +195,21 @@ func BuildFallbackRecommendations(ranked []SearchResult, limit int, reason strin return fallback } +func RandomizeTopRecommendations(items []AIRecommendation, window int) []AIRecommendation { + if len(items) < 2 || window < 2 { + return items + } + + limit := min(window, len(items)) + shuffled := make([]AIRecommendation, len(items)) + copy(shuffled, items) + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + rng.Shuffle(limit, func(i, j int) { + shuffled[i], shuffled[j] = shuffled[j], shuffled[i] + }) + return shuffled +} + func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult, limit int) []AIRecommendation { merged := make([]AIRecommendation, 0, min(limit, len(ranked))) seen := map[string]bool{} diff --git a/frontend/app.js b/frontend/app.js index 4ada852..ec8d0a9 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -42,10 +42,13 @@ const resultModalTitle = document.getElementById("resultModalTitle"); const resultModalSource = document.getElementById("resultModalSource"); const resultModalSnippet = document.getElementById("resultModalSnippet"); const resultModalReason = document.getElementById("resultModalReason"); -const resultModalFrame = document.getElementById("resultModalFrame"); const resultModalOpenExternal = document.getElementById("resultModalOpenExternal"); const resultModalDownload = document.getElementById("resultModalDownload"); const closeResultModal = document.getElementById("closeResultModal"); +const resultModalMediaFrame = document.getElementById("resultModalMediaFrame"); +const resultModalVideo = document.getElementById("resultModalVideo"); +const resultModalThumbnail = document.getElementById("resultModalThumbnail"); +const resultModalEmbedNotice = document.getElementById("resultModalEmbedNotice"); let pendingDownload = null; let cropStart = 0; @@ -345,6 +348,15 @@ function hideModal(element) { setHidden(element, true); } +function resetResultModalMedia() { + resultModalVideo.pause(); + detachVideoSource(resultModalVideo); + resultModalMediaFrame.style.aspectRatio = ""; + setHidden(resultModalVideo, true, ""); + setHidden(resultModalThumbnail, true, ""); + setHidden(resultModalEmbedNotice, false, ""); +} + function renderResults(results) { searchResults.innerHTML = ""; if (!results.length) { @@ -359,8 +371,8 @@ function renderResults(results) { image.src = item.thumbnailUrl || PREVIEW_PLACEHOLDER; image.alt = item.title; node.querySelector("h3").textContent = item.title; - node.querySelector(".result-snippet").textContent = item.snippet || item.reason || item.source || ""; - node.querySelector(".result-reason").textContent = item.reason ? `AI 노트: ${item.reason}` : ""; + node.querySelector(".result-snippet").textContent = item.reason || item.snippet || item.source || ""; + node.querySelector(".result-reason").textContent = item.snippet ? `Source: ${item.snippet}` : ""; node.querySelector(".source-badge").textContent = item.source; node.addEventListener("click", () => openResultModal(item)); previewVideo.poster = item.thumbnailUrl || ""; @@ -385,6 +397,7 @@ function renderResults(results) { } async function prepareDirectDownload(targetUrl) { + downloadUrl.value = targetUrl; downloadResult.textContent = "checking duplicate history..."; const dup = await api(`/api/history/check?url=${encodeURIComponent(targetUrl)}`); let force = false; @@ -410,12 +423,22 @@ function openResultModal(item) { activeResultItem = item; resultModalTitle.textContent = item.title || "Untitled"; resultModalSource.textContent = item.source || ""; - resultModalSnippet.textContent = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다."; resultModalReason.textContent = item.reason || "AI 노트가 없습니다."; - resultModalFrame.src = item.link || "about:blank"; + resultModalSnippet.textContent = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다."; resultModalOpenExternal.href = item.link || "#"; const canDirectDownload = item.source === "Google Video" && item.link; resultModalDownload.classList.toggle("hidden", !canDirectDownload); + resetResultModalMedia(); + if (item.previewVideoUrl) { + attachVideoSource(resultModalVideo, item.previewVideoUrl); + setHidden(resultModalVideo, false, ""); + setHidden(resultModalEmbedNotice, true, ""); + } else if (item.thumbnailUrl) { + resultModalThumbnail.src = item.thumbnailUrl; + resultModalThumbnail.alt = item.title || ""; + setHidden(resultModalThumbnail, false, ""); + setHidden(resultModalEmbedNotice, true, ""); + } showModal(resultModal); logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link }); } @@ -425,7 +448,7 @@ function closeResultViewer() { logEvent("result:modal:close", { title: activeResultItem?.title || "" }); } activeResultItem = null; - resultModalFrame.src = "about:blank"; + resetResultModalMedia(); hideModal(resultModal); } @@ -640,6 +663,16 @@ previewThumbnail.addEventListener("load", () => { previewMediaFrame.style.aspectRatio = `${previewThumbnail.naturalWidth} / ${previewThumbnail.naturalHeight}`; } }); +resultModalVideo.addEventListener("loadedmetadata", () => { + if (resultModalVideo.videoWidth > 0 && resultModalVideo.videoHeight > 0) { + resultModalMediaFrame.style.aspectRatio = `${resultModalVideo.videoWidth} / ${resultModalVideo.videoHeight}`; + } +}); +resultModalThumbnail.addEventListener("load", () => { + if (!resultModalVideo.src && resultModalThumbnail.naturalWidth > 0 && resultModalThumbnail.naturalHeight > 0) { + resultModalMediaFrame.style.aspectRatio = `${resultModalThumbnail.naturalWidth} / ${resultModalThumbnail.naturalHeight}`; + } +}); for (const button of platformToggles) { button.addEventListener("click", () => { const platform = button.dataset.platformToggle; diff --git a/frontend/index.html b/frontend/index.html index 2f5a833..527f7c0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -158,23 +158,31 @@
Open -
- +
+ + +
+ 외부 사이트 임베딩 차단 문제를 피하기 위해 내부 미리보기만 표시합니다. +
+
-
-

Source Summary

-

-

AI Note

+ +
+
+

Source Summary

+