Der Publikumswert steht auf der Seite, also wird er von dort gelesen
OMDb führt im Feld `Ratings` nur eine Zahl je Dienst, und bei Rotten
Tomatoes ist das die der Kritik. Die erweiterten Felder gibt es, sie
stehen aber seit Jahren auf `N/A`; Jellyfin fragt sie an und liest sie
nicht einmal aus — deren Antwortklasse kennt `tomatoUserMeter` gar nicht.
Dort hat den Publikumswert also niemand.
Auf der Seite selbst steht er. Gemessen an `/m/the_matrix`: im Quelltext
liegt `"audienceScore":{… "score":"85" …}` neben `"criticsScore"`, dazu
ein `application/ld+json` mit Titel und Erscheinungsdatum; bei Serien
unter `/tv/…` dasselbe, und ein unbekannter Weg antwortet mit 404. Eine
Prüfung mit `[Explicit]` geht einmal wirklich hinaus und bestätigt es:
`rottenTomatoes 83%, rottenTomatoesAudience 85%`.
Das ist Auslesen einer fremden Webseite, und es hat seinen Preis. Eine
offene Suche gibt es nicht — `/napi/search` antwortet mit 401 —, also
wird der Weg aus dem Titel geraten und hinterher am Jahr geprüft: auf der
Seite steht keine IMDb-Kennung, und `/m/the_lion_king` ist der von 1994.
Weicht das Jahr um mehr als eines ab, wird verworfen; lieber keine Zahl
als die eines fremden Films. Der Originaltitel kommt zuerst dran, sonst
fände ein deutscher Bestand dort fast nichts. Ohne Jahr zuerst, weil der
Normalfall den blanken Namen trägt — mit Jahr zuerst kostete jeder Titel
eine Anfrage zu viel.
Deshalb ein Schalter, und der steht aus, bis jemand ihn umlegt. Dazu eine
Bremse: höchstens eine Anfrage je Sekunde, prozessweit. Achthundert Titel
in einem Schwall von einer Adresse wären eine Zumutung und würden
gesperrt. Der Scan dauert dadurch länger; das ist der Preis fürs
Mitbenutzen.
Die Probe unter Einstellungen → Metadaten fragt die Quelle mit, und dort
ist sie mehr als eine Höflichkeit: wird die Seite umgebaut, merkt man es
genau da und sonst nirgends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e05bbfc2cd
commit
8397e042b8
@@ -0,0 +1,332 @@
|
||||
using Flaro.Core.Metadata;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Flaro.Metadata.Tests;
|
||||
|
||||
/// <summary>Die beiden Werte von Rotten Tomatoes, von der Seite selbst gelesen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Gegen einen festgehaltenen Seitenausschnitt, nicht gegen das Netz.</b>
|
||||
/// Eine Prüfung, die wirklich hinausgeht, misst das Netz und nicht den Code;
|
||||
/// sie wäre launisch und fiele aus, sobald jemand ohne Verbindung arbeitet.
|
||||
/// Der Ausschnitt unten ist gekürzt, aber in der Form, die
|
||||
/// <c>/m/the_matrix</c> am 9. Oktober 2026 geliefert hat.
|
||||
/// </remarks>
|
||||
public class RottenTomatoesTests
|
||||
{
|
||||
private static string Seite(
|
||||
string art = "Movie", string name = "The Matrix", string datum = "1999-03-31",
|
||||
string kritik = "83", string publikum = "85") =>
|
||||
$$$"""
|
||||
<html><head>
|
||||
<script type="application/ld+json">
|
||||
{"@context":"http://schema.org","@type":"{{{art}}}","name":"{{{name}}}",
|
||||
"dateCreated":"{{{datum}}}","url":"https://www.rottentomatoes.com/m/matrix"}
|
||||
</script>
|
||||
</head><body>
|
||||
<script>window.__data = {"audienceScore":{"averageRating":"3.6",
|
||||
"likedCount":142778,"score":"{{{publikum}}}","scoreType":"ALL",
|
||||
"sentiment":"POSITIVE","scorePercent":"{{{publikum}}}%","title":"Popcornmeter"},
|
||||
"criticsScore":{"averageRating":"7.90","certified":true,
|
||||
"score":"{{{kritik}}}","sentiment":"POSITIVE","scorePercent":"{{{kritik}}}%"}};
|
||||
</script>
|
||||
</body></html>
|
||||
""";
|
||||
|
||||
private static RottenTomatoesClient Client(Attrappe attrappe, bool an = true) =>
|
||||
new(new HttpClient(attrappe) { BaseAddress = new Uri(RottenTomatoesClient.BaseAddress) },
|
||||
new FesterTomatenschalter(an),
|
||||
// Ohne Pause: die Bremse ist eine Höflichkeit gegenüber der
|
||||
// echten Seite, und eine Prüfung geht nirgends hin.
|
||||
new Anklopfbremse(TimeSpan.Zero),
|
||||
NullLogger<RottenTomatoesClient>.Instance);
|
||||
|
||||
private static Wertungsfrage Matrix => new("tt0133093", "The Matrix", 1999);
|
||||
|
||||
/// <summary>Ausgeschaltet heisst: keine einzige Anfrage.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Das ist der wichtigste Satz dieser Datei.</b> Hier wird eine fremde
|
||||
/// Webseite ausgelesen; das darf nicht beiläufig geschehen, weil jemand
|
||||
/// einen Scan startet.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ausgeschaltet_geht_keine_Anfrage_hinaus()
|
||||
{
|
||||
var attrappe = Attrappe.MitJson(Seite());
|
||||
|
||||
var wertungen = await Client(attrappe, an: false).HolenAsync(Matrix);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(wertungen, Is.Empty);
|
||||
Assert.That(attrappe.Aufrufe, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Kritik und Publikum kommen getrennt zurück.</summary>
|
||||
[Test]
|
||||
public async Task Beide_Werte_kommen_getrennt()
|
||||
{
|
||||
var wertungen = await Client(Attrappe.MitJson(Seite())).HolenAsync(Matrix);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
var kritik = wertungen.Single(w => w.Quelle == "rottenTomatoes");
|
||||
Assert.That(kritik.Anzeige, Is.EqualTo("83%"));
|
||||
Assert.That(kritik.Wert, Is.EqualTo(8.3).Within(0.01));
|
||||
|
||||
var publikum = wertungen.Single(w => w.Quelle == "rottenTomatoesAudience");
|
||||
Assert.That(publikum.Anzeige, Is.EqualTo("85%"));
|
||||
Assert.That(publikum.Wert, Is.EqualTo(8.5).Within(0.01));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Der gewöhnliche Titel kostet genau eine Anfrage.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Gemessen an der echten Seite:</b> <c>/m/the_matrix</c> antwortet,
|
||||
/// <c>/m/the_matrix_1999</c> mit 404. Mit Jahr zuerst zu fragen kostete
|
||||
/// bei jedem gewöhnlichen Titel eine Anfrage zu viel — auf einer Seite,
|
||||
/// die man ohnehin nur mitbenutzt.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ein_gewoehnlicher_Titel_kostet_eine_Anfrage()
|
||||
{
|
||||
var attrappe = Attrappe.MitJson(Seite());
|
||||
|
||||
await Client(attrappe).HolenAsync(Matrix);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(1));
|
||||
Assert.That(attrappe.Aufrufe[0], Does.EndWith("m/the_matrix"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Ein Film mit anderem Jahr ist ein anderer Film.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Auf der Seite steht keine IMDb-Kennung.</b> Der Weg wird aus dem
|
||||
/// Titel geraten, und ein geratener Weg kann überall hinführen. Lieber
|
||||
/// keine Zahl als die eines fremden Films.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ein_fremdes_Jahr_wird_verworfen()
|
||||
{
|
||||
var attrappe = Attrappe.MitJson(Seite(name: "The Lion King", datum: "1994-06-24"));
|
||||
|
||||
var wertungen = await Client(attrappe)
|
||||
.HolenAsync(new Wertungsfrage(null, "The Lion King", 2019));
|
||||
|
||||
Assert.That(wertungen, Is.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Beim falschen Jahr wird der Weg mit Jahr nachgereicht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Neuverfilmung ist der einzige Fall, der zwei Anfragen
|
||||
/// rechtfertigt.</b> <c>/m/the_lion_king</c> ist die von 1994,
|
||||
/// <c>/m/the_lion_king_2019</c> die neue. Erst wird die alte am Jahr
|
||||
/// erkannt und verworfen, dann greift die zweite.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Eine_Neuverfilmung_wird_ueber_den_Weg_mit_Jahr_gefunden()
|
||||
{
|
||||
var attrappe = Attrappe.NachPfad(
|
||||
("the_lion_king_2019", Seite(name: "The Lion King (2019)", datum: "2019-07-19",
|
||||
kritik: "52", publikum: "88")),
|
||||
("the_lion_king", Seite(name: "The Lion King", datum: "1994-06-24",
|
||||
kritik: "93", publikum: "93")));
|
||||
|
||||
var wertungen = await Client(attrappe)
|
||||
.HolenAsync(new Wertungsfrage(null, "The Lion King", 2019));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(2));
|
||||
Assert.That(wertungen.Single(w => w.Quelle == "rottenTomatoes").Anzeige,
|
||||
Is.EqualTo("52%"), "die Fassung von 2019, nicht die von 1994");
|
||||
Assert.That(wertungen.Single(w => w.Quelle == "rottenTomatoesAudience").Anzeige,
|
||||
Is.EqualTo("88%"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Ein Jahr daneben ist kein anderer Film.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Rotten Tomatoes führt das Datum der amerikanischen Erstaufführung,
|
||||
/// TMDB oft das der Heimatpremiere — ein Film vom Dezember steht dort
|
||||
/// schon mal im Folgejahr.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ein_Jahr_Unterschied_zaehlt_noch_als_derselbe()
|
||||
{
|
||||
var wertungen = await Client(Attrappe.MitJson(Seite(datum: "2000-01-20")))
|
||||
.HolenAsync(Matrix);
|
||||
|
||||
Assert.That(wertungen, Has.Count.EqualTo(2));
|
||||
}
|
||||
|
||||
/// <summary>Eine Serie darf nicht auf einer Filmseite landen.</summary>
|
||||
[Test]
|
||||
public async Task Eine_Filmseite_beantwortet_keine_Seriensuche()
|
||||
{
|
||||
var wertungen = await Client(Attrappe.MitJson(Seite(art: "Movie", name: "Dark")))
|
||||
.HolenAsync(new Wertungsfrage(null, "Dark", 1999, Serie: true));
|
||||
|
||||
Assert.That(wertungen, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_Serie_wird_unter_tv_gesucht()
|
||||
{
|
||||
var attrappe = Attrappe.MitJson(
|
||||
Seite(art: "TVSeries", name: "Dark", datum: "2017-12-01"));
|
||||
|
||||
var wertungen = await Client(attrappe)
|
||||
.HolenAsync(new Wertungsfrage(null, "Dark", 2017, Serie: true));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(attrappe.Aufrufe[0], Does.EndWith("tv/dark"),
|
||||
"Serien stehen dort ohne Jahr: /tv/dark_2017 antwortet mit 404.");
|
||||
Assert.That(wertungen, Has.Count.EqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Ein unbekannter Weg endet mit 404 — und das ist eine Antwort.</summary>
|
||||
[Test]
|
||||
public async Task Ein_unbekannter_Weg_ergibt_nichts()
|
||||
{
|
||||
var wertungen = await Client(Attrappe.MitStatus(System.Net.HttpStatusCode.NotFound))
|
||||
.HolenAsync(Matrix);
|
||||
|
||||
Assert.That(wertungen, Is.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Eine Sperrseite ohne die bekannten Felder ergibt nichts, keinen Absturz.</summary>
|
||||
[Test]
|
||||
public async Task Eine_umgebaute_Seite_ergibt_nichts()
|
||||
{
|
||||
var wertungen = await Client(Attrappe.MitJson("<html><body>Nichts hier.</body></html>"))
|
||||
.HolenAsync(Matrix);
|
||||
|
||||
Assert.That(wertungen, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Kein_Netz_ergibt_nichts_statt_eines_Absturzes()
|
||||
{
|
||||
var wertungen = await Client(Attrappe.DieWirft(new HttpRequestException("weg")))
|
||||
.HolenAsync(Matrix);
|
||||
|
||||
Assert.That(wertungen, Is.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Der Originaltitel wird zuerst versucht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Sonst fände ein deutscher Bestand dort so gut wie nichts.</b> Steht
|
||||
/// die Mediathek auf Deutsch, heisst der Film bei uns „Ich einfach
|
||||
/// unverbesserlich"; Rotten Tomatoes kennt nur <c>despicable_me</c>.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Der_Originaltitel_wird_zuerst_versucht()
|
||||
{
|
||||
var attrappe = Attrappe.NachPfad(
|
||||
("despicable_me", Seite(name: "Despicable Me", datum: "2010-07-09",
|
||||
kritik: "81", publikum: "84")));
|
||||
|
||||
var wertungen = await Client(attrappe).HolenAsync(new Wertungsfrage(
|
||||
null, "Ich einfach unverbesserlich", 2010, Originaltitel: "Despicable Me"));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(attrappe.Aufrufe[0], Does.EndWith("m/despicable_me"));
|
||||
Assert.That(wertungen.Single(w => w.Quelle == "rottenTomatoesAudience").Anzeige,
|
||||
Is.EqualTo("84%"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Gibt es den Originaltitel dort nicht, kommt der deutsche dran.</summary>
|
||||
[Test]
|
||||
public async Task Ohne_Treffer_wird_auch_der_andere_Titel_versucht()
|
||||
{
|
||||
var attrappe = Attrappe.NachPfad(
|
||||
("ich_einfach_unverbesserlich",
|
||||
Seite(name: "Ich einfach unverbesserlich", datum: "2010-07-09")));
|
||||
|
||||
var wertungen = await Client(attrappe).HolenAsync(new Wertungsfrage(
|
||||
null, "Ich einfach unverbesserlich", 2010, Originaltitel: "Despicable Me"));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(attrappe.Aufrufe[0], Does.Contain("despicable_me"), "der originale zuerst");
|
||||
Assert.That(wertungen, Has.Count.EqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Derselbe Titel zweimal ergibt nicht zwei Anfragen.</summary>
|
||||
[Test]
|
||||
public async Task Ein_gleicher_Originaltitel_kostet_nichts_extra()
|
||||
{
|
||||
var attrappe = Attrappe.MitJson(Seite());
|
||||
|
||||
await Client(attrappe).HolenAsync(
|
||||
new Wertungsfrage(null, "The Matrix", 1999, Originaltitel: "The Matrix"));
|
||||
|
||||
Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
/// <summary>Einmal wirklich hinaus — nur von Hand.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><c>[Explicit]</c>, und das ist der Punkt.</b> Diese Prüfung misst
|
||||
/// das Netz und eine fremde Seite, nicht den Code; im Durchlauf wäre sie
|
||||
/// launisch und fiele ohne Verbindung aus. Von Hand ist sie dagegen das
|
||||
/// einzige, was die Frage beantwortet, ob das Auslesen heute noch geht:
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>dotnet test --filter "FullyQualifiedName~Wirklich_von_der_echten_Seite"</c>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Test, Explicit("Geht wirklich ins Netz.")]
|
||||
public async Task Wirklich_von_der_echten_Seite_gelesen()
|
||||
{
|
||||
using var echt = new HttpClient { BaseAddress = new Uri(RottenTomatoesClient.BaseAddress) };
|
||||
echt.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
+ "Chrome/131.0.0.0 Safari/537.36");
|
||||
|
||||
var client = new RottenTomatoesClient(
|
||||
echt, new FesterTomatenschalter(true), new Anklopfbremse(TimeSpan.Zero),
|
||||
NullLogger<RottenTomatoesClient>.Instance);
|
||||
|
||||
var wertungen = await client.HolenAsync(Matrix);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(wertungen.Select(w => w.Quelle),
|
||||
Is.EquivalentTo(new[] { "rottenTomatoes", "rottenTomatoesAudience" }));
|
||||
TestContext.Out.WriteLine(string.Join(", ",
|
||||
wertungen.Select(w => $"{w.Quelle} {w.Anzeige}")));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>So baut Rotten Tomatoes seine Wege.</summary>
|
||||
[TestCase("The Matrix", ExpectedResult = "the_matrix")]
|
||||
[TestCase("Spider-Man: No Way Home", ExpectedResult = "spider_man_no_way_home")]
|
||||
[TestCase("WALL·E", ExpectedResult = "wall_e")]
|
||||
[TestCase("Ich einfach unverbesserlich", ExpectedResult = "ich_einfach_unverbesserlich")]
|
||||
[TestCase("Österreich – Grüße", ExpectedResult = "osterreich_grusse")]
|
||||
[TestCase(" ", ExpectedResult = "")]
|
||||
public string Aus_dem_Titel_wird_ein_Weg(string titel) =>
|
||||
RottenTomatoesClient.Kennstueck(titel);
|
||||
}
|
||||
|
||||
public sealed record FesterTomatenschalter(bool An) : IRottenTomatoesSettingsSource
|
||||
{
|
||||
public Task<bool> EingeschaltetAsync(CancellationToken ct = default) => Task.FromResult(An);
|
||||
}
|
||||
Reference in New Issue
Block a user