[feat] solution: 인물 사진은 위키미디어에서 · 사진 없는 엽서는 싣지 않는다

시안과 나란히 놓으니 세 가지가 달랐다(2026-09-10 실측).
① 엽서 네 장이 **같은 사진**이었다 ② 그 네 장이 전부 같은 박물관의 관람안내(주소·휴관일·
운영시간)였다 ③ 인물 열전은 사진이 한 장도 없었다.

★ 인물 사진 — 위키미디어 공식 API (services/external/wikimedia.py)
  공공데이터는 관광지 사진을 주지 사람 얼굴을 주지 않는다. 인물 사진이 공개돼 있으면서
  **재게시 권리를 기계가 읽을 수 있게** 알려주는 곳은 사실상 위키백과·위키공용뿐이다.
  - 크롤링이 아니라 MediaWiki API 다. 페이지를 긁어 파싱하지 않는다
  - 권리 판정을 여기서 끝낸다: 위키에는 자유 저작물만 있지 않다 — 인물에는 특히 '공정 이용'
    (비자유) 파일이 섞이고, 그걸 발행본에 실으면 상업적 이용이라 바로 침해다.
    PD·CC0·CC BY·CC BY-SA 만 통과시키고, NC·ND·fair use·판정 불가는 버린다
  - 통과한 사진에는 **출처 표시가 따라붙는다**(CC BY 계열의 조건). imageCredit 이 그 값이고
    화면에 찍는다 — 표시하지 않을 거면 애초에 쓰지 않는다
  - 실측: 10명 중 2명(전봉준·신석정류)만 자유 저작물이 있다. 나머지는 없는 것이 정답이고
    그 자리는 렌더러가 이니셜로 세운다. 비슷한 이름의 다른 사람 사진을 붙이는 게 더 나쁘다

★ 사진이 본체인 종류는 사진 없는 항목을 버린다 (_IMAGE_REQUIRED_KINDS = postcard)
  엽서는 앞면 사진이 본체다. 없으면 뒷면만 남아 빈 카드로 보인다. 연표는 다르다 —
  활자만으로도 레일에 서므로 버리면 오히려 구멍이 난다. 실측: 12건 중 6건이 빠졌다

★ 같은 사진을 두 번 쓰지 않는다
  네 항목이 같은 시설을 말하면 검색이 같은 사진을 네 번 준다. 두 번째부터는 없는 것으로 친다

★ 프롬프트(shared 한 벌, export 포함)
  - postcard: "같은 대상을 두 번 쓰지 않는다" · "운영시간·휴관일·주소·요금은 엽서에 적지
    않는다 — 그건 이용 정보지 엽서 문장이 아니다" · place 가 사진을 찾는 열쇠임을 명시
- shared/section-data: PeopleItem·ChronicleItem·PostcardItem 에 imageCredit 추가
- site: SourceLine 이 사진 출처를 함께 찍는다(글 출처가 없어도 사진 출처만으로 한 줄 선다).
  엽서는 사진 바로 아래에 따로 찍는다 — 앞면이 사진이라 거기 붙는 게 맞다

실측(전북 군산시) 발행본: 인물 2장 · 엽서 6장(전부 고유) · 연표 3장(전부 고유),
사진 11장 모두 출처 표시. site vitest 51 passed · story·snapshot 23 passed.
This commit is contained in:
hbyang 2026-09-10 15:19:23 +09:00
parent 56ed951a6e
commit a7fcc14e7d
9 changed files with 226 additions and 43 deletions

View File

