Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions blog-automation/fix_unlicensed_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,28 +125,53 @@ def _iter_posts(blog_id: str, token: str):
def _find_safe_replacement(title: str, keyword: str = "") -> dict | None:
"""์ œ๋ชฉ ๊ธฐ๋ฐ˜์œผ๋กœ ์ €์ž‘๊ถŒ ์•ˆ์ „ํ•œ ๋Œ€์ฒด ์ด๋ฏธ์ง€๋ฅผ ํ™•๋ณดํ•ฉ๋‹ˆ๋‹ค.

_fetch_best_image๋Š” ์ด๋ฏธ ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ๊ฐ€ ์ ์šฉ๋œ ์†Œ์Šค๋งŒ ๊ฒ€์ƒ‰ํ•˜์ง€๋งŒ,
๋ฐ˜ํ™˜๊ฐ’์„ ํ•œ ๋ฒˆ ๋” ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค โ€” ์—ฌ๊ธฐ์„œ ์•ˆ์ „ํ•˜์ง€ ์•Š์€ ๊ฒƒ์ด ํ†ต๊ณผํ•˜๋ฉด
๋ฌด๋‹จ ์ด๋ฏธ์ง€๋ฅผ ๋‹ค๋ฅธ ๋ฌด๋‹จ ์ด๋ฏธ์ง€๋กœ ๋ฐ”๊พธ๋Š” ์ตœ์•…์˜ ๊ฒฐ๊ณผ๊ฐ€ ๋ฉ๋‹ˆ๋‹ค.
๊ฐ™์€ ์‚ฌ์ง„์ด ์—ฌ๋Ÿฌ ๊ธ€์— ๋ฐ˜๋ณต๋˜์ง€ ์•Š๋„๋ก, ํ›„๋ณด๋ฅผ ๊ด€๋ จ์„ฑ ์ˆœ์œผ๋กœ ํ›‘์œผ๋ฉด์„œ
"์ด๋ฏธ ๋งŽ์ด ์“ด ์‚ฌ์ง„"์€ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค.

URL์ด๋‚˜ ์ œ๋ชฉ์œผ๋กœ๋Š” ์ด ํŒ์ •์„ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค โ€” Pixabay๋Š” ๊ฐ™์€ ์‚ฌ์ง„์—
๋งค๋ฒˆ ๋‹ค๋ฅธ ์„œ๋ช… URL์„ ์ฃผ๊ณ , ํƒœ๊ทธ ๋ฌธ์ž์—ด๋„ ์กฐ๊ธˆ์”ฉ ๋‹ฌ๋ผ์ง‘๋‹ˆ๋‹ค. ๋‚ด๋ ค๋ฐ›์•„
๋‚ด์šฉ ํ•ด์‹œ๋ฅผ ๊ณ„์‚ฐํ•œ ๋’ค์—์•ผ ๊ฐ™์€ ์‚ฌ์ง„์ธ ์ค„ ์••๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ๋ณต์ œ๊ธฐ๊ฐ€
๋Œ๋ ค์ฃผ๋Š” uses๋ฅผ ๋ณด๊ณ  ํŒ๋‹จํ•ฉ๋‹ˆ๋‹ค (2026-08-25 ์‹ค์ธก: ์‚ฌ์ง„ ํ•œ ์žฅ์ด 61ํŽธ).

ํ›„๋ณด๊ฐ€ ์ „๋ถ€ ๊ณผ๋‹ค ์‚ฌ์šฉ์ด๋ฉด ๊ทธ์ค‘ ๊ฐ€์žฅ ๋œ ์“ด ๊ฒƒ์„ ์”๋‹ˆ๋‹ค โ€” ์ด๋ฏธ์ง€ ์—†๋Š”
๊ธ€๋ณด๋‹ค๋Š” ๋ฐ˜๋ณต์ด๋ผ๋„ ๋‚ซ๊ณ , ์—ฌ๊ธฐ์„œ None์„ ๋Œ๋ ค์ฃผ๋ฉด ๊ต์ฒด ๋Œ€์ƒ ์ด๋ฏธ์ง€๊ฐ€
์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค.
"""
import image_mirror
from image_fetcher import (
_fetch_best_image, _simplify_query, adopt_image, generate_fallback_image,
is_license_safe,
_score_title_relevance, _search_all_sources, _simplify_query, adopt_image,
filter_license_safe, generate_fallback_image, is_license_safe,
)

