From a7fcc14e7d0072c623e7c4c2063d6efdb556e52d Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 10 Sep 2026 15:19:23 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution:=20=EC=9D=B8=EB=AC=BC=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=EC=9D=80=20=EC=9C=84=ED=82=A4=EB=AF=B8?= =?UTF-8?q?=EB=94=94=EC=96=B4=EC=97=90=EC=84=9C=20=C2=B7=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=20=EC=97=86=EB=8A=94=20=EC=97=BD=EC=84=9C=EB=8A=94=20?= =?UTF-8?q?=EC=8B=A3=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시안과 나란히 놓으니 세 가지가 달랐다(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. --- .../backend/services/external/wikimedia.py | 116 ++++++++++++++++++ .../services/prompts/section_prompts.json | 2 +- solution/backend/services/story_service.py | 99 +++++++++------ solution/shared/src/lib/section-data.ts | 15 +++ solution/shared/src/lib/section-prompts.ts | 6 + .../src/sections/items/ChronicleSection.tsx | 2 +- .../site/src/sections/items/PeopleSection.tsx | 3 +- .../src/sections/items/PostcardSection.tsx | 8 +- solution/site/src/sections/items/common.tsx | 18 ++- 9 files changed, 226 insertions(+), 43 deletions(-) create mode 100644 solution/backend/services/external/wikimedia.py diff --git a/solution/backend/services/external/wikimedia.py b/solution/backend/services/external/wikimedia.py new file mode 100644 index 0000000..c3df518 --- /dev/null +++ b/solution/backend/services/external/wikimedia.py @@ -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) diff --git a/solution/backend/services/prompts/section_prompts.json b/solution/backend/services/prompts/section_prompts.json index 16c1264..cf54772 100644 --- a/solution/backend/services/prompts/section_prompts.json +++ b/solution/backend/services/prompts/section_prompts.json @@ -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", diff --git a/solution/backend/services/story_service.py b/solution/backend/services/story_service.py index 56604b1..2da9936 100644 --- a/solution/backend/services/story_service.py +++ b/solution/backend/services/story_service.py @@ -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: diff --git a/solution/shared/src/lib/section-data.ts b/solution/shared/src/lib/section-data.ts index f8d885f..9147a0d 100644 --- a/solution/shared/src/lib/section-data.ts +++ b/solution/shared/src/lib/section-data.ts @@ -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; } diff --git a/solution/shared/src/lib/section-prompts.ts b/solution/shared/src/lib/section-prompts.ts index 73f7800..284f94a 100644 --- a/solution/shared/src/lib/section-prompts.ts +++ b/solution/shared/src/lib/section-prompts.ts @@ -139,6 +139,12 @@ export const SECTION_PROMPTS: Record = { · 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다. · 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다. · 해시태그는 3개까지. 지역명 하나는 반드시 넣는다. +· **같은 대상을 두 번 쓰지 않는다.** 열두 장이 전부 다른 곳·다른 이야기여야 한다 — + 한 시설의 주소·휴관일·운영시간을 나눠 적으면 엽서가 아니라 안내문 열두 장이 된다 + (실측 2026-09-10: 네 장이 같은 박물관의 관람안내였다). +· 운영시간·휴관일·주소·요금은 엽서에 적지 않는다. 그건 이용 정보지 엽서 문장이 아니다. +· place 는 사진을 찾는 열쇠다. **찾아갈 수 있는 곳 이름을 정확히** 적는다 — + 이 값으로 공공데이터에서 앞면 사진을 찾고, 못 찾으면 그 엽서는 실리지 않는다. `, }, diff --git a/solution/site/src/sections/items/ChronicleSection.tsx b/solution/site/src/sections/items/ChronicleSection.tsx index 7189dfe..a81f3a3 100644 --- a/solution/site/src/sections/items/ChronicleSection.tsx +++ b/solution/site/src/sections/items/ChronicleSection.tsx @@ -67,7 +67,7 @@ export function ChronicleSection() {

{item.title}

{item.summary &&

{item.summary}

} {item.place &&

지금 이 자리 · {item.place}

} - + diff --git a/solution/site/src/sections/items/PeopleSection.tsx b/solution/site/src/sections/items/PeopleSection.tsx index 47154a5..0c69c75 100644 --- a/solution/site/src/sections/items/PeopleSection.tsx +++ b/solution/site/src/sections/items/PeopleSection.tsx @@ -38,6 +38,7 @@ export function PeopleSection() { {parsed.items.map((person, index) => (
+ {/* 사진 출처는 카드 아래 SourceLine 옆에 붙는다(아래) — 라이선스 조건이다. */} {person.imageUrl ? ( {person.oneLine &&

{person.oneLine}

}
- +
))} diff --git a/solution/site/src/sections/items/PostcardSection.tsx b/solution/site/src/sections/items/PostcardSection.tsx index 858a107..720b8e8 100644 --- a/solution/site/src/sections/items/PostcardSection.tsx +++ b/solution/site/src/sections/items/PostcardSection.tsx @@ -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 && ( +

+ 사진 · {card.imageCredit || '출처 표기 없음'} +

+ )}
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
diff --git a/solution/site/src/sections/items/common.tsx b/solution/site/src/sections/items/common.tsx index c4690da..ac40b9f 100644 --- a/solution/site/src/sections/items/common.tsx +++ b/solution/site/src/sections/items/common.tsx @@ -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 (

{source?.name && ( @@ -209,6 +222,7 @@ export function SourceLine({source, verified}: {source?: DataSource; verified?: )} )} + {imageCredit && 사진 · {imageCredit}}

); }