@ -0,0 +1,116 @@
"""위키미디어 사진 — 인물 얼굴을 **권리를 확인하고** 가져온다.
★ 왜 위키인가
공공데이터(TourAPI)는 관광지·시설 사진을 준다. 사람 얼굴은 주지 않는다 — 인물 열전
열 명을 찔러도 0건이다(실측 2026-09-10). 인물 사진이 공개돼 있으면서 **재게시 권리를
기계가 읽을 수 있는 형태로 알려주는** 곳은 사실상 위키백과·위키공용뿐이다.
★ 크롤링이 아니다. MediaWiki 공식 API 를 쓴다 — robots 를 거스르지 않고, 페이지를 긁어
파싱하지도 않는다. 우리는 API 가 주는 파일 이름과 **그 파일의 라이선스 메타데이터**를 읽는다.
★ 권리 판정을 여기서 끝낸다 (`tour_api.find_image` 와 같은 자리)
위키백과에는 자유 저작물만 있는 것이 아니다 — 인물 사진에는 특히 '공정 이용'(비자유)
파일이 섞인다. 그걸 발행본에 실으면 상업적 이용이라 바로 침해다. 그래서 **상업적 이용을
허용하는 라이선스만** 통과시키고, 판정할 수 없으면 버린다.
통과한 사진에도 **출처 표시**가 따라붙는다(CC BY·BY-SA 의 조건) — `credit` 이 그 값이고,
화면에 그대로 찍는다. 표시하지 않을 거면 애초에 쓰지 않는다.
"""
import re
from typing import NamedTuple, Optional
import httpx
from common.logger import LOG
KO_API = "https://ko.wikipedia.org/w/api.php"
COMMONS_API = "https://commons.wikimedia.org/w/api.php"
REQUEST_TIMEOUT = 15.0
# ★ 위키미디어 API 예절: 누가 부르는지 밝힌다. 익명 UA 는 차단 대상이다.
# (우회 장치가 아니다 — 정직하게 신원을 적는 것이 저쪽이 요구하는 방식이다.)
HEADERS = {"User-Agent": "o2o-web4ai/1.0 (+https://web4ai.o2osolution.ai; contact: hbyang@o2o.kr)"}
# 상업적 이용이 허용되는 라이선스만. 소문자로 맞춰 비교한다.
# · public domain / pd — 조건 없음
# · cc0 — 조건 없음
# · cc by, cc by-sa — 출처 표시 조건(우리는 credit 을 화면에 찍는다)
# ★ 여기 없는 것은 전부 버린다. 특히 'fair use'·'non-free'·'nc'(비영리)·'nd' 는 못 쓴다.
_OK_LICENSE_RE = re.compile(r"\b(public domain|pd-|cc0|cc[- ]by(?:[- ]sa)?)\b", re.I)
_DENY_LICENSE_RE = re.compile(r"\b(fair use|non-?free|nc\b|no[nt][- ]commercial|nd\b)", re.I)
class WikiImage(NamedTuple):
url: str
"""화면에 찍을 출처 표시. CC BY·BY-SA 의 조건이라 비워 두면 쓸 수 없다."""
credit: str
"""그 파일의 설명 페이지. 라이선스 원문을 확인할 수 있는 자리다."""
source_url: str
def _strip_tags(value: str) -> str:
"""extmetadata 의 값에는 HTML 이 섞여 온다(링크·줄바꿈). 화면에 그대로 찍을 수 없다."""
text = re.sub(r"<[^>]+>", "", value or "")
return re.sub(r"\s+", " ", text).strip()
def _license_ok(license_name: str, usage_terms: str) -> bool:
blob = f"{license_name} {usage_terms}"
if _DENY_LICENSE_RE.search(blob):
return False
return bool(_OK_LICENSE_RE.search(blob))
async def find_person_image(client: httpx.AsyncClient, name: str) -> Optional[WikiImage]:
"""인물 이름으로 사진 한 장. 없거나 권리가 불확실하면 None.
★ 없는 것이 정상적인 결말이다. 한국 근대 인물은 자유 저작물 사진이 없는 경우가 많고,
그때 인물 자리는 렌더러가 이니셜 활자로 세운다(PeopleItem.imageUrl 주석).
억지로 채우려고 비슷한 이름의 다른 사람 사진을 붙이는 것이 훨씬 나쁘다.
"""
person = (name or "").strip()
if not person:
return None
try:
res = await client.get(KO_API, params={
"action": "query", "format": "json", "titles": person,
"prop": "pageimages", "piprop": "name", "redirects": 1,
})
pages = (res.json().get("query") or {}).get("pages") or {}
page = next(iter(pages.values()), {})
filename = page.get("pageimage")
if not filename:
return None
meta_res = await client.get(COMMONS_API, params={
"action": "query", "format": "json", "titles": f"File:{filename}",
"prop": "imageinfo", "iiprop": "url|extmetadata",
})
meta_pages = (meta_res.json().get("query") or {}).get("pages") or {}
info = ((next(iter(meta_pages.values()), {}) or {}).get("imageinfo") or [{}])[0]
except Exception as ex: # noqa: BLE001 — 사진은 있으면 좋은 것이지 없으면 안 되는 것이 아니다
LOG.w(f"[wiki] '{person}' 조회 실패(계속): {type(ex).__name__}: {ex}")
return None
url = str(info.get("url") or "").strip()
if not url:
return None
meta = info.get("extmetadata") or {}
license_name = _strip_tags((meta.get("LicenseShortName") or {}).get("value") or "")
usage_terms = _strip_tags((meta.get("UsageTerms") or {}).get("value") or "")
if not _license_ok(license_name, usage_terms):
LOG.i(f"[wiki] '{person}' 사진 있으나 라이선스가 상업 이용 불가/불명 — 버린다 ({license_name or '불명'})")
return None
artist = _strip_tags((meta.get("Artist") or {}).get("value") or "")
credit = " · ".join(x for x in (artist or "Wikimedia Commons", license_name) if x)
return WikiImage(
url=url,
credit=credit,
source_url=str(info.get("descriptionurl") or "").strip() or url,
)
def make_client() -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=REQUEST_TIMEOUT, headers=HEADERS, follow_redirects=True)