query = _simplify_query(title, 3) or title
img = _fetch_best_image(
query, n_candidates=6, pixabay_api_key=os.getenv("PIXABAY_API_KEY", ""),
)
if img and is_license_safe(img.get("url", "")):
img["alt_text"] = query[:50]
return adopt_image(img)
candidates = filter_license_safe(_search_all_sources(
query, "", "", 8, pixabay_api_key=os.getenv("PIXABAY_API_KEY", ""),
))
candidates.sort(key=lambda c: -_score_title_relevance(c, query))

least_used = None
for cand in candidates[:5]:
if not is_license_safe(cand.get("url", "")):
continue
cand["alt_text"] = query[:50]
got = adopt_image(cand)
if not got:
continue
uses = image_mirror.times_used(got.get("url", ""))
if uses <= image_mirror.MAX_REUSE:
return got
logger.info(f" ์ด๋ฏธ {uses}ํŽธ์— ์“ด ์‚ฌ์ง„ โ€” ๋‹ค๋ฅธ ํ›„๋ณด ํ™•์ธ: {query[:20]}")
if least_used is None or uses < least_used[0]:
least_used = (uses, got)

thumb = generate_fallback_image(title, keyword)
if thumb and is_license_safe(thumb.get("url", "")):
kind = "AI ์ƒ์„ฑ ์ด๋ฏธ์ง€" if thumb.get("source") == "ai_generated" else "์ƒ์„ฑ ์ธ๋„ค์ผ"
logger.info(f" ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์—†์Œ โ†’ {kind} ์‚ฌ์šฉ")
logger.info(f" ์“ธ ๋งŒํ•œ ์ƒˆ ์‚ฌ์ง„ ์—†์Œ โ†’ {kind} ์‚ฌ์šฉ")
return thumb
if least_used:
logger.warning(f" ํ›„๋ณด๊ฐ€ ์ „๋ถ€ ๊ณผ๋‹ค ์‚ฌ์šฉ โ€” {least_used[0]}ํŽธ์งœ๋ฆฌ ์‚ฌ์ง„์„ ์žฌ์‚ฌ์šฉ")
return least_used[1]
return None


Expand Down
41 changes: 39 additions & 2 deletions blog-automation/image_mirror.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,11 @@ def mirror_url(url: str) -> dict | None:
rec = manifest["items"][known]
path = os.path.join(MIRROR_DIR, rec["file"])
if os.path.exists(path):
rec["uses"] = times_used_by_record(rec) + 1
save_manifest(manifest)
return {"url": f"{public_base()}/{rec['file']}", "file": rec["file"],
"sha": known, "bytes": rec.get("bytes", 0), "reused": True}
"sha": known, "bytes": rec.get("bytes", 0), "reused": True,
"uses": rec["uses"]}
# ๋งค๋‹ˆํŽ˜์ŠคํŠธ์—๋Š” ์žˆ๋Š”๋ฐ ํŒŒ์ผ์ด ์—†์œผ๋ฉด ๋‹ค์‹œ ๋‚ด๋ ค๋ฐ›์Šต๋‹ˆ๋‹ค

got = _download(url)
Expand All @@ -258,12 +261,46 @@ def mirror_url(url: str) -> dict | None:
digest, {"file": name, "bytes": len(raw), "mime": mime,
"firstSeen": datetime.now(timezone.utc).isoformat(), "origins": []})
rec["file"] = name
# ๊ฐ™์€ ์‚ฌ์ง„์„ ๋ช‡ ํŽธ์— ๋„ฃ์—ˆ๋Š”์ง€ ์…‰๋‹ˆ๋‹ค. Pixabay๋Š” ๊ฐ™์€ ์‚ฌ์ง„์— ๋งค๋ฒˆ ๋‹ค๋ฅธ
# ์„œ๋ช… URL์„ ์ฃผ๋ฏ€๋กœ origins ๊ฐœ์ˆ˜๋กœ๋Š” ์ •ํ™•ํžˆ ์…€ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค โ€” ๋‚ด๋ ค๋ฐ›์•„
# ํ•ด์‹œ๋ฅผ ๊ณ„์‚ฐํ•œ ์ง€๊ธˆ์ด ์œ ์ผํ•˜๊ฒŒ ํ™•์‹คํ•œ ์‹œ์ ์ž…๋‹ˆ๋‹ค.
#
# ์ด๋ฒˆ ์›๋ณธ์„ ๋ชฉ๋ก์— ๋„ฃ๊ธฐ **์ „์—** ์„ธ์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋„ฃ๊ณ  ๋‚˜์„œ ์„ธ๋ฉด ๋ฐฉ๊ธˆ ๊ฒƒ์„
# ์ด๋ฏธ ์“ด ๊ฒƒ์œผ๋กœ ์ž˜๋ชป ํฌํ•จํ•ด ํ•œ ๋ฒˆ์”ฉ ๋ถ€ํ’€๋ ค์ง‘๋‹ˆ๋‹ค.
prior = times_used_by_record(rec)
if url not in rec["origins"]:
rec["origins"].append(url)
rec["uses"] = prior + 1
save_manifest(manifest)

