Improve embed handling and Gemini verdict flow
build-push / docker (push) Successful in 4m15s

This commit is contained in:
AI Assistant
2026-03-16 14:09:33 +09:00
parent a37e02aea9
commit 7d9a939633
5 changed files with 64 additions and 51 deletions
+15
View File
@@ -341,6 +341,21 @@
- [ ] full browser-level validation was not fully reproducible in this environment
## Recent Change Log
- Date: `2026-03-16`
- What changed:
- Google Video embed URL now uses `youtube-nocookie` with explicit `origin` to reduce player load failures.
- Gemini Vision prompt now forces a `Yes` or `No` verdict per candidate and only `Yes` candidates are merged into the final result set.
- Candidate visual fetch now prefers Envato / Artgrid preview video frames before thumbnails and sends a `Referer` header for thumbnail fetches.
- Envato / Artgrid enrichment now retries preview extraction once after a short delay when the first fetch still lacks a usable preview URL.
- Why it changed:
- The user reported YouTube player error `153`, too many fallback-style results, and source pages that appear to need additional loading time before preview URLs become visible.
- How it was verified:
- log review from `ai-media-hub-2026-03-16T05-05-58-704Z.log`
- `go test ./...`
- What is still risky or incomplete:
- If a YouTube video has embedding disabled by the uploader, no embed URL variant will fully bypass that restriction.
- Delay-and-retry HTML fetching cannot execute JavaScript, so pages that only expose preview URLs after full client-side rendering may still need a real browser-based fetch path later.
- Date: `2026-03-16`
- What changed:
- Reduced the heaviest search-stage caps slightly: fewer query variants per request, smaller per-source result caps, lower enrichment scope, and a bounded Gemini candidate set.
+23
View File
@@ -211,6 +211,19 @@ func (s *SearchService) enrichEnvato(result SearchResult) SearchResult {
deriveEnvatoPreviewFromThumbnail(result.ThumbnailURL),
)
}
if result.PreviewVideoURL == "" {
time.Sleep(1200 * time.Millisecond)
if retryHTML, retryErr := s.fetchText(result.Link); retryErr == nil {
result.PreviewVideoURL = firstNonEmpty(
extractJSONLDValue(retryHTML, "contentUrl"),
extractMetaContent(retryHTML, "twitter:player:stream"),
extractVideoPreviewURL(retryHTML),
extractEnvatoPreviewFromHydration(retryHTML),
deriveEnvatoPreviewFromThumbnail(pageThumbnail),
deriveEnvatoPreviewFromThumbnail(result.ThumbnailURL),
)
}
}
return result
}
@@ -264,6 +277,16 @@ func (s *SearchService) enrichArtgrid(result SearchResult) SearchResult {
extractVideoPreviewURL(html),
)
}
if result.PreviewVideoURL == "" {
time.Sleep(1200 * time.Millisecond)
if retryHTML, retryErr := s.fetchText(result.Link); retryErr == nil {
result.PreviewVideoURL = firstNonEmpty(
extractJSONLDValue(retryHTML, "contentUrl"),
extractMetaContent(retryHTML, "twitter:player:stream"),
extractVideoPreviewURL(retryHTML),
)
}
}
}
}
+25 -25
View File
@@ -153,9 +153,10 @@ func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AI
parts := []geminiPart{
{
"text": `You are a professional video editor. Analyze whether each provided visual is suitable as a usable scene or shot for the user's requested keyword. Return JSON only in this shape:
{"recommendations":[{"index":0,"reason":"short reason","recommended":true}]}
{"recommendations":[{"index":0,"verdict":"Yes","reason":"short reason","recommended":true}]}
Return one entry for every analyzed candidate. Use Korean for every reason. Keep reasons concise but specific enough to explain usefulness.
Mark the strongest matches as recommended=true and weaker matches as recommended=false.
Set verdict to "Yes" or "No" for every candidate. "Yes" means the scene is usable and relevant for editing against the user's keyword. "No" means it is not suitable or not relevant enough.
Set recommended=true only when verdict is "Yes". Set recommended=false when verdict is "No".
Prefer cinematic b-roll, stock footage, editorial footage, clean composition, usable establishing shots, and professional media thumbnails.
Avoid clickbait faces, exaggerated expressions, meme aesthetics, low-information thumbnails, sensational text overlays, or gossip-style imagery.
Favor scenes that look directly useful for professional editing, sequencing, establishing, cutaway, or mood-building usage.
@@ -226,6 +227,7 @@ User query: ` + query,
var parsed struct {
Recommendations []struct {
Index int `json:"index"`
Verdict string `json:"verdict"`
Reason string `json:"reason"`
Recommended bool `json:"recommended"`
} `json:"recommendations"`
@@ -240,6 +242,7 @@ User query: ` + query,
continue
}
src := candidates[rec.Index]
recommended := rec.Recommended || strings.EqualFold(strings.TrimSpace(rec.Verdict), "yes")
recommendations = append(recommendations, AIRecommendation{
Title: src.Title,
Link: src.Link,
@@ -248,7 +251,7 @@ User query: ` + query,
PreviewVideoURL: src.PreviewVideoURL,
Source: src.Source,
Reason: normalizeKoreanReason(rec.Reason),
Recommended: rec.Recommended,
Recommended: recommended,
})
}
@@ -259,31 +262,19 @@ User query: ` + query,
return recommendations, nil
}
func fetchImageAsInlineData(client *http.Client, imageURL string) (string, string, error) {
func fetchImageAsInlineData(client *http.Client, imageURL, referer string) (string, string, error) {
if strings.TrimSpace(imageURL) == "" {
return "", "", fmt.Errorf("image url is empty")
}
resp, err := client.Get(imageURL)
if err == nil {
defer resp.Body.Close()
req, reqErr := newBrowserStyleImageRequest(imageURL, referer)
if reqErr != nil {
return "", "", reqErr
}
if err != nil || resp.StatusCode >= 300 {
req, reqErr := newBrowserStyleImageRequest(imageURL)
if reqErr != nil {
if err != nil {
return "", "", err
}
return "", "", reqErr
}
if resp != nil {
resp.Body.Close()
}
resp, err = client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return "", "", fmt.Errorf("thumbnail fetch failed with %d", resp.StatusCode)
@@ -302,7 +293,7 @@ func fetchImageAsInlineData(client *http.Client, imageURL string) (string, strin
return base64.StdEncoding.EncodeToString(data), mimeType, nil
}
func newBrowserStyleImageRequest(imageURL string) (*http.Request, error) {
func newBrowserStyleImageRequest(imageURL, referer string) (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, imageURL, nil)
if err != nil {
return nil, err
@@ -310,12 +301,21 @@ func newBrowserStyleImageRequest(imageURL string) (*http.Request, error) {
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36")
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
if strings.TrimSpace(referer) != "" {
req.Header.Set("Referer", referer)
}
return req, nil
}
func fetchCandidateVisualInlineData(client *http.Client, candidate SearchResult) (string, string, error) {
if candidate.PreviewVideoURL != "" && (candidate.Source == "Envato" || candidate.Source == "Artgrid") {
data, mimeType, err := extractFrameFromVideo(candidate.PreviewVideoURL)
if err == nil {
return data, mimeType, nil
}
}
if candidate.ThumbnailURL != "" {
data, mimeType, err := fetchImageAsInlineData(client, candidate.ThumbnailURL)
data, mimeType, err := fetchImageAsInlineData(client, candidate.ThumbnailURL, candidate.Link)
if err == nil {
return data, mimeType, nil
}
-25
View File
@@ -302,31 +302,6 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult,
seen[item.Link] = true
merged = append(merged, item)
}
for _, item := range recommended {
if item.Recommended || item.Link == "" || seen[item.Link] || len(merged) >= limit {
continue
}
seen[item.Link] = true
merged = append(merged, item)
}
for _, item := range ranked {
if len(merged) >= limit || item.Link == "" || seen[item.Link] {
continue
}
seen[item.Link] = true
merged = append(merged, AIRecommendation{
Title: item.Title,
Link: item.Link,
Snippet: item.Snippet,
ThumbnailURL: item.ThumbnailURL,
PreviewVideoURL: item.PreviewVideoURL,
Source: item.Source,
Reason: GeminiFallbackReason,
Recommended: false,
})
}
return merged
}
+1 -1
View File
@@ -386,7 +386,7 @@ function buildResultModalEmbedURL(item) {
if (item.source === "Google Video") {
const videoId = extractYouTubeID(item.link);
if (videoId) {
return `https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0`;
return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0&playsinline=1&origin=${encodeURIComponent(window.location.origin)}`;
}
}
return item.link;