View File

@ -28,7 +28,7 @@
"label": "오늘의 엽서",
"maxItems": 12,
"task": "[해야 할 일]\n[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다.\n사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다.\n\n[스키마]\n{ \"kind\":\"postcard\", \"version\":1, \"title\":\"오늘의 엽서\", \"items\":[\n { \"line\":\"한 문장\", \"hashtags\":[\"#태그\"], \"place\":\"장소\",\n \"postmark\":\"소인에 찍을 짧은 지명\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.\n· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.\n· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.\n"
"rules": "\n[이 아이템만의 규칙]\n· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.\n· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.\n· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.\n· **같은 대상을 두 번 쓰지 않는다.** 열두 장이 전부 다른 곳·다른 이야기여야 한다 —\n 한 시설의 주소·휴관일·운영시간을 나눠 적으면 엽서가 아니라 안내문 열두 장이 된다\n (실측 2026-09-10: 네 장이 같은 박물관의 관람안내였다).\n· 운영시간·휴관일·주소·요금은 엽서에 적지 않는다. 그건 이용 정보지 엽서 문장이 아니다.\n· place 는 사진을 찾는 열쇠다. **찾아갈 수 있는 곳 이름을 정확히** 적는다 —\n 이 값으로 공공데이터에서 앞면 사진을 찾고, 못 찾으면 그 엽서는 실리지 않는다.\n"
},
"quiz": {
"kind": "quiz",

View File

@ -31,7 +31,7 @@ from common.utils.gtime import GTime
from common.logger import LOG
from crud.local_content_crud import LocalContentCRUD
from services.grounding import story as grounding
from services.external import tour_api
from services.external import tour_api, wikimedia
from services.llm import perplexity
from services.prompts import story as prompts
@ -74,26 +74,30 @@ async def _generate_kind(client: httpx.AsyncClient, kind: str, region_label: str
return [], [f"호출 실패: {ex}"]
items, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind))
await _attach_images(kind, items, region_label)
items = await _attach_images(kind, items, region_label)
LOG.i(f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림")
return items, dropped
# 사진을 찾아볼 만한 종류와, 무엇으로 찾을지.
# 사진을 무엇으로 찾을지. 종류마다 출처가 다르다.
#
# ★ 인물(people)은 넣지 않는다. 공공데이터는 관광지·시설의 사진을 주지 그 사람의 얼굴을
# 주지 않는다 — 열 명을 다 찔러 봐야 0건이고 호출만 열 번이다. 인물 자리는 렌더러가
# 이니셜 활자로 세우도록 이미 설계돼 있고(PeopleItem.imageUrl 주석), 모델이 남긴
# `imageQuery` 가 "어디서 찾을지" 를 대신 말한다.
# ★ 가요(songs)·퀴즈(quiz)도 넣지 않는다. 시안에도 그 자리에 사진이 없다.
_IMAGE_KEYS = {
"chronicle": ("place", "title"), # 그 해의 장소 → 없으면 사건 이름
# chronicle · postcard 공공데이터(TourAPI) — 장소·시설 사진. 공공누리 Type1/Type3 만
# people 위키미디어 — 사람 얼굴. 상업 이용 가능 라이선스만
#
# ★ 가요·퀴즈는 찾지 않는다. 시안에도 그 자리에 사진이 없다.
_PLACE_IMAGE_KEYS = {
"chronicle": ("place", "title"), # 그 해의 장소 → 없으면 사건 이름
"postcard": ("place", "postmark"), # 엽서 앞면이 될 장소
}
# 한 종류에서 사진을 찾아볼 항목 수 상한. TourAPI 는 건당 0.3초 안팎이지만,
# 종류마다 열두 개를 다 찌르면 생성 시간이 두 배가 된다 — 앞쪽 항목부터 채운다.
_IMAGE_LOOKUP_LIMIT = 6
# 한 종류에서 사진을 찾아볼 항목 수 상한. 건당 0.3~1초라 열두 개를 다 찌르면 생성이 두 배 걸린다.
_IMAGE_LOOKUP_LIMIT = 12
# ★ 사진이 없으면 항목 자체를 버리는 종류.
# 엽서는 **앞면 사진이 본체**다. 사진이 없으면 뒷면(문장·우표·소인)만 남아 카드가 반쪽이 되고,
# 시안과 나란히 놓으면 빈 카드로 보인다(실측 2026-09-10). 연표는 다르다 — 활자만으로도
# 레일 위에 서므로 사진 없는 항목을 버리면 연표에 구멍이 난다.
_IMAGE_REQUIRED_KINDS = {"postcard"}
def _region_token(region_label: str) -> str:
@ -105,35 +109,56 @@ def _region_token(region_label: str) -> str:
return (region_label or "").split()[-1] if region_label else ""
async def _attach_images(kind: str, items: list[dict], region_label: str) -> None:
"""항목에 공공데이터 사진을 붙인다. **수집하는 그 자리에서 함께 가져온다.**
async def _attach_images(kind: str, items: list[dict], region_label: str) -> list[dict]:
"""항목에 사진을 붙이고, 사진이 본체인 종류는 못 붙은 항목을 버린다.
★ 왜 여기인가
이미 DB 에 있는 사진(주변 관광지 등)을 가져다 쓰지 않는다. 그건 "이 항목의 사진" 이
아니라 "마침 우리가 갖고 있던 사진" 이고, 엉뚱한 장소가 그 해의 사진으로 붙는다.
이야기를 만드는 그 순간 그 대상의 이름으로 찾아온 것만 쓴다.
★ 권리 판정은 `tour_api.find_image` 한 곳이다(공공누리 Type1·Type3).
★ 실패해도 조용히 넘어간다 — 사진이 없으면 활자만으로 서는 것이 설계된 모습이다.
★ **수집하는 그 자리에서 함께 가져온다.** 이미 DB 에 있는 사진을 가져다 쓰지 않는다 —
그건 "이 항목의 사진" 이 아니라 "마침 우리가 갖고 있던 사진" 이고, 엉뚱한 장소가
그 해의 사진으로 붙는다.
★ **같은 사진을 두 번 쓰지 않는다.** 네 항목이 같은 시설을 말하면 검색이 같은 사진을
네 번 준다 — 화면에는 같은 그림 넷이 늘어선다(실측 2026-09-10, 군산근대역사박물관).
두 번째부터는 사진 없는 것으로 친다.
★ 권리 판정은 부르는 쪽이 아니라 각 출처 모듈이 한다(tour_api.find_image · wikimedia).
"""
fields = _IMAGE_KEYS.get(kind)
if not fields or not items:
return
token = _region_token(region_label)
found = 0
async with tour_api.make_client() as client:
for item in items[:_IMAGE_LOOKUP_LIMIT]:
if item.get("imageUrl"):
continue
keyword = next((str(item.get(f) or "").strip() for f in fields if item.get(f)), "")
if not keyword:
continue
url = await tour_api.find_image(client, keyword, token)
if url:
item["imageUrl"] = url
used: set[str] = set()
if kind == "people":
async with wikimedia.make_client() as client:
for item in items[:_IMAGE_LOOKUP_LIMIT]:
hit = await wikimedia.find_person_image(client, str(item.get("name") or ""))
if not hit or hit.url in used:
continue
item["imageUrl"], item["imageCredit"] = hit.url, hit.credit
used.add(hit.url)
found += 1
elif kind in _PLACE_IMAGE_KEYS:
fields = _PLACE_IMAGE_KEYS[kind]
token = _region_token(region_label)
async with tour_api.make_client() as client:
for item in items[:_IMAGE_LOOKUP_LIMIT]:
keyword = next((str(item.get(f) or "").strip() for f in fields if item.get(f)), "")
if not keyword:
continue
url = await tour_api.find_image(client, keyword, token)
if not url or url in used:
continue
item["imageUrl"] = url
# 공공누리 제1유형도 출처 표시가 조건이다. 사진을 준 곳을 그대로 적는다.
item["imageCredit"] = "한국관광공사"
used.add(url)
found += 1
if kind in _IMAGE_REQUIRED_KINDS:
kept = [i for i in items if i.get("imageUrl")]
if len(kept) != len(items):
LOG.i(f"[story] {region_label} {kind}: 사진 없는 {len(items) - len(kept)}건 제외 "
f"(이 종류는 사진이 본체다)")
items = kept
if found:
LOG.i(f"[story] {region_label} {kind}: 공공데이터 사진 {found}장 붙임")
LOG.i(f"[story] {region_label} {kind}: 사진 {found}장 붙임")
return items
async def generate_region_stories(region_code: str, region_label: str, kinds: list[str] | None = None) -> dict:

View File

@ -97,6 +97,11 @@ export interface PeopleItem {
* (`services/external/tour_places.py` 의 `_IMAGE_OK`).
*/
imageUrl?: string;
/**
* 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면
* 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`).
*/
imageCredit?: string;
verified?: DataVerified;
source?: DataSource;
}
@ -109,6 +114,11 @@ export interface ChronicleItem {
place?: string;
/** 그 해의 사진. 없으면 연표는 지금처럼 활자만으로 선다 — 자리를 비워 두지 않는다(PeopleItem.imageUrl 과 같은 출처·규칙). */
imageUrl?: string;
/**
* 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면
* 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`).
*/
imageCredit?: string;
/** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */
turning?: boolean;
verified?: DataVerified;
@ -136,6 +146,11 @@ export interface PostcardItem {
postmark?: string;
/** 엽서 앞면 사진. 없으면 뒷면(문장·우표·소인)만 있는 지금 모양 그대로다(PeopleItem.imageUrl 과 같은 출처·규칙). */
imageUrl?: string;
/**
* 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면
* 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`).
*/
imageCredit?: string;
verified?: DataVerified;
source?: DataSource;
}

View File

@ -139,6 +139,12 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.
· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.
· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.
· **같은 대상을 두 번 쓰지 않는다.** 열두 장이 전부 다른 곳·다른 이야기여야 한다 —
한 시설의 주소·휴관일·운영시간을 나눠 적으면 엽서가 아니라 안내문 열두 장이 된다
(실측 2026-09-10: 네 장이 같은 박물관의 관람안내였다).
· 운영시간·휴관일·주소·요금은 엽서에 적지 않는다. 그건 이용 정보지 엽서 문장이 아니다.
· place 는 사진을 찾는 열쇠다. **찾아갈 수 있는 곳 이름을 정확히** 적는다 —
이 값으로 공공데이터에서 앞면 사진을 찾고, 못 찾으면 그 엽서는 실리지 않는다.
`,
},

View File

@ -67,7 +67,7 @@ export function ChronicleSection() {
<h3 className="serif text-base font-bold">{item.title}</h3>
{item.summary && <p className="text-[13px] leading-relaxed opacity-80">{item.summary}</p>}
{item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</p>}
<SourceLine source={item.source} verified={item.verified} />
<SourceLine source={item.source} verified={item.verified} imageCredit={item.imageCredit} />
</div>
</div>
</article>

View File

@ -38,6 +38,7 @@ export function PeopleSection() {
<Rail label="인물 목록" arrows="overlay">
{parsed.items.map((person, index) => (
<article key={`${person.name}-${index}`} className="w-[210px] shrink-0 snap-center">
{/* 사진 출처는 카드 아래 SourceLine 옆에 붙는다(아래) — 라이선스 조건이다. */}
{person.imageUrl ? (
<img
src={person.imageUrl}
@ -69,7 +70,7 @@ export function PeopleSection() {
</p>
{person.oneLine && <p className="mt-2 text-[13px] leading-relaxed opacity-85">{person.oneLine}</p>}
<div className="mt-2">
<SourceLine source={person.source} verified={person.verified} />
<SourceLine source={person.source} verified={person.verified} imageCredit={person.imageCredit} />
</div>
</article>
))}

View File

@ -34,10 +34,16 @@ export function PostcardSection() {
alt={`${card.place || card.postmark || '엽서'} 사진`}
loading="lazy"
decoding="async"
className="mb-3.5 block aspect-3/2 w-full border object-cover"
className="block aspect-3/2 w-full border object-cover"
style={{borderColor: ITEM_BORDER}}
/>
)}
{/* 사진 출처. CC BY·공공누리 제1유형이 **요구하는** 표시라 생략할 수 없다. */}
{card.imageUrl && (
<p className="mt-1 mb-3.5 text-[10px] leading-tight opacity-45">
사진 · {card.imageCredit || '출처 표기 없음'}
</p>
)}
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
<div className="min-w-0 border-r pr-3" style={{borderColor: ITEM_BORDER}}>

View File

@ -186,10 +186,23 @@ export function Rail({
* 검수는 에디터(빌더 캔버스)에서 계속 보인다 — 고칠 사람은 거기서 본다.
* ★ 출처는 남긴다. 인용될 때 근거를 대는 건 이 사이트의 존재 이유다.
*/
export function SourceLine({source, verified}: {source?: DataSource; verified?: DataVerified}) {
export function SourceLine({
source,
verified,
imageCredit,
}: {
source?: DataSource;
verified?: DataVerified;
/**
* 사진의 출처 표시. **글의 출처와 다른 것**이다 — 사진은 위키미디어(CC BY·BY-SA)나
* 공공누리 제1유형에서 오고, 둘 다 출처 표시가 **라이선스 조건**이다. 생략하면 침해다.
* 그래서 글 출처가 없어도 사진 출처만으로 이 줄이 선다.
*/
imageCredit?: string;
}) {
// verified 는 계약에 남겨 둔다(에디터·게이트가 읽는다). 화면에만 안 낸다.
void verified;
if (!source?.name) return null;
if (!source?.name && !imageCredit) return null;
return (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] opacity-70">
{source?.name && (
@ -209,6 +222,7 @@ export function SourceLine({source, verified}: {source?: DataSource; verified?:
)}
</span>
)}
{imageCredit && <span>사진 · {imageCredit}</span>}
</p>
);
}