return {"url": f"{public_base()}/{name}", "file": name, "sha": digest,
"bytes": len(raw), "reused": False}
"bytes": len(raw), "reused": prior > 0, "uses": rec["uses"]}


#: ํ•œ ์‚ฌ์ง„์„ ์ด ํŽธ์ˆ˜๊นŒ์ง€๋งŒ ์”๋‹ˆ๋‹ค. ๋„˜์œผ๋ฉด ๋‹ค๋ฅธ ํ›„๋ณด๋ฅผ ์ฐพ์Šต๋‹ˆ๋‹ค.
#: 2026-08-25 ์‹ค์ธก์—์„œ ์‚ฌ์ง„ ํ•œ ์žฅ์ด 61ํŽธ์— ๋“ค์–ด๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.
MAX_REUSE = 3


def times_used_by_record(rec: dict) -> int:
"""์ด ์‚ฌ์ง„์ด ๋ช‡ ํŽธ์— ์“ฐ์˜€๋Š”์ง€.

uses๊ฐ€ ์—†๋Š” ์˜› ๊ธฐ๋ก์€ origins ๊ฐœ์ˆ˜๋กœ ์…‰๋‹ˆ๋‹ค โ€” ๊ฐ™์€ ์‚ฌ์ง„์— ์„œ๋ช… URL์ด
์—ฌ๋Ÿฌ ๊ฐœ ๋ถ™์€ ๋งŒํผ์€ ์‹ค์ œ๋กœ ์“ฐ์ธ ๊ฒƒ์ด๋ฏ€๋กœ ์ตœ์†Œ๊ฐ’์œผ๋กœ๋Š” ๋งž์Šต๋‹ˆ๋‹ค.
"""
return int(rec.get("uses") or len(rec.get("origins", [])) or 0)


def times_used(mirrored_url: str) -> int:
"""๋ณต์ œ๋ณธ URL์ด ๋ช‡ ํŽธ์— ์“ฐ์˜€๋Š”์ง€ (๋ชจ๋ฅด๋ฉด 0)."""
m = re.search(r"/([0-9a-f]{16})\.[a-z]+$", mirrored_url or "")
if not m:
return 0
return times_used_by_record(load_manifest().get("items", {}).get(m.group(1), {}))


def is_overused(mirrored_url: str, limit: int = MAX_REUSE) -> bool:
return times_used(mirrored_url) > limit


def mirror_images(images: list[dict]) -> int:
Expand Down
15 changes: 9 additions & 6 deletions blog-automation/tests/test_fix_unlicensed_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ def test_rejects_unsafe_search_result(self, monkeypatch):
import fix_unlicensed_images as fx
import image_fetcher

monkeypatch.setattr(image_fetcher, "_fetch_best_image",
lambda *a, **k: {"url": NAVER, "title": "x"})
monkeypatch.setattr(image_fetcher, "_search_all_sources",
lambda *a, **k: [{"url": NAVER, "title": "x"}])
monkeypatch.setattr(image_fetcher, "adopt_image", lambda img: img)
monkeypatch.setattr(image_fetcher, "generate_fallback_image",
lambda *a, **k: {"url": PIXA, "source": "generated_thumbnail"})
out = fx._find_safe_replacement("์ œ๋ชฉ")
Expand All @@ -132,8 +133,9 @@ def test_returns_none_when_everything_unsafe(self, monkeypatch):
import fix_unlicensed_images as fx
import image_fetcher

monkeypatch.setattr(image_fetcher, "_fetch_best_image",
lambda *a, **k: {"url": NAVER})
monkeypatch.setattr(image_fetcher, "_search_all_sources",
lambda *a, **k: [{"url": NAVER}])
monkeypatch.setattr(image_fetcher, "adopt_image", lambda img: img)
monkeypatch.setattr(image_fetcher, "generate_fallback_image",
lambda *a, **k: {"url": NAVER})
assert fx._find_safe_replacement("์ œ๋ชฉ") is None
Expand All @@ -142,8 +144,9 @@ def test_prefers_search_result_when_safe(self, monkeypatch):
import fix_unlicensed_images as fx
import image_fetcher

monkeypatch.setattr(image_fetcher, "_fetch_best_image",
lambda *a, **k: {"url": WIKI, "title": "x"})
monkeypatch.setattr(image_fetcher, "_search_all_sources",
lambda *a, **k: [{"url": WIKI, "title": "x"}])
monkeypatch.setattr(image_fetcher, "adopt_image", lambda img: img)
monkeypatch.setattr(image_fetcher, "generate_fallback_image",
lambda *a, **k: {"url": PIXA})
assert fx._find_safe_replacement("์ œ๋ชฉ")["url"] == WIKI
Expand Down
123 changes: 123 additions & 0 deletions blog-automation/tests/test_image_reuse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""๊ฐ™์€ ์‚ฌ์ง„์ด ์—ฌ๋Ÿฌ ๊ธ€์— ๋ฐ˜๋ณต๋˜์ง€ ์•Š๊ฒŒ โ€” ๋‚ด์šฉ ํ•ด์‹œ๋กœ ํŒ์ •ํ•ฉ๋‹ˆ๋‹ค.

URL์ด๋‚˜ ์ œ๋ชฉ์œผ๋กœ๋Š” ์ด ํŒ์ •์„ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. Pixabay๋Š” ๊ฐ™์€ ์‚ฌ์ง„์— ๋งค๋ฒˆ
๋‹ค๋ฅธ ์„œ๋ช… URL์„ ์ฃผ๊ณ  ํƒœ๊ทธ ๋ฌธ์ž์—ด๋„ ์กฐ๊ธˆ์”ฉ ๋‹ค๋ฆ…๋‹ˆ๋‹ค. 2026-08-25 ํŒŒ์ผ๋Ÿฟ์—์„œ
์ƒˆ๋กœ ์ฑ„ํƒํ•œ 13์žฅ ์ค‘ 10์žฅ์ด ์ด๋ฏธ ์ €์žฅ์†Œ์— ์žˆ๋Š” ์‚ฌ์ง„๊ณผ ๋ฐ”์ดํŠธ ๋‹จ์œ„๋กœ
๋™์ผํ–ˆ๊ณ , ํ•œ ์žฅ์€ 61ํŽธ์— ๋“ค์–ด๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

๋‚ด๋ ค๋ฐ›์•„ ํ•ด์‹œ๋ฅผ ๊ณ„์‚ฐํ•œ ๋’ค์—์•ผ ๊ฐ™์€ ์‚ฌ์ง„์ธ ์ค„ ์•Œ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, ๋ณต์ œ๊ธฐ๊ฐ€
์„ธ์–ด ์ฃผ๋Š” ์‚ฌ์šฉ ํšŸ์ˆ˜๋กœ ํŒ์ •ํ•ฉ๋‹ˆ๋‹ค.
"""

import os
import sys

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

import image_mirror # noqa: E402

PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 40


@pytest.fixture
def store(tmp_path, monkeypatch):
monkeypatch.setattr(image_mirror, "MIRROR_DIR", str(tmp_path / "images"))
monkeypatch.setattr(image_mirror, "MANIFEST_FILE", str(tmp_path / "m.json"))
monkeypatch.setenv("IMAGE_HOST_BASE", "https://example.test/images")
return tmp_path


class _Resp:
status_code = 200

def __init__(self, body=PNG):
self.body = body
self.headers = {"Content-Type": "image/png"}

def raise_for_status(self):
pass

def iter_content(self, n):
yield self.body


def test_same_photo_from_different_urls_counts_up(store, monkeypatch):
"""์„œ๋ช… URL์ด ๋‹ฌ๋ผ๋„ ๋‚ด์šฉ์ด ๊ฐ™์œผ๋ฉด ๊ฐ™์€ ์‚ฌ์ง„์ž…๋‹ˆ๋‹ค."""
monkeypatch.setattr(image_mirror.requests, "get", lambda *a, **k: _Resp())
a = image_mirror.mirror_url("https://pixabay.com/get/sig1.jpg")
b = image_mirror.mirror_url("https://pixabay.com/get/sig2.jpg")
assert a["file"] == b["file"], "๊ฐ™์€ ๋‚ด์šฉ์ธ๋ฐ ๋‹ค๋ฅธ ํŒŒ์ผ๋กœ ์ €์žฅ๋์Šต๋‹ˆ๋‹ค"
assert image_mirror.times_used(b["url"]) == 2


def test_reusing_the_same_url_also_counts(store, monkeypatch):
monkeypatch.setattr(image_mirror.requests, "get", lambda *a, **k: _Resp())
url = "https://pixabay.com/get/sig1.jpg"
image_mirror.mirror_url(url)
again = image_mirror.mirror_url(url)
assert again["reused"] is True
assert image_mirror.times_used(again["url"]) == 2


def test_overuse_threshold(store, monkeypatch):
monkeypatch.setattr(image_mirror.requests, "get", lambda *a, **k: _Resp())
got = None
for i in range(image_mirror.MAX_REUSE):
got = image_mirror.mirror_url(f"https://pixabay.com/get/s{i}.jpg")
assert image_mirror.is_overused(got["url"]) is False
got = image_mirror.mirror_url("https://pixabay.com/get/extra.jpg")
assert image_mirror.is_overused(got["url"]) is True


def test_old_records_without_uses_fall_back_to_origins(store):
"""์˜ค๋Š˜ ์ด์ „ ๊ธฐ๋ก์—๋Š” uses๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค โ€” origins ๊ฐœ์ˆ˜๊ฐ€ ์ตœ์†Œ๊ฐ’์ž…๋‹ˆ๋‹ค."""
image_mirror.save_manifest({"items": {"a" * 16: {
"file": "a" * 16 + ".jpg", "origins": ["u1", "u2", "u3", "u4"]}}})
assert image_mirror.times_used(f"https://example.test/images/{'a' * 16}.jpg") == 4
assert image_mirror.is_overused(f"https://example.test/images/{'a' * 16}.jpg") is True


def test_unknown_url_is_not_overused(store):
assert image_mirror.times_used("https://example.test/images/deadbeefdeadbeef.jpg") == 0
assert image_mirror.is_overused("https://elsewhere/x.jpg") is False


# โ”€โ”€ ํ›„๋ณด ์žฌ์„ ํƒ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def _wire(monkeypatch, candidates, uses_by_url):
"""๊ฒ€์ƒ‰ยท์ฑ„ํƒยท์‚ฌ์šฉํšŸ์ˆ˜๋ฅผ ๊ฐˆ์•„ ๋ผ์›๋‹ˆ๋‹ค."""
import fix_unlicensed_images as fx
monkeypatch.setattr("image_fetcher._search_all_sources",
lambda *a, **k: [dict(c) for c in candidates])
monkeypatch.setattr("image_fetcher.filter_license_safe", lambda imgs: imgs)
monkeypatch.setattr("image_fetcher.is_license_safe", lambda u: True)
monkeypatch.setattr("image_fetcher._score_title_relevance",
lambda img, q: img.get("score", 0))
monkeypatch.setattr("image_fetcher.adopt_image", lambda img: img)
monkeypatch.setattr("image_fetcher.generate_fallback_image", lambda t, k: None)
monkeypatch.setattr(image_mirror, "times_used",
lambda url: uses_by_url.get(url, 0))
return fx


def test_overused_candidate_is_skipped(monkeypatch):
"""๊ฐ€์žฅ ๊ด€๋ จ์„ฑ ๋†’์€ ํ›„๋ณด๋ผ๋„ ์ด๋ฏธ ๋งŽ์ด ์ผ์œผ๋ฉด ๋‹ค์Œ์œผ๋กœ ๋„˜์–ด๊ฐ‘๋‹ˆ๋‹ค."""
fx = _wire(monkeypatch,
[{"url": "a", "score": 1.0}, {"url": "b", "score": 0.9}],
{"a": 99, "b": 0})
assert fx._find_safe_replacement("์–ด๋–ค ๊ธ€ ์ œ๋ชฉ")["url"] == "b"


def test_all_overused_reuses_the_least_used(monkeypatch):
"""์ „๋ถ€ ๊ณผ๋‹ค ์‚ฌ์šฉ์ด๋ฉด ๊ฐ€์žฅ ๋œ ์“ด ๊ฒƒ โ€” ์—ฌ๊ธฐ์„œ None์„ ์ฃผ๋ฉด ์ด๋ฏธ์ง€๊ฐ€ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค."""
fx = _wire(monkeypatch,
[{"url": "a", "score": 1.0}, {"url": "b", "score": 0.9}],
{"a": 50, "b": 10})
assert fx._find_safe_replacement("์–ด๋–ค ๊ธ€ ์ œ๋ชฉ")["url"] == "b"


def test_fresh_candidate_wins_immediately(monkeypatch):
fx = _wire(monkeypatch, [{"url": "a", "score": 1.0}], {"a": 0})
assert fx._find_safe_replacement("์–ด๋–ค ๊ธ€ ์ œ๋ชฉ")["url"] == "a"
Loading