Skip to content

Commit edf0e60

Browse files
committed
fix(link-preview): save MissAV OG metadata fallback
1 parent 612d4f0 commit edf0e60

6 files changed

Lines changed: 245 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ before the CalVer migration retain their original version labels.
1616

1717
### Fixed
1818

19+
- Save searchable MissAV `missav.ai` Open Graph metadata by falling back to readable mirror metadata when the main page blocks preview fetches.
1920
- Render successful resource creation info logs in green in runtime logger output.
2021
- Save a webpage `og:image` preview when a URL resolves to a non-media resource instead of storing the resolved HTML or document file.
2122
- Send and index a generated preview image when a downloaded URL video is too large for Telegram video upload, while still saving the downloaded video locally.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# MissAV AI Open Graph Metadata Fallback
2+
3+
## What happened
4+
5+
A `missav.ai` URL could show a rich Telegram client preview, but `save_it` could not reliably save the page's Open Graph title, description, keywords, or image URL into Typesense. URL-only saves therefore had no user caption and no searchable URL metadata.
6+
7+
## Root cause
8+
9+
Telegram client previews do not mean the Bot API exposes the resolved Open Graph fields to the bot. `save_it` still has to fetch the preview page itself. Direct server-side requests to the affected `missav.ai` page returned a Cloudflare 403 page, so `SmallSdk.LinkPreview.get_metadata/1` returned an error and the bot indexed the saved media without URL metadata.
10+
11+
The MissAV provider strategy recognized `missav.ai`, but it did not have a metadata fallback URL when the primary host blocked preview fetches.
12+
13+
## Fix applied
14+
15+
`SmallSdk.LinkPreview` now tries the normal preview URL first. When a `missav.ai` metadata fetch fails, it retries the same path on a configurable MissAV metadata fallback host, defaulting to `https://missav.ws`. Metadata parsed from the fallback page still flows through the existing `title`, `description`, `keywords`, and `thumbnail_url` fields, while the saved document keeps the original `missav.ai` URL.
16+
17+
Regression coverage now verifies both the `LinkPreview` fallback behavior and the full bot save path that writes MissAV Open Graph metadata into the Typesense document.
18+
19+
## What we learned
20+
21+
Telegram's visible link preview is not a reliable data source for bot indexing. Provider-specific metadata recovery should live at the link-preview boundary so the rest of the save pipeline can keep the same field semantics and continue preserving the user's original URL.

docs/specs/001-typesense-saved-content.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ Supported provider strategies:
157157
| Instagram | `instagram.com` and subdomains | Use the default URL metadata strategy. Provider-specific handling may be added when public metadata is blocked or incomplete. |
158158
| YouTube Shorts | `youtube.com/shorts/*`, `youtu.be/*` when resolved as Shorts | Use the default URL metadata strategy. The Shorts provider may normalize canonical YouTube URLs before metadata fetches. |
159159
| `bad.news` | `bad.news` and subdomains | Use `bad.news` download resolution for media and the default URL metadata strategy for page metadata. |
160-
| `missav.ai` | `missav.ai` and subdomains | Use the default URL metadata strategy. Provider-specific media and metadata handling may be added without changing document field semantics. |
160+
| `missav.ai` | `missav.ai` and subdomains | Use the default URL metadata strategy first. If the `missav.ai` preview HTML fetch fails, fetch the same path from the configured MissAV metadata fallback host, defaulting to `https://missav.ws`, and store any readable Open Graph metadata while preserving the original `missav.ai` URL as `url`. |
161161
| Other URL | Any supported URL not matched above | Use the default URL metadata strategy. |
162162

163163
### Default URL Metadata Strategy

lib/small_sdk/link_preview.ex

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ defmodule SmallSdk.LinkPreview do
66
alias SmallSdk.WebDownloader
77
alias SmallSdk.XMetadata
88

