fix(sxyprn): blokada uploaderow ze spamem obsady/tagow + dopasowanie nazwisk bez przedrostka w obie strony
- person_named_in: jednowyrazowe nazwisko tylko co do znaku (albo zlepek sasiednich czlonow tytulu), przedrostek co najwyzej o 2 znaki; "Thebdck" lapal kazde "the" - sxyprn: autor z sciezki miniatury, _BLOCKED_AUTHORS (Shizuka, Ajx, Denzel), karty z >20 hashtagami wchodza bez tagow - scripts/sxyprn_purge_uploaders.py: dry-run/apply, sceny tych autorow + smieciowi performerzy
This commit is contained in:
parent
9c988bc12a
commit
01298c9699
4 changed files with 316 additions and 3 deletions
|
|
@ -19,6 +19,14 @@ Metadane z karty (bez +1 fetch detalu):
|
|||
- thumbnail: `data-src='//...small.jpg'`
|
||||
|
||||
Playback resolwuje extractor `sxyprncom` (osobno; mp4 z trafficdeposit/lulustream).
|
||||
|
||||
Blokada uploaderow (2026-09-04). Kilku autorow wrzuca dobre sceny z tytulem w stylu
|
||||
„Tytul ft. Obsada, Obsada by Studio Kategoria, Kategoria, ..." i 25-35 hashtagami
|
||||
na kazdym poscie. Kazde nazwisko z tytulu to potencjalne przypisanie performera
|
||||
(over-attribution), a hashtagi wchodzily jako tagi sceny (mediana 27 zamiast 12).
|
||||
Karta nie ma pola autora, ale id autora siedzi w sciezce miniatury:
|
||||
`.../<token>/<ts>/<id_autora>/<id_posta>/small.jpg`. Autorow z `_BLOCKED_AUTHORS`
|
||||
pomijamy w calosci, a karty z wiecej niz `_MAX_HASHTAGS` hashtagami wchodza bez tagow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -60,6 +68,17 @@ _POSTTIME_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
_HASHTAG_RE = re.compile(r"#(\w[\w-]*)")
|
||||
# `.../<id_autora>/<id_posta>/small.jpg`, oba 13 hex. Drugi segment MUSI byc id posta,
|
||||
# inaczej to inny format miniatury i autora nie znamy.
|
||||
_AUTHOR_IN_THUMB_RE = re.compile(r"/([a-f0-9]{13})/([a-f0-9]{13})/[^/]+$", re.IGNORECASE)
|
||||
# id autora sxyprn -> nazwa (tylko do logow). Patrz docstring modulu.
|
||||
_BLOCKED_AUTHORS: dict[str, str] = {
|
||||
"69cab3467ec41": "Shizuka",
|
||||
"68e92925d23d9": "Ajx",
|
||||
"6a0d125a875d2": "Denzel",
|
||||
}
|
||||
# Powyzej tylu hashtagow to spam SEO, nie opis sceny. Mediana katalogu sxyprn to 12.
|
||||
_MAX_HASHTAGS = 20
|
||||
_BRACE_RE = re.compile(r"\{[^}]*\}")
|
||||
_URL_TAIL_RE = re.compile(r"https?://\S+")
|
||||
|
||||
|
|
@ -76,6 +95,16 @@ def _parse_duration(s: str) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
def _author_id(thumb: str | None, post_id: str) -> str | None:
|
||||
"""Id autora ze sciezki miniatury albo None, gdy format inny niz oczekiwany."""
|
||||
if not thumb:
|
||||
return None
|
||||
m = _AUTHOR_IN_THUMB_RE.search(thumb)
|
||||
if not m or m.group(2).lower() != post_id.lower():
|
||||
return None
|
||||
return m.group(1).lower()
|
||||
|
||||
|
||||
def _clean_title(raw: str) -> tuple[str, list[str]]:
|
||||
"""Zwraca (czysty tytuł, tagi). Usuwa {markery}, hashtagi i końcowy URL."""
|
||||
text = html.unescape(raw)
|
||||
|
|
@ -146,6 +175,14 @@ class SxyPrnScraper(BaseSearchScraper):
|
|||
if thumb and thumb.startswith("//"):
|
||||
thumb = "https:" + thumb
|
||||
|
||||
author = _author_id(thumb, post_id)
|
||||
if author in _BLOCKED_AUTHORS:
|
||||
log.debug("sxyprn: pomijam post %s autora %s", post_id, _BLOCKED_AUTHORS[author])
|
||||
continue
|
||||
if len(tags) > _MAX_HASHTAGS:
|
||||
log.debug("sxyprn: post %s ma %d hashtagow, wchodzi bez tagow", post_id, len(tags))
|
||||
tags = []
|
||||
|
||||
studio = None
|
||||
sub_m = _SUBCAT_RE.search(window)
|
||||
if sub_m:
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@ def slugify(value: str | None) -> str:
|
|||
# Poniżej wymagamy zgodności co do znaku, bo inicjał („s", „b") pasuje do połowy słów
|
||||
# w dowolnym tytule i to jest dokładnie źródło over-atrybucji opisanej niżej.
|
||||
_PREFIX_FUZZ_MIN = 3
|
||||
# O ile znakow czlon z tytulu moze byc dluzszy (albo krotszy) od czlonu nazwiska, zeby
|
||||
# przedrostek jeszcze uchodzil za wariant zapisu. „Savanna" wobec „Savannah" to 1,
|
||||
# „Jyn" wobec „Jynx" to 1. Bez tego limitu przedrostek dzialal w obie strony bez
|
||||
# ograniczen i jednowyrazowy „Thebdck" lapal kazdy tytul ze slowem „the", a
|
||||
# „Sashamalone" kazdy z „Sasha": 36 performerow na jednej scenie sxyprn (2026-09-04).
|
||||
_PREFIX_FUZZ_MAX_DIFF = 2
|
||||
# Od tylu znakow jednowyrazowe nazwisko moze byc zlepkiem dwoch czlonow tytulu.
|
||||
_GLUED_NAME_MIN = 5
|
||||
|
||||
# Granice, przez które nazwisko nie może się przelewać. Tytuły tubów to listy osób
|
||||
# rozdzielone przecinkiem albo myślnikiem, a na końcu doklejone tagi. Bez tego cięcia
|
||||
|
|
@ -73,10 +81,14 @@ _PREFIX_FUZZ_MIN = 3
|
|||
_PERSON_SEPARATOR_RE = re.compile(r"\s[-–—]\s|[,|&/;:()\[\]]|feat\.?|ft\.?", re.IGNORECASE)
|
||||
|
||||
|
||||
def _person_token_match(name_tok: str, text_tok: str) -> bool:
|
||||
def _person_token_match(name_tok: str, text_tok: str, *, exact: bool = False) -> bool:
|
||||
if name_tok == text_tok:
|
||||
return True
|
||||
if exact:
|
||||
return False
|
||||
if len(name_tok) >= _PREFIX_FUZZ_MIN and len(text_tok) >= _PREFIX_FUZZ_MIN:
|
||||
if abs(len(name_tok) - len(text_tok)) > _PREFIX_FUZZ_MAX_DIFF:
|
||||
return False
|
||||
return name_tok.startswith(text_tok) or text_tok.startswith(name_tok)
|
||||
return False
|
||||
|
||||
|
|
@ -95,6 +107,11 @@ def person_named_in(haystack: str | None, person: str | None) -> bool:
|
|||
zgodność przedrostka, żeby przeżyły warianty zapisu („Savanna Bond" przy tytule
|
||||
„Savannah Bond", „Jyn Maze" przy „Jynx Maze"). Inicjały muszą zgadzać się dokładnie.
|
||||
|
||||
Nazwisko jednowyrazowe („Mandingo", „Thebdck") musi zgadzac sie co do znaku: nie ma
|
||||
drugiego czlonu, ktory by je potwierdzil, a przedrostek robil z niego wytrych.
|
||||
Wyjatek: zlepek w stylu sxyprn („JoannaAngel", „Bonnieb") pasuje, gdy jest rowny
|
||||
sklejce dwoch lub trzech sasiednich czlonow tytulu („Joanna Angel", „Bonnie B").
|
||||
|
||||
Świadomy koszt: odwrócona kolejność („Goldy Kim" wobec „Kim Gold") nie przejdzie.
|
||||
Przy wyborze między pominięciem sceny a przypisaniem jej niewłaściwej osobie wolimy
|
||||
pominąć, bo błędne przypisanie widać na ekranie performera i to ono jest zgłaszane.
|
||||
|
|
@ -103,12 +120,22 @@ def person_named_in(haystack: str | None, person: str | None) -> bool:
|
|||
span = len(name_toks)
|
||||
if not span:
|
||||
return False
|
||||
exact = span == 1
|
||||
for segment in _PERSON_SEPARATOR_RE.split(haystack or ""):
|
||||
text_toks = normalize_person(segment).split()
|
||||
if len(text_toks) < span:
|
||||
continue
|
||||
if exact and len(name_toks[0]) >= _GLUED_NAME_MIN and any(
|
||||
"".join(text_toks[i : i + k]) == name_toks[0]
|
||||
for k in (2, 3)
|
||||
for i in range(len(text_toks) - k + 1)
|
||||
):
|
||||
return True
|
||||
if any(
|
||||
all(_person_token_match(n, t) for n, t in zip(name_toks, text_toks[i : i + span]))
|
||||
all(
|
||||
_person_token_match(n, t, exact=exact)
|
||||
for n, t in zip(name_toks, text_toks[i : i + span])
|
||||
)
|
||||
for i in range(len(text_toks) - span + 1)
|
||||
):
|
||||
return True
|
||||
|
|
|
|||
216
scripts/sxyprn_purge_uploaders.py
Normal file
216
scripts/sxyprn_purge_uploaders.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"""Usuwa z bazy sceny zablokowanych uploaderow sxyprn i sciaga smieciowych performerow.
|
||||
|
||||
Kontekst (2026-09-04): trzej autorzy (Shizuka, Ajx, Denzel) wrzucaja dobre sceny, ale z
|
||||
obsada wpisana w tytul i 25-35 hashtagami. Tytul z lista nazwisk to over-attribution
|
||||
performerow, hashtagi to 27 tagow na scene zamiast 12. Scraper ma juz ich na blokadzie
|
||||
(`_BLOCKED_AUTHORS` w `app/connectors/direct_scrapers/sxyprn.py`), ten skrypt sprzata
|
||||
to, co juz weszlo.
|
||||
|
||||
Autora poznajemy po sciezce miniatury: `.../<id_autora>/<id_posta>/small.jpg`.
|
||||
|
||||
Co robi:
|
||||
1. Sceny, ktore maja TYLKO zrodla tych autorow (zadnego innego zywego zrodla, zadnego
|
||||
refu tpdb/stashdb): DELETE ze scenes (kaskada zdejmuje refy, performerow, tagi,
|
||||
ulubione, postep). To jest utrata danych userow, dlatego domyslnie dry-run.
|
||||
2. Sceny scalone z innymi zrodlami: zostaja, wylatuje tylko zrodlo sxyprn tego autora
|
||||
i tag „Sizuka" (podpis Shizuki).
|
||||
3. Smieciowi performerzy: na scenach, ktorych jedynym zrodlem jest sxyprn, zdejmuje
|
||||
przypisania jednowyrazowych performerow, ktorych nazwa nie stoi doslownie w tytule
|
||||
(ani jako zlepek „JoannaAngel" przy tytule „Joanna Angel").
|
||||
To pozostalosc po dopasowaniu przedrostkiem w obie strony („Thebdck" do „the"),
|
||||
naprawionym tego samego dnia w `person_named_in`.
|
||||
|
||||
Uzycie (na VPS, z pliku, nie przez stdin):
|
||||
docker compose run --rm worker python3 -u /srv/scripts/sxyprn_purge_uploaders.py
|
||||
docker compose run --rm worker python3 -u /srv/scripts/sxyprn_purge_uploaders.py --apply
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import session_scope
|
||||
|
||||
AUTORZY = {
|
||||
"69cab3467ec41": "Shizuka",
|
||||
"68e92925d23d9": "Ajx",
|
||||
"6a0d125a875d2": "Denzel",
|
||||
}
|
||||
_CHUNK = 500
|
||||
|
||||
# Zrodlo sxyprn z id autora wyciagnietym z miniatury (drugi segment musi byc id posta).
|
||||
_SQL_ZRODLA_AUTOROW = text(
|
||||
"""
|
||||
SELECT ps.id, ps.scene_id, a.aid
|
||||
FROM playback_sources ps
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT substring(
|
||||
ps.thumbnail_url FROM
|
||||
'/([a-f0-9]{13})/' || substring(ps.page_url FROM '/post/([a-f0-9]+)\\.html') || '/'
|
||||
) AS aid
|
||||
) a
|
||||
WHERE ps.origin = 'tube:sxyprncom' AND a.aid = ANY(:autorzy)
|
||||
"""
|
||||
)
|
||||
|
||||
_SQL_KLASYFIKACJA = text(
|
||||
"""
|
||||
SELECT s.id,
|
||||
EXISTS (SELECT 1 FROM playback_sources p WHERE p.scene_id = s.id
|
||||
AND p.dead_at IS NULL AND p.id <> ALL(:zrodla_autorow)) AS inne_zrodlo,
|
||||
EXISTS (SELECT 1 FROM scene_external_refs r JOIN sources so ON so.id = r.source_id
|
||||
WHERE r.scene_id = s.id AND so.name IN ('tpdb', 'stashdb')) AS kanon,
|
||||
(SELECT count(*) FROM favorite_scenes f WHERE f.scene_id = s.id) AS ulubione,
|
||||
(SELECT count(*) FROM scene_play_progress f WHERE f.scene_id = s.id) AS ogladane
|
||||
FROM scenes s WHERE s.id = ANY(:sceny)
|
||||
"""
|
||||
)
|
||||
|
||||
_SQL_SMIECIOWI_PERF = text(
|
||||
"""
|
||||
WITH only_sx AS (
|
||||
SELECT s.id, s.title FROM scenes s
|
||||
WHERE EXISTS (SELECT 1 FROM playback_sources p WHERE p.scene_id = s.id AND p.origin = 'tube:sxyprncom')
|
||||
AND NOT EXISTS (SELECT 1 FROM playback_sources p WHERE p.scene_id = s.id AND p.origin <> 'tube:sxyprncom')
|
||||
AND NOT EXISTS (SELECT 1 FROM scene_external_refs r JOIN sources so ON so.id = r.source_id
|
||||
WHERE r.scene_id = s.id AND so.name IN ('tpdb', 'stashdb'))
|
||||
)
|
||||
SELECT sp.scene_id, sp.performer_id, p.canonical_name
|
||||
FROM only_sx o
|
||||
JOIN scene_performers sp ON sp.scene_id = o.id
|
||||
JOIN performers p ON p.id = sp.performer_id
|
||||
WHERE p.canonical_name !~ '\\s'
|
||||
AND position(lower(p.canonical_name) IN lower(o.title)) = 0
|
||||
AND NOT (length(p.canonical_name) >= 5
|
||||
AND position(regexp_replace(lower(p.canonical_name), '[^a-z0-9]', '', 'g')
|
||||
IN regexp_replace(lower(o.title), '[^a-z0-9]', '', 'g')) > 0)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _chunks(seq: list, n: int):
|
||||
for i in range(0, len(seq), n):
|
||||
yield seq[i : i + n]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--apply", action="store_true", help="bez tego tylko raport")
|
||||
ap.add_argument("--skip-junk", action="store_true", help="pomin krok 3 (smieciowi performerzy)")
|
||||
ap.add_argument("--only-junk", action="store_true", help="tylko krok 3, scen i zrodel nie ruszaj")
|
||||
ap.add_argument("--authors", default=",".join(AUTORZY), help="id autorow po przecinku")
|
||||
args = ap.parse_args()
|
||||
autorzy = [a.strip().lower() for a in args.authors.split(",") if a.strip()]
|
||||
dry = not args.apply
|
||||
print(f"tryb: {'DRY-RUN' if dry else 'APPLY'}; autorzy: {', '.join(AUTORZY.get(a, a) for a in autorzy)}")
|
||||
|
||||
with session_scope() as s:
|
||||
rows = s.execute(_SQL_ZRODLA_AUTOROW, {"autorzy": autorzy}).all()
|
||||
zrodla = [r[0] for r in rows]
|
||||
per_autor: dict[str, set] = {}
|
||||
sceny_all: set = set()
|
||||
for _, scene_id, aid in rows:
|
||||
per_autor.setdefault(aid, set()).add(scene_id)
|
||||
sceny_all.add(scene_id)
|
||||
for aid, ids in per_autor.items():
|
||||
n_src = sum(1 for r in rows if r[2] == aid)
|
||||
print(f" {AUTORZY.get(aid, aid):<8} zrodel/scen: {n_src}/{len(ids)}")
|
||||
sceny = sorted(sceny_all, key=str)
|
||||
print(f"lacznie: {len(zrodla)} zrodel, {len(sceny)} scen")
|
||||
|
||||
do_usuniecia: list = []
|
||||
do_odciecia: list = []
|
||||
ulub = ogl = 0
|
||||
for chunk in _chunks(sceny, _CHUNK):
|
||||
for sid, inne, kanon, u, o in s.execute(
|
||||
_SQL_KLASYFIKACJA, {"sceny": chunk, "zrodla_autorow": zrodla}
|
||||
).all():
|
||||
if inne or kanon:
|
||||
do_odciecia.append(sid)
|
||||
else:
|
||||
do_usuniecia.append(sid)
|
||||
ulub += u
|
||||
ogl += o
|
||||
print(
|
||||
f"do usuniecia: {len(do_usuniecia)} scen (ulubionych {ulub}, wpisow historii {ogl}); "
|
||||
f"do odciecia zrodla (scena zostaje): {len(do_odciecia)}"
|
||||
)
|
||||
|
||||
smieci = [] if args.skip_junk else s.execute(_SQL_SMIECIOWI_PERF).all()
|
||||
if not args.skip_junk:
|
||||
print(
|
||||
f"smieciowe przypisania performerow na scenach tylko-sxyprn: {len(smieci)} "
|
||||
f"na {len({r[0] for r in smieci})} scenach"
|
||||
)
|
||||
top: dict[str, int] = {}
|
||||
for _, _, name in smieci:
|
||||
top[name] = top.get(name, 0) + 1
|
||||
najczestsi = sorted(top.items(), key=lambda x: -x[1])[:12]
|
||||
print(" najczestsi: " + ", ".join(f"{n} ({c})" for n, c in najczestsi))
|
||||
|
||||
if dry:
|
||||
print("dry-run, nic nie zmieniam")
|
||||
return 0
|
||||
|
||||
if args.only_junk:
|
||||
do_odciecia, do_usuniecia = [], []
|
||||
print("tylko krok 3, sceny i zrodla zostaja")
|
||||
|
||||
# 2. sceny scalone: zostaja, wylatuje tylko zrodlo autora + tag Sizuka
|
||||
n_ps = 0
|
||||
for chunk in _chunks(do_odciecia, _CHUNK):
|
||||
n_ps += s.execute(
|
||||
text("DELETE FROM playback_sources WHERE scene_id = ANY(:sceny) AND id = ANY(:zrodla)"),
|
||||
{"sceny": chunk, "zrodla": zrodla},
|
||||
).rowcount
|
||||
s.execute(
|
||||
text(
|
||||
"DELETE FROM scene_tags st USING tags t "
|
||||
"WHERE t.id = st.tag_id AND t.slug = 'sizuka' AND st.scene_id = ANY(:sceny)"
|
||||
),
|
||||
{"sceny": chunk},
|
||||
)
|
||||
s.commit()
|
||||
print(f"odciete zrodla: {n_ps}")
|
||||
|
||||
# 1. sceny wylacznie tych autorow: DELETE, kaskada robi reszte
|
||||
n_sc = 0
|
||||
for chunk in _chunks(do_usuniecia, _CHUNK):
|
||||
s.execute(
|
||||
text("DELETE FROM merge_candidates WHERE left_id = ANY(:sceny) OR right_id = ANY(:sceny)"),
|
||||
{"sceny": chunk},
|
||||
)
|
||||
n_sc += s.execute(text("DELETE FROM scenes WHERE id = ANY(:sceny)"), {"sceny": chunk}).rowcount
|
||||
s.commit()
|
||||
print(f"usuniete sceny: {n_sc}")
|
||||
|
||||
# 3. smieciowi performerzy
|
||||
n_sp = 0
|
||||
pary = [(r[0], r[1]) for r in smieci]
|
||||
for chunk in _chunks(pary, _CHUNK):
|
||||
n_sp += s.execute(
|
||||
text(
|
||||
"DELETE FROM scene_performers sp "
|
||||
"USING unnest(CAST(:sc AS uuid[]), CAST(:pf AS uuid[])) AS u(sc, pf) "
|
||||
"WHERE sp.scene_id = u.sc AND sp.performer_id = u.pf"
|
||||
),
|
||||
{"sc": [c[0] for c in chunk], "pf": [c[1] for c in chunk]},
|
||||
).rowcount
|
||||
s.commit()
|
||||
print(f"zdjete smieciowe przypisania: {n_sp}")
|
||||
|
||||
# podpis Shizuki jako tag: bez scen nie ma czego trzymac
|
||||
s.execute(
|
||||
text(
|
||||
"DELETE FROM tags t WHERE t.slug = 'sizuka' "
|
||||
"AND NOT EXISTS (SELECT 1 FROM scene_tags st WHERE st.tag_id = t.id)"
|
||||
)
|
||||
)
|
||||
s.commit()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
import pytest
|
||||
|
||||
from app.normalize.text import normalize, normalize_person, slugify, strip_accents
|
||||
from app.normalize.text import (
|
||||
normalize,
|
||||
normalize_person,
|
||||
person_named_in,
|
||||
slugify,
|
||||
strip_accents,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -48,3 +54,30 @@ def test_strip_accents_idempotent() -> None:
|
|||
once = strip_accents("naïve")
|
||||
twice = strip_accents(once)
|
||||
assert once == twice == "naive"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("title", "person", "expected"),
|
||||
[
|
||||
("Savannah Bond Takes It All", "Savanna Bond", True),
|
||||
("Jynx Maze Anal", "Jyn Maze", True),
|
||||
("Julia Ann and Bridgette B", "Julia B.", False),
|
||||
("Kim Gold Solo", "Goldy Kim", False),
|
||||
# Jednowyrazowe nazwisko: tylko co do znaku. „Thebdck" lapal kazde „the".
|
||||
("Twins Sasha Paige Share the Dick ft. Martin Spell", "Thebdck", False),
|
||||
("Twins Sasha Paige Share the Dick ft. Martin Spell", "Sashamalone", False),
|
||||
("Twins Sasha Paige Share the Dick ft. Martin Spell", "Dickxy", False),
|
||||
("Twins Sasha Paige Share the Dick ft. Martin Spell", "Sasha Paige", True),
|
||||
("Mandingo Destroys Her", "Mandingo", True),
|
||||
# Zlepek sxyprn rowny sklejce sasiednich czlonow tytulu.
|
||||
("Joanna Angel Rides Again", "JoannaAngel", True),
|
||||
("Bonnie B Solo", "Bonnieb", True),
|
||||
("Joanna Smith and Angel Dark", "JoannaAngel", False),
|
||||
("Mandingos Best", "Mandingo", False),
|
||||
# Przedrostek z limitem dlugosci: wariant zapisu tak, doklejone slowo nie.
|
||||
("Alexis Crystal Returns", "Alexis Crystalline", False),
|
||||
("Sasha Grey Returns", "Sash Grey", True),
|
||||
],
|
||||
)
|
||||
def test_person_named_in(title: str, person: str, expected: bool) -> None:
|
||||
assert person_named_in(title, person) is expected
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue