Skip to content

Commit 477e0a6

Browse files
committed
refactor(link-preview): simplify missav metadata fallback host
1 parent edf0e60 commit 477e0a6

8 files changed

Lines changed: 189 additions & 121 deletions

File tree

docs/postmortem/2026-06-16-missav-ai-og-metadata-fallback.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ The MissAV provider strategy recognized `missav.ai`, but it did not have a metad
1212

1313
## Fix applied
1414

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.
15+
`SmallSdk.LinkPreview` now tries the normal preview URL first, then delegates provider-specific fallback handling to isolated provider modules. `SmallSdk.MissavMetadata` owns the `missav.ai` fallback behavior: when a MissAV metadata fetch fails, it retries the same path on `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.
1616

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.
17+
Regression coverage now verifies the isolated MissAV metadata provider and the full bot save path that writes MissAV Open Graph metadata into the Typesense document.
1818

1919
## What we learned
2020

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 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`. |
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 `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: 6 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ defmodule SmallSdk.LinkPreview do
44
require Logger
55

66
alias SmallSdk.WebDownloader
7+
alias SmallSdk.LinkPreview.ProviderFallback
78
alias SmallSdk.XMetadata
89

9-
@missav_metadata_fallback_base_url "https://missav.ws"
10-
1110
@preview_patterns [
1211
~r/<meta[^>]+property=["']og:image["'][^>]+(?:content|value)=["']([^"']+)["']/i,
1312
~r/<meta[^>]+(?:content|value)=["']([^"']+)["'][^>]+property=["']og:image["']/i,
@@ -99,8 +98,11 @@ defmodule SmallSdk.LinkPreview do
9998

10099
defp get_html_metadata(page_url, opts) do
101100
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)
101+
{:ok, metadata} ->
102+
{:ok, metadata}
103+
104+
{:error, reason} ->
105+
ProviderFallback.fetch_metadata(page_url, reason, opts, &fetch_html_metadata/2)
104106
end
105107
end
106108

@@ -122,21 +124,6 @@ defmodule SmallSdk.LinkPreview do
122124
end
123125
end
124126

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-
140127
defp request_options(opts) do
141128
opts
142129
|> Keyword.get(:req_options, Application.get_env(:save_it, :link_preview_req_options, []))
@@ -150,43 +137,6 @@ defmodule SmallSdk.LinkPreview do
150137
end)
151138
end
152139

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-
190140
def get_metadata_from_html(page_url, html) when is_binary(page_url) and is_binary(html) do
191141
%{
192142
title: html |> extract_title() |> blank_to_nil(),
@@ -302,16 +252,6 @@ defmodule SmallSdk.LinkPreview do
302252
)
303253
end
304254

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-
315255
defp format_log_url(nil), do: "nil"
316256

317257
defp format_log_url(url) when is_binary(url) do
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
defmodule SmallSdk.LinkPreview.ProviderFallback do
2+
@moduledoc false
3+
4+
@providers [SmallSdk.MissavMetadata]
5+
6+
def fetch_metadata(page_url, reason, opts, fetch_metadata) do
7+
case Enum.find(@providers, & &1.supports?(page_url)) do
8+
nil ->
9+
{:error, reason}
10+
11+
provider ->
12+
provider.fetch_fallback_metadata(page_url, reason, opts, fetch_metadata)
13+
end
14+
end
15+
end

lib/small_sdk/missav_metadata.ex

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
defmodule SmallSdk.MissavMetadata do
2+
@moduledoc false
3+
4+
@fallback_base_url "https://missav.ws"
5+
6+
require Logger
7+
8+
def supports?(url) when is_binary(url) do
9+
case URI.parse(url) do
10+
%URI{host: host} when is_binary(host) ->
11+
missav_ai_host?(String.downcase(host))
12+
13+
_ ->
14+
false
15+
end
16+
rescue
17+
_ -> false
18+
end
19+
20+
def supports?(_url), do: false
21+
22+
def fetch_fallback_metadata(page_url, reason, opts, fetch_metadata)
23+
when is_binary(page_url) and is_function(fetch_metadata, 2) do
24+
case fallback_url(page_url) do
25+
fallback_url when is_binary(fallback_url) ->
26+
log_metadata_fallback(page_url, fallback_url, reason)
27+
fetch_metadata.(fallback_url, opts)
28+
29+
nil ->
30+
{:error, reason}
31+
end
32+
end
33+
34+
def fetch_fallback_metadata(_page_url, reason, _opts, _fetch_metadata), do: {:error, reason}
35+
36+
defp fallback_url(page_url) do
37+
with true <- supports?(page_url),
38+
%URI{host: fallback_host} = fallback_uri when is_binary(fallback_host) <-
39+
URI.parse(@fallback_base_url),
40+
%URI{} = page_uri <- URI.parse(page_url) do
41+
%URI{
42+
page_uri
43+
| scheme: fallback_uri.scheme || "https",
44+
userinfo: fallback_uri.userinfo,
45+
host: fallback_host,
46+
port: fallback_uri.port
47+
}
48+
|> URI.to_string()
49+
else
50+
_ -> nil
51+
end
52+
rescue
53+
_ -> nil
54+
end
55+
56+
defp missav_ai_host?(host), do: host == "missav.ai" or String.ends_with?(host, ".missav.ai")
57+
58+
defp log_metadata_fallback(page_url, fallback_url, reason) do
59+
Logger.warning(
60+
"Link preview metadata fallback selected: " <>
61+
"page_url=#{format_log_url(page_url)} " <>
62+
"fallback_url=#{format_log_url(fallback_url)} " <>
63+
"reason=#{format_log_reason(reason)}",
64+
kind: :link_preview
65+
)
66+
end
67+
68+
defp format_log_url(nil), do: "nil"
69+
70+
defp format_log_url(url) when is_binary(url) do
71+
url
72+
|> remove_query_and_fragment()
73+
|> format_log_value()
74+
end
75+
76+
defp format_log_value(nil), do: "nil"
77+
78+
defp format_log_value(value) when is_binary(value) do
79+
value
80+
|> truncate_log_value()
81+
|> inspect()
82+
end
83+
84+
defp format_log_reason(reason) do
85+
reason
86+
|> inspect()
87+
|> truncate_log_value()
88+
end
89+
90+
defp truncate_log_value(value) do
91+
if String.length(value) > 160 do
92+
String.slice(value, 0, 160) <> "..."
93+
else
94+
value
95+
end
96+
end
97+
98+
defp remove_query_and_fragment(url) do
99+
uri = URI.parse(url)
100+
101+
%URI{uri | query: nil, fragment: nil}
102+
|> URI.to_string()
103+
rescue
104+
_ -> url
105+
end
106+
end

test/bot_test.exs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,8 +346,6 @@ defmodule SaveIt.BotTest do
346346
adapter: &__MODULE__.MissavLinkPreviewAdapter.request/1
347347
)
348348

349-
Application.put_env(:save_it, :missav_metadata_fallback_base_url, "https://missav.ws")
350-
351349
message = %{
352350
chat: %{id: 12_345, username: "save_it_test_chat"},
353351
date: 1_717_170_000,

test/small_sdk/link_preview_test.exs

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -132,24 +132,6 @@ 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-
153135
test "uses authenticated X metadata when cobalt cookies are available", %{tmp_dir: tmp_dir} do
154136
cookie_path = Path.join(tmp_dir, "cobalt-cookies.json")
155137

@@ -190,38 +172,6 @@ defmodule SmallSdk.LinkPreviewTest do
190172
end)
191173
end
192174

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-
225175
defmodule TestHttpServer do
226176
use GenServer
227177

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
defmodule SmallSdk.MissavMetadataTest do
2+
use ExUnit.Case, async: false
3+
4+
import ExUnit.CaptureLog
5+
6+
alias SmallSdk.MissavMetadata
7+
8+
test "fetches mirror metadata for missav.ai URLs" do
9+
reason = {:preview_page_status, 403}
10+
11+
fetch_metadata = fn fallback_url, opts ->
12+
send(self(), {:fallback_fetch, fallback_url, opts})
13+
14+
{:ok,
15+
%{
16+
title: "MissAV Mirror OG Title",
17+
description: "MissAV Mirror OG Description",
18+
keywords: ["missav", "metadata", "fallback"],
19+
image_url: "https://fourhoi.com/sdam-101-uncensored-leak/cover-n.jpg"
20+
}}
21+
end
22+
23+
log =
24+
capture_log(fn ->
25+
assert {:ok, metadata} =
26+
MissavMetadata.fetch_fallback_metadata(
27+
"https://missav.ai/ja/sdam-101-uncensored-leak?token=secret",
28+
reason,
29+
[],
30+
fetch_metadata
31+
)
32+
33+
assert metadata.title == "MissAV Mirror OG Title"
34+
end)
35+
36+
assert_receive {:fallback_fetch, "https://missav.ws/ja/sdam-101-uncensored-leak?token=secret",
37+
[]}
38+
39+
assert log =~ "Link preview metadata fallback selected"
40+
assert log =~ ~s(page_url="https://missav.ai/ja/sdam-101-uncensored-leak")
41+
assert log =~ ~s(fallback_url="https://missav.ws/ja/sdam-101-uncensored-leak")
42+
assert log =~ "reason={:preview_page_status, 403}"
43+
refute log =~ "token=secret"
44+
end
45+
46+
test "does not handle non-missav URLs" do
47+
fetch_metadata = fn _url, _opts ->
48+
flunk("non-missav URL should not be fetched")
49+
end
50+
51+
assert {:error, :blocked} =
52+
MissavMetadata.fetch_fallback_metadata(
53+
"https://example.com/ja/sdam-101-uncensored-leak",
54+
:blocked,
55+
[],
56+
fetch_metadata
57+
)
58+
end
59+
end

0 commit comments

Comments
 (0)