9+
@missav_metadata_fallback_base_url "https://missav.ws"
10+
911
@preview_patterns [
1012
~r/<meta[^>]+property=["']og:image["'][^>]+(?:content|value)=["']([^"']+)["']/i,
1113
~r/<meta[^>]+(?:content|value)=["']([^"']+)["'][^>]+property=["']og:image["']/i,
@@ -72,18 +74,20 @@ defmodule SmallSdk.LinkPreview do
7274

7375
def get_title(_page_url), do: {:error, :missing_preview_url}
7476

75-
def get_metadata(page_url) when is_binary(page_url) do
77+
def get_metadata(page_url, opts \\ [])
78+
79+
def get_metadata(page_url, opts) when is_binary(page_url) do
7680
case get_authenticated_metadata(page_url) do
7781
{:ok, metadata} ->
7882
log_metadata(page_url, metadata)
7983
{:ok, metadata}
8084

8185
{:error, _reason} ->
82-
get_html_metadata(page_url)
86+
get_html_metadata(page_url, opts)
8387
end
8488
end
8589

86-
def get_metadata(_page_url), do: {:error, :missing_preview_url}
90+
def get_metadata(_page_url, _opts), do: {:error, :missing_preview_url}
8791

8892
defp get_authenticated_metadata(page_url) do
8993
if XMetadata.x_url?(page_url) do
@@ -93,8 +97,15 @@ defmodule SmallSdk.LinkPreview do
9397
end
9498
end
9599

96-
defp get_html_metadata(page_url) do
97-
case Req.get(page_url, headers: [{"User-Agent", "Mozilla/5.0"}]) do
100+
defp get_html_metadata(page_url, opts) do
101+
case fetch_html_metadata(page_url, opts) do
102+
{:ok, metadata} -> {:ok, metadata}
103+
{:error, reason} -> maybe_get_provider_fallback_metadata(page_url, reason, opts)
104+
end
105+
end
106+
107+
defp fetch_html_metadata(page_url, opts) do
108+
case Req.get(page_url, request_options(opts)) do
98109
{:ok, %{status: status, body: body}} when status in 200..209 and is_binary(body) ->
99110
metadata = get_metadata_from_html(page_url, body)
100111
log_metadata(page_url, metadata)
@@ -111,6 +122,71 @@ defmodule SmallSdk.LinkPreview do
111122
end
112123
end
113124

125+
defp maybe_get_provider_fallback_metadata(page_url, reason, opts) do
126+
if Keyword.get(opts, :provider_fallback?, true) do
127+
case missav_metadata_fallback_url(page_url, opts) do
128+
fallback_url when is_binary(fallback_url) ->
129+
log_metadata_fallback(page_url, fallback_url, reason)
130+
fetch_html_metadata(fallback_url, Keyword.put(opts, :provider_fallback?, false))
131+
132+
nil ->
133+
{:error, reason}
134+
end
135+
else
136+
{:error, reason}
137+
end
138+
end
139+
140+
defp request_options(opts) do
141+
opts
142+
|> Keyword.get(:req_options, Application.get_env(:save_it, :link_preview_req_options, []))
143+
|> Keyword.put_new(:retry, false)
144+
|> put_request_headers([{"User-Agent", "Mozilla/5.0"}])
145+
end
146+
147+
defp put_request_headers(req_options, headers) do
148+
Keyword.update(req_options, :headers, headers, fn existing_headers ->
149+
headers ++ existing_headers
150+
end)
151+
end
152+
153+
defp missav_metadata_fallback_url(page_url, opts) do
154+
with %URI{host: host} = page_uri when is_binary(host) <- URI.parse(page_url),
155+
true <- missav_ai_host?(String.downcase(host)),
156+
fallback_base_url when is_binary(fallback_base_url) <-
157+
missav_metadata_fallback_base_url(opts),
158+
%URI{host: fallback_host} = fallback_uri when is_binary(fallback_host) <-
159+
URI.parse(fallback_base_url) do
160+
%URI{
161+
page_uri
162+
| scheme: fallback_uri.scheme || "https",
163+
userinfo: fallback_uri.userinfo,
164+
host: fallback_host,
165+
port: fallback_uri.port
166+
}
167+
|> URI.to_string()
168+
else
169+
_ -> nil
170+
end
171+
rescue
172+
_ -> nil
173+
end
174+
175+
defp missav_metadata_fallback_base_url(opts) do
176+
opts
177+
|> Keyword.get(
178+
:missav_metadata_fallback_base_url,
179+
Application.get_env(
180+
:save_it,
181+
:missav_metadata_fallback_base_url,
182+
@missav_metadata_fallback_base_url
183+
)
184+
)
185+
|> blank_to_nil()
186+
end
187+
188+
defp missav_ai_host?(host), do: host == "missav.ai" or String.ends_with?(host, ".missav.ai")
189+
114190
def get_metadata_from_html(page_url, html) when is_binary(page_url) and is_binary(html) do
115191
%{
116192
title: html |> extract_title() |> blank_to_nil(),
@@ -226,6 +302,16 @@ defmodule SmallSdk.LinkPreview do
226302
)
227303
end
228304

305+
defp log_metadata_fallback(page_url, fallback_url, reason) do
306+
Logger.warning(
307+
"Link preview metadata fallback selected: " <>
308+
"page_url=#{format_log_url(page_url)} " <>
309+
"fallback_url=#{format_log_url(fallback_url)} " <>
310+
"reason=#{format_log_reason(reason)}",
311+
kind: :link_preview
312+
)
313+
end
314+
229315
defp format_log_url(nil), do: "nil"
230316

231317
defp format_log_url(url) when is_binary(url) do

test/bot_test.exs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,48 @@ defmodule SaveIt.BotTest do
335335
end)
336336
end
337337

338+
test "stores missav.ai mirror OG metadata when direct preview fetch is blocked",
339+
%{base_url: base_url} do
340+
original_url = "https://missav.ai/ja/sdam-101-uncensored-leak"
341+
download_url = base_url <> "/downloaded/photo.jpg"
342+
343+
Application.put_env(:save_it, :test_pid, self())
344+
345+
Application.put_env(:save_it, :link_preview_req_options,
346+
adapter: &__MODULE__.MissavLinkPreviewAdapter.request/1
347+
)
348+
349+
Application.put_env(:save_it, :missav_metadata_fallback_base_url, "https://missav.ws")
350+
351+
message = %{
352+
chat: %{id: 12_345, username: "save_it_test_chat"},
353+
date: 1_717_170_000,
354+
message_id: 103,
355+
text: original_url
356+
}
357+
358+
assert {:ok, true} = Bot.handle({:text, original_url, message}, nil)
359+
360+
assert_receive {:test_http_request, :post, "/", cobalt_body}
361+
assert Jason.decode!(cobalt_body) == %{"url" => original_url}
362+
assert_receive {:test_http_request, :get, "/downloaded/photo.jpg", ""}
363+
assert_receive {:link_preview_request, "https://missav.ai/ja/sdam-101-uncensored-leak"}
364+
assert_receive {:link_preview_request, "https://missav.ws/ja/sdam-101-uncensored-leak"}
365+
assert_receive {:test_http_request, :post, "/collections/photos/documents", typesense_body}
366+
367+
document = Jason.decode!(typesense_body)
368+
369+
assert document["url"] == original_url
370+
assert document["download_url"] == download_url
371+
assert document["thumbnail_url"] == "https://fourhoi.com/sdam-101-uncensored-leak/cover-n.jpg"
372+
assert document["caption"] == ""
373+
assert document["title"] == "MissAV Mirror OG Title"
374+
assert document["description"] == "MissAV Mirror OG Description"
375+
assert document["keywords"] == ["missav", "metadata", "fallback"]
376+
assert document["file_id"] == "telegram-photo-file-id"
377+
assert document["belongs_to_id"] == "12345"
378+
end
379+
338380
test "sends downloaded URL media to the source topic and stores a topic message URL",
339381
%{base_url: base_url} do
340382
Application.put_env(:ex_gram, :adapter, __MODULE__.TopicSendPhotoAdapter)
@@ -1882,6 +1924,41 @@ defmodule SaveIt.BotTest do
18821924
end
18831925
end
18841926

1927+
defmodule MissavLinkPreviewAdapter do
1928+
def request(%Req.Request{url: url} = request) do
1929+
send(
1930+
Application.fetch_env!(:save_it, :test_pid),
1931+
{:link_preview_request, URI.to_string(url)}
1932+
)
1933+
1934+
response =
1935+
case {url.host, url.path} do
1936+
{"missav.ai", "/ja/sdam-101-uncensored-leak"} ->
1937+
%Req.Response{status: 403, body: "Just a moment..."}
1938+
1939+
{"missav.ws", "/ja/sdam-101-uncensored-leak"} ->
1940+
%Req.Response{
1941+
status: 200,
1942+
body: """
1943+
<html>
1944+
<head>
1945+
<meta property="og:title" content="MissAV Mirror OG Title" />
1946+
<meta property="og:description" content="MissAV Mirror OG Description" />
1947+
<meta name="keywords" content="missav, metadata, fallback" />
1948+
<meta property="og:image" content="https://fourhoi.com/sdam-101-uncensored-leak/cover-n.jpg" />
1949+
</head>
1950+
</html>
1951+
"""
1952+
}
1953+
1954+
unexpected ->
1955+
raise "Unexpected MissAV preview request: #{inspect(unexpected)}"
1956+
end
1957+
1958+
{request, response}
1959+
end
1960+
end
1961+
18851962
defmodule TopicSendPhotoAdapter do
18861963
@behaviour ExGram.Adapter
18871964

@@ -2587,6 +2664,10 @@ defmodule SaveIt.BotTest do
25872664
json_response(%{"url" => "http://127.0.0.1:#{port}/downloaded/video.mp4"})
25882665
end
25892666

2667+
defp cobalt_response(%{"url" => "https://missav.ai/ja/sdam-101-uncensored-leak"}, port) do
2668+
json_response(%{"url" => "http://127.0.0.1:#{port}/downloaded/photo.jpg"})
2669+
end
2670+
25902671
defp cobalt_response(%{"url" => "https://example.com/unavailable"}, _port) do
25912672
error_response(%{"error" => "unsupported url"})
25922673
end

test/small_sdk/link_preview_test.exs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,24 @@ defmodule SmallSdk.LinkPreviewTest do
132132
refute log =~ "token=secret"
133133
end
134134

135+
test "uses missav mirror metadata when missav.ai blocks preview HTML" do
136+
Application.put_env(:save_it, :test_pid, self())
137+
138+
assert {:ok, metadata} =
139+
LinkPreview.get_metadata("https://missav.ai/ja/sdam-101-uncensored-leak",
140+
req_options: [adapter: &__MODULE__.MissavMetadataAdapter.request/1],
141+
missav_metadata_fallback_base_url: "https://missav.ws"
142+
)
143+
144+
assert metadata.title == "MissAV Mirror OG Title"
145+
assert metadata.description == "MissAV Mirror OG Description"
146+
assert metadata.keywords == ["missav", "metadata", "fallback"]
147+
assert metadata.image_url == "https://fourhoi.com/sdam-101-uncensored-leak/cover-n.jpg"
148+
149+
assert_receive {:link_preview_request, "https://missav.ai/ja/sdam-101-uncensored-leak"}
150+
assert_receive {:link_preview_request, "https://missav.ws/ja/sdam-101-uncensored-leak"}
151+
end
152+
135153
test "uses authenticated X metadata when cobalt cookies are available", %{tmp_dir: tmp_dir} do
136154
cookie_path = Path.join(tmp_dir, "cobalt-cookies.json")
137155

@@ -172,6 +190,38 @@ defmodule SmallSdk.LinkPreviewTest do
172190
end)
173191
end
174192

193+
defmodule MissavMetadataAdapter do
194+
def request(%Req.Request{url: url} = request) do
195+
send(
196+
Application.fetch_env!(:save_it, :test_pid),
197+
{:link_preview_request, URI.to_string(url)}
198+
)
199+
200+
response =
201+
case {url.host, url.path} do
202+
{"missav.ai", "/ja/sdam-101-uncensored-leak"} ->
203+
%Req.Response{status: 403, body: "Just a moment..."}
204+
205+
{"missav.ws", "/ja/sdam-101-uncensored-leak"} ->
206+
%Req.Response{
207+
status: 200,
208+
body: """
209+
<html>
210+
<head>
211+
<meta property="og:title" content="MissAV Mirror OG Title" />
212+
<meta property="og:description" content="MissAV Mirror OG Description" />
213+
<meta name="keywords" content="missav, metadata, fallback" />
214+
<meta property="og:image" content="https://fourhoi.com/sdam-101-uncensored-leak/cover-n.jpg" />
215+
</head>
216+
</html>
217+
"""
218+
}
219+
end
220+
221+
{request, response}
222+
end
223+
end
224+
175225
defmodule TestHttpServer do
176226
use GenServer
177227

0 commit comments

Comments
 (0)