fix: 저장·복구 정합성 5종 — 호스트 재직렬화, 고아 크롭, assetMissing 생애주기

1) `_result_host` 가 자기가 만든 host 를 안 읽어 재직렬화 시 퇴행 (잠재)

   라운드트립은 provider 를 버리고 domain(= provider 이름)만 남긴다. 그래서
   복구된 행을 다시 직렬화하면 `explicit != provider` 가 성립해 호스트가
   "google_custom_search" / "google" / "internal" 로 되돌아갔다 — 847f7a8 이
   고친 바로 그 버그가, 테스트 전부 초록인 채로. host 우선 반환으로 고쳤고
   3회 왕복 안정성을 테스트로 고정했다. 현재 `_evidence_payload` 호출부는
   모두 신선한 증거라 잠재였지만, 증거 편집 엔드포인트나 마이그레이션이
   하나 생기면 바로 라이브가 된다.

   `_hostname_of` 도 함께 고쳤다: urlparse("//google") 이 "google" 을 호스트로
   보고하므로 점 없는 토큰은 거부한다. 잘못 저장된 host="google" 행은 URL
   파싱으로 자동 복구된다.

2) 복구가 남긴 고아 faceCrops 참조

   백업 복구는 보존기간 대상 미디어를 의도적으로 스킵하는데 DB 는 그대로
   복사되므로, 행은 존재하지 않는 크롭 썸네일을 계속 광고한다. 재생성 게이트가
   `"faceCrops" not in submission` 이라 복구된 행은 리로드에서도 건너뛰고,
   purge 는 존재하는 파일만 순회하므로 참조도 못 지운다 — 영구히 깨진 생체
   썸네일에 회수 경로가 없었다. 게이트를 파일 존재 확인으로 바꾸고(원본이
   있으면 재생성), 원본까지 없으면 참조를 정리하고 감사 이벤트를 남긴다.

3) assetMissing 이 한 번 켜지면 안 꺼졌다

   파일이 돌아와도 486행에서 조기 continue 하므로 플래그가 영구 거짓이 되고,
   1회 마킹 가드가 이후의 진짜 소실에 대한 감사 이벤트까지 막았다. 복귀 시
   해제 + "Submission asset restored" 이벤트를 추가해, 두 번째 소실도 감사에
   남는다.

4) 플래그와 감사 이벤트가 트랜잭션 밖 2회 쓰기였다

   사이에서 죽으면 플래그만 남고 이벤트는 없으며, 가드 때문에 이후 어떤
   리로드도 이벤트를 쓰지 않는다 — 감사 로그가 유일한 기록인 납품에서 판정
   보존의 근거가 사라진다. purge_expired_face_crops / set_provider_enabled 와
   같은 방식으로 한 트랜잭션에 묶었다.

5) 스코어러 마이그레이션이 감사 흔적 없이 밴드를 바꿨다

   가중치 변경은 다음 리로드에서 전 행을 리스코어한다. 운영자가 high 로
   반려한 케이스가 medium 으로 표시되는데 "새 근거" 와 "스코어러 교체" 를
   구분할 방법이 없었다. 라이브 스탬프 `scoredVersion` 을 도입해 버전이
   바뀌면서 밴드가 움직인 행에만 "Risk rescored" 이벤트를 1회 남긴다 —
   동일 버전의 일상 리스코어는 조용하므로 500행 감사 캡을 쓰지 않는다.
   as-created 스탬프(scorerVersion)는 그대로 보존된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
changukyu 2026-09-18 23:53:31 +09:00
parent b0aa4c5bcc
commit 4ba83ebc65
6 changed files with 323 additions and 13 deletions

View file

