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