@ -338,7 +338,7 @@ class CopyrighterStore(
self._refresh_existing_submission_file_facts(image_store, queue_id=queue_id) self._refresh_existing_submission_file_facts(image_store, queue_id=queue_id)
self._refresh_existing_local_face_evidence(image_store, queue_id=queue_id) self._refresh_existing_local_face_evidence(image_store, queue_id=queue_id)
for submission in self._all("submissions", queue_id=queue_id): for submission in self._all("submissions", queue_id=queue_id):
if "faceCrops" not in submission: if self._face_crops_need_sync(submission):
self._sync_face_crops(str(submission["id"]), image_store) self._sync_face_crops(str(submission["id"]), image_store)
self._rescore_all_submissions(queue_id=queue_id) self._rescore_all_submissions(queue_id=queue_id)
self._sync_submission_provider_state(queue_id=queue_id) self._sync_submission_provider_state(queue_id=queue_id)
@ -477,12 +477,19 @@ class CopyrighterStore(
public_prefix = image_store.public_prefix.rstrip("/") + "/" public_prefix = image_store.public_prefix.rstrip("/") + "/"
missing_ids: list[str] = [] missing_ids: list[str] = []
retained: list[dict[str, Any]] = [] retained: list[dict[str, Any]] = []
recovered: list[dict[str, Any]] = []
for submission in self._all("submissions", queue_id=queue_id): for submission in self._all("submissions", queue_id=queue_id):
asset = str(submission.get("asset", "")) asset = str(submission.get("asset", ""))
if not asset.startswith(public_prefix): if not asset.startswith(public_prefix):
continue continue
relative_path = asset.removeprefix(public_prefix) relative_path = asset.removeprefix(public_prefix)
if image_store.media_path(relative_path).exists(): if image_store.media_path(relative_path).exists():
# The file came back - ordinary housekeeping is reversible. The
# marker has to come off with it: left set it becomes a permanent
# lie in the console, and the mark-once guard below would then
# suppress the audit event for a later, genuine disappearance.
if submission.get("assetMissing"):
recovered.append(submission)
continue continue
# A decided case IS the record of that decision, and evidence rows # A decided case IS the record of that decision, and evidence rows
# cascade with the submission. Moving a reviewed image out of the # cascade with the submission. Moving a reviewed image out of the
@ -494,16 +501,35 @@ class CopyrighterStore(
continue continue
missing_ids.append(str(submission["id"])) missing_ids.append(str(submission["id"]))
for submission in recovered:
with self._transaction() as conn:
submission["assetMissing"] = False
self._put("submissions", submission["id"], submission, conn=conn)
self.add_audit_event(
actor="system",
event="Submission asset restored",
object_id=str(submission["id"]),
change="source image file present again",
conn=conn,
)
for submission in retained: for submission in retained:
# Mark once. This sweep runs on every reload, so an unmarked retain # Mark once. This sweep runs on every reload, so an unmarked retain
# would re-emit the same audit event until it filled the log. # would re-emit the same audit event until it filled the log. Marker
# and event go in ONE transaction: a crash between two separate
# commits would persist the flag without the event, and the guard
# above then means no later reload ever writes it - leaving the
# retention of a decided case with no audit trail at all, in a
# delivery whose audit log is the only record.
with self._transaction() as conn:
submission["assetMissing"] = True submission["assetMissing"] = True
self._put("submissions", submission["id"], submission) self._put("submissions", submission["id"], submission, conn=conn)
self.add_audit_event( self.add_audit_event(
actor="system", actor="system",
event="Submission retained", event="Submission retained",
object_id=str(submission["id"]), object_id=str(submission["id"]),
change="source image file missing; decided case kept", change="source image file missing; decided case kept",
conn=conn,
) )
if not missing_ids: if not missing_ids:

View file

@ -13,6 +13,7 @@ import json
import logging import logging
from dataclasses import replace from dataclasses import replace
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Any from typing import Any
from rights_filter.analysis.face_person_detection import ( from rights_filter.analysis.face_person_detection import (
@ -187,6 +188,56 @@ class StoreEnrichmentMixin:
) )
self._rescore_submission(submission_id) self._rescore_submission(submission_id)
def _face_crop_path(self, submission_id: str, crop: dict[str, Any]) -> Path:
return self.face_crop_image_dir / submission_id / f"crop-{crop.get('index')}.jpg"
def _face_crops_need_sync(self, submission: dict[str, Any]) -> bool:
"""Whether a reload has to rebuild (or clear) this row's face crops.
A missing key means the row predates the feature. A key whose files are
gone means the crops were taken out from under the DB: archive restore
skips retention-bound media on purpose (store_backup.py), and nothing
reconciled the references it left behind - the rebuild used to be gated
on the key's absence alone, so a restored row kept advertising
thumbnails the server would 404 forever.
"""
if "faceCrops" not in submission:
return True
submission_id = str(submission.get("id", ""))
return any(
not self._face_crop_path(submission_id, crop).exists()
for crop in submission.get("faceCrops", [])
if isinstance(crop, dict)
)
def _prune_missing_face_crop_refs(self, submission_id: str) -> None:
"""Drop crop references whose file is gone when no rebuild is possible.
Reached when the original image is unavailable too (a restore that
skipped both, or a deleted upload). purge_expired_face_crops only walks
files that still exist, so without this the row is stuck advertising
biometric thumbnails that cannot be served or regenerated.
"""
try:
submission = self._get("submissions", submission_id)
except KeyError:
return
crops = [crop for crop in submission.get("faceCrops", []) if isinstance(crop, dict)]
remaining = [crop for crop in crops if self._face_crop_path(submission_id, crop).exists()]
if len(remaining) == len(crops):
return
dropped = len(crops) - len(remaining)
with self._transaction() as conn:
submission["faceCrops"] = remaining
self._put("submissions", submission_id, submission, conn=conn)
self.add_audit_event(
actor="system",
event="Face crop reference cleared",
object_id=submission_id,
change=f"{dropped} crop reference(s) had no file and no original image to rebuild from",
conn=conn,
)
def _sync_face_crops( def _sync_face_crops(
self, self,
submission_id: str, submission_id: str,
@ -200,6 +251,7 @@ class StoreEnrichmentMixin:
# A missing/unreadable image is expected for some rows, but log it so # A missing/unreadable image is expected for some rows, but log it so
# a real disk/permission failure is not invisible. # a real disk/permission failure is not invisible.
logger.warning("face-crop sync skipped: could not load image for %s", submission_id, exc_info=True) logger.warning("face-crop sync skipped: could not load image for %s", submission_id, exc_info=True)
self._prune_missing_face_crop_refs(submission_id)
return return
signal = HeuristicFacePersonDetector().detect(original) signal = HeuristicFacePersonDetector().detect(original)

View file

@ -13,7 +13,7 @@ from pathlib import Path
from typing import Any from typing import Any
from rights_filter.analysis.fingerprints import FingerprintService from rights_filter.analysis.fingerprints import FingerprintService
from rights_filter.analysis.risk_scoring import RiskScorer from rights_filter.analysis.risk_scoring import SCORER_VERSION, RiskScorer
from rights_filter.domain.records import ( from rights_filter.domain.records import (
Evidence, Evidence,
EvidenceSource, EvidenceSource,
@ -725,6 +725,14 @@ class StoreSearchCandidatesMixin:
for item in self._evidence_for_submission(submission_id) for item in self._evidence_for_submission(submission_id)
] ]
score = RiskScorer().score(evidence) score = RiskScorer().score(evidence)
previous_band = str(submission.get("riskBand", ""))
previous_score = submission.get("riskScore", "")
# The version the LIVE score was last produced by, as opposed to the
# as-created stamp below. A weight change rescores every row on the next
# folder reload, so without this a case an operator rejected as "high"
# could display as "medium" with different reasons and no trace that a
# scorer migration - rather than new evidence - moved it.
scored_version = str(submission.get("scoredVersion", ""))
# Only the live score moves. initialRiskScore / initialRiskBand / # Only the live score moves. initialRiskScore / initialRiskBand /
# scorerVersion are the as-created stamp and must survive every rescore — # scorerVersion are the as-created stamp and must survive every rescore —
# they are the other half of the comparison that makes score-preservation # they are the other half of the comparison that makes score-preservation
@ -738,7 +746,30 @@ class StoreSearchCandidatesMixin:
# mark them instead of fabricating one, so a later calibration can # mark them instead of fabricating one, so a later calibration can
# exclude them rather than silently trust a round-tripped value. # exclude them rather than silently trust a round-tripped value.
submission["scorerVersion"] = "legacy" submission["scorerVersion"] = "legacy"
submission["scoredVersion"] = SCORER_VERSION
migrated_band = (
scored_version != SCORER_VERSION
and bool(previous_band)
and previous_band != score.band
)
if not migrated_band:
self._put("submissions", submission_id, submission) self._put("submissions", submission_id, submission)
return
# One event per row per migration: scoredVersion is updated in the same
# write, so an ordinary rescore on the same version stays silent and the
# 500-row audit cap is not spent on repeat reloads.
with self._transaction() as conn:
self._put("submissions", submission_id, submission, conn=conn)
self.add_audit_event(
actor="system",
event="Risk rescored",
object_id=submission_id,
change=(
f"scorer {scored_version or 'pre-stamp'} -> {SCORER_VERSION}: "
f"{previous_band} {previous_score} -> {score.band} {score.score}"
),
conn=conn,
)
def _rescore_all_submissions(self, queue_id: str | None = None) -> None: def _rescore_all_submissions(self, queue_id: str | None = None) -> None:
for submission in self._all("submissions", queue_id=queue_id): for submission in self._all("submissions", queue_id=queue_id):

View file

@ -277,6 +277,15 @@ def _result_host(evidence: Evidence) -> str:
— and until now neither the operator's evidence card nor the LLM prompt could — and until now neither the operator's evidence card nor the LLM prompt could
see it: every row read "domain=google". see it: every row read "domain=google".
""" """
# A row that already carries a resolved host keeps it. Re-serializing a
# restored row used to walk straight past this: the round trip drops
# `provider` but keeps `domain` (which holds the provider NAME), so
# `explicit != provider` held and the host reverted to "google_custom_search"
# / "google" / "internal" - the exact bug this function was written to fix,
# silently, with every test still green.
restored = _hostname_of(str(evidence.data.get("host", "")))
if restored:
return restored
provider = str(evidence.data.get("provider", "")).strip() provider = str(evidence.data.get("provider", "")).strip()
explicit = str(evidence.data.get("domain", "")).strip() explicit = str(evidence.data.get("domain", "")).strip()
if explicit and explicit != provider: if explicit and explicit != provider:
@ -294,6 +303,12 @@ def _hostname_of(value: str) -> str:
if not text: if not text:
return "" return ""
if "://" not in text: if "://" not in text:
# urlparse("//google") happily reports "google" as a hostname, so a bare
# token with no dot would pass an API name off as a publishing host. A
# hostname we can show an operator has a label separator; anything else
# is "no host", which is what the caller already handles.
if "." not in text.split("/")[0]:
return ""
text = f"//{text}" text = f"//{text}"
try: try:
host = urlparse(text).hostname or "" host = urlparse(text).hostname or ""

View file

@ -78,6 +78,10 @@ class StoreWorkflowMixin:
"initialRiskScore": submission.get("initialRiskScore"), "initialRiskScore": submission.get("initialRiskScore"),
"initialRiskBand": submission.get("initialRiskBand"), "initialRiskBand": submission.get("initialRiskBand"),
"scorerVersion": submission.get("scorerVersion", "legacy"), "scorerVersion": submission.get("scorerVersion", "legacy"),
# The version that produced the score shown above. It diverges from
# scorerVersion (the as-created stamp) after a weight change, which
# is exactly when "why is this case medium now?" gets asked.
"scoredVersion": submission.get("scoredVersion", ""),
"providerState": dict(submission.get("providerState") or {}), "providerState": dict(submission.get("providerState") or {}),
"evidenceCount": len(shown), "evidenceCount": len(shown),
"contributedCount": sum(1 for item in shown if item["contributed"]), "contributedCount": sum(1 for item in shown if item["contributed"]),

View file

@ -13,6 +13,7 @@ from rights_filter.analysis.face_person_detection import (
face_person_reason, face_person_reason,
) )
from rights_filter.analysis.preprocessing import ImagePayload, build_face_crop_derivatives from rights_filter.analysis.preprocessing import ImagePayload, build_face_crop_derivatives
from rights_filter.analysis.risk_scoring import SCORER_VERSION
from rights_filter.domain.records import Evidence, EvidenceSource from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.server.image_store import LocalSubmissionImageStore from rights_filter.server.image_store import LocalSubmissionImageStore
from rights_filter.server import sqlite_store as sqlite_store_module from rights_filter.server import sqlite_store as sqlite_store_module
@ -141,6 +142,27 @@ def _image_store(tmp_path: Path) -> LocalSubmissionImageStore:
return LocalSubmissionImageStore(tmp_path) return LocalSubmissionImageStore(tmp_path)
def _append_submission_record(image_root: Path, record_id: str, file_name: str) -> None:
"""Drop one more image + manifest row into an existing queue folder."""
(image_root / "images" / file_name).write_text(
"<svg><!-- FACE PERSON --></svg>",
encoding="utf-8",
)
manifest_path = image_root / "submissions.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest.append(
{
"id": record_id,
"title": "Late arrival",
"file": f"images/{file_name}",
"width": 800,
"height": 600,
"submitted_at": "2026-05-26 11:00",
}
)
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
def _png_image_store(tmp_path: Path) -> LocalSubmissionImageStore: def _png_image_store(tmp_path: Path) -> LocalSubmissionImageStore:
from PIL import Image from PIL import Image
@ -1070,9 +1092,15 @@ def test_sqlite_store_emergency_disable_stops_automatic_external_calls(tmp_path:
calls_at_stop = len(transport.calls) calls_at_stop = len(transport.calls)
submission_id = str(store.bootstrap()["submissions"][0]["id"]) submission_id = str(store.bootstrap()["submissions"][0]["id"])
store.seed_from_image_store(LocalSubmissionImageStore(image_root)) # A NEW file has to arrive for the seed path to reach its provider gate.
# Re-seeding the same folder returns at the "nothing new to analyze" guard
# eleven lines earlier, so the gate could be deleted outright and a
# re-seed-only assertion would still pass.
_append_submission_record(image_root, "SUB-LATE", "late.svg")
seeded = store.seed_from_image_store(LocalSubmissionImageStore(image_root))
store.rerun_enrichment(submission_id, image_store) store.rerun_enrichment(submission_id, image_store)
assert seeded == 1, "the new record must actually reach the seed path"
assert len(transport.calls) == calls_at_stop assert len(transport.calls) == calls_at_stop
@ -8465,3 +8493,157 @@ def test_purge_expired_face_crops_keeps_recent_crops(tmp_path: Path):
assert store.purge_expired_face_crops() == 0 assert store.purge_expired_face_crops() == 0
assert crop_file.exists() assert crop_file.exists()
def test_asset_missing_marker_is_cleared_when_the_source_file_comes_back(tmp_path: Path):
# Archiving a decided image out of the queue folder is reversible, so the
# marker has to be too. Left permanently set it became a lie in the console,
# and the mark-once guard then suppressed the audit event for a later,
# genuine disappearance - the second loss was invisible in the audit trail.
image_root = tmp_path / "submissions"
image_store = _image_store(image_root)
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
store.initialize()
store.seed_from_image_store(image_store)
submission_id = str(store.bootstrap()["submissions"][0]["id"])
store.record_decision(submission_id, "rejected", memo="third-party photo")
image_path = image_root / "images" / "sample.svg"
archived = image_path.read_text(encoding="utf-8")
image_path.unlink()
store.seed_from_image_store(LocalSubmissionImageStore(image_root))
assert store._get("submissions", submission_id)["assetMissing"] is True
image_path.write_text(archived, encoding="utf-8")
store.seed_from_image_store(LocalSubmissionImageStore(image_root))
assert store._get("submissions", submission_id)["assetMissing"] is False
assert any(event["event"] == "Submission asset restored" for event in store.audit_events())
image_path.unlink()
store.seed_from_image_store(LocalSubmissionImageStore(image_root))
retained = [event for event in store.audit_events() if event["event"] == "Submission retained"]
assert len(retained) == 2, "a second, genuine loss has to be audited too"
def _submission_with_face_crop_reference(store: CopyrighterStore, submission_id: str) -> None:
store._put(
"submissions",
submission_id,
{
"id": submission_id,
"title": "t",
"asset": "",
"riskScore": 0,
"riskBand": "low",
"decisionStatus": "unreviewed",
"providerState": {},
"fileFacts": {},
"evidence": [],
"faceCrops": [
{"index": 1, "url": f"/face-crop-media/{submission_id}/crop-1.jpg", "box": [0, 0, 1, 1]}
],
},
)
def test_reload_detects_face_crop_references_whose_files_are_gone(tmp_path: Path):
# Archive restore skips retention-bound media on purpose, so the DB comes
# back listing biometric thumbnails whose files are not there. The rebuild
# used to be gated on the key being absent, and purge_expired_face_crops
# only walks files that exist - so nothing reconciled the reference and the
# console rendered a permanently broken crop with no recovery path.
store = CopyrighterStore(tmp_path / "c.sqlite3")
store.initialize()
_submission_with_face_crop_reference(store, "SUB-CROP")
assert store._face_crops_need_sync(store._get("submissions", "SUB-CROP")) is True
crop_dir = store.face_crop_image_dir / "SUB-CROP"
crop_dir.mkdir(parents=True)
(crop_dir / "crop-1.jpg").write_bytes(b"jpegdata")
assert store._face_crops_need_sync(store._get("submissions", "SUB-CROP")) is False
def test_face_crop_references_are_cleared_when_there_is_nothing_to_rebuild_from(tmp_path: Path):
# Reached when the original image is gone too. Clearing is the only honest
# outcome: the row cannot serve the crop and cannot regenerate it.
store = CopyrighterStore(tmp_path / "c.sqlite3")
store.initialize()
_submission_with_face_crop_reference(store, "SUB-CROP")
store._sync_face_crops("SUB-CROP", _image_store(tmp_path / "submissions"))
assert store._get("submissions", "SUB-CROP")["faceCrops"] == []
assert any(event["event"] == "Face crop reference cleared" for event in store.audit_events())
def test_rescore_audits_a_band_change_caused_by_a_scorer_migration(tmp_path: Path):
# A weight change rescores every row on the next folder reload. Without a
# trace, a case an operator rejected as "high" displays as "medium" with
# different reasons and nothing says a scorer migration - rather than new
# evidence - moved it.
image_root = tmp_path / "submissions"
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
store.initialize()
store.seed_from_image_store(_image_store(image_root))
submission_id = str(store.bootstrap()["submissions"][0]["id"])
submission = store._get("submissions", submission_id)
submission["scoredVersion"] = "v1"
submission["riskBand"] = "high"
submission["riskScore"] = 100
store._put("submissions", submission_id, submission)
store._rescore_submission(submission_id)
events = [event for event in store.audit_events() if event["event"] == "Risk rescored"]
assert len(events) == 1
assert f"v1 -> {SCORER_VERSION}" in events[0]["change"]
assert store._get("submissions", submission_id)["scoredVersion"] == SCORER_VERSION
# An ordinary rescore on the same version stays silent, so repeat reloads do
# not spend the audit cap.
store._rescore_submission(submission_id)
assert len([event for event in store.audit_events() if event["event"] == "Risk rescored"]) == 1
def test_evidence_host_survives_repeated_persistence_round_trips():
# The round trip drops `provider` but keeps `domain` (which holds the
# provider NAME), so re-serializing a restored row used to resolve the host
# back to "google_custom_search" - the bug this field was added to fix,
# reintroduced silently with every test still green.
fresh = Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="Google custom web search result found",
confidence=0.35,
data={
"provider": "google_custom_search",
"domain": "blog.example.test",
"search_type": "web",
"match": "page",
"result_url": "https://blog.example.test/post/1",
},
)
first = store_serialization_module._evidence_payload("SUB-1", fresh)
second = store_serialization_module._evidence_payload(
"SUB-1", store_serialization_module._domain_evidence_from_ui(first)
)
third = store_serialization_module._evidence_payload(
"SUB-1", store_serialization_module._domain_evidence_from_ui(second)
)
assert first["host"] == "blog.example.test"
assert second["host"] == "blog.example.test"
assert third["host"] == "blog.example.test"
def test_provider_names_are_not_accepted_as_publishing_hosts():
# urlparse("//google") reports "google" as a hostname, which is how an API
# name ended up rendered as the place an image was published.
hostname_of = store_serialization_module._hostname_of
assert hostname_of("google_custom_search") == ""
assert hostname_of("internal") == ""
assert hostname_of("biz.chosun.com") == "biz.chosun.com"
assert hostname_of("https://www.Example.co.kr/a.jpg") == "example.co.kr"