사장님이 후보를 고른 뒤에도 "네이버 플레이스를 자동으로 찾지 못했습니다" 가 떴다.
찾을 수 있는데도 그랬다 — 자동 발견이 확정 경로(로그인 뒤)에만 있었고, 공개 검색은
상호·주소만 돌려줬다. 그리고 확정이 사업장 생성을 요구해서, 로그인 없이 시작하기로 한
위저드가 검색 직후부터 막혔다(자동 로그인이 그걸 가리고 있었다).
- place_service.search_places_public: 응답에 naver_place_url 을 싣는다. 넓은 검색어
한 페이지에서 못 찾은 후보는 그 후보만 겨냥해 다시 찾는다(상위 2건, 429 회피).
실측: 12개 상호 전부 발견. 전에는 4개 중 2개
- naver_place_lookup._region_hint: 주소에서 시·군·구까지만 뽑아 검색을 좁힌다.
첫 토막('경기도')만 쓰면 **다른 동네 동명 업소**가 잡히고, 그 id 로 검증하면 남의
가게가 이 사이트의 기준 정보가 된다 — 실측으로 한 번 겪었다
- ttl_cache(신규) + 공개 검색 10분 캐시: 검색 1회가 네이버를 최대 3번 긁는데 인증이
없어 새로고침만으로 나간다. 실측 1.38s → 0.005s. **빈 결과는 캐시하지 않는다** —
일시적 0건을 굳히면 사장님이 10분간 막힌다
- 확정은 서버를 부르지 않는다(usePlaceSearch). 화면에만 남기고, 수집 직전 로그인 뒤
ensureServerPlace 가 생성 → 검증을 한 번에 한다. 나눠 두면 "사업장은 생겼는데 검증이
빠진" 상태가 생기고 수집이 PLACE_NOT_VERIFIED 로 조용히 거절된다
- Step3: 수집 버튼이 로그인 모달을 연다(/login 으로 튕기지 않는다 — 위저드 상태가
주소창에 없어 돌아올 길이 없다). 로그인하면 이어서 돈다
- Step2: 후보 카드에 '네이버 플레이스 찾음' 배지. 붙여넣기 칸은 접는다 —
펼쳐 두면 시도도 전에 실패한 것으로 읽힌다. 뒤로 오면 처음 화면으로
- ChannelUrlInput: [추가] → [이 주소로 가져오기]. 로그인 전에는 addLink 가 placeId 가
없어 **조용히 return** 해서 입력칸만 비워졌다(useChannelLinks.ts:37)
- LoginPage: admin/1234 기본값 제거. 배포 번들에 그대로 나가 있었다
- 기본 발행 호스트를 localhost 로(compose 4곳 · site_payload.DEFAULT_HOST · .env.example).
운영 도메인을 기본값으로 두면 .env 를 안 채운 로컬 빌드가 조용히 운영 주소를 번들에
굽는다 — 실측: 로컬에서 만든 링크가 킹서버로 갔다. localhost 는 http 로 조립한다
검증: tsc·eslint·vite build 통과. 브라우저로 전 구간 확인(검색 → 확정 → 로그인 →
자동 발견 → 검증 → 수집 fact 27건·사진 10장·메뉴 23건 → 사진 분석).
백엔드 테스트는 이 워크트리에서 못 돌렸다 — config.test.toml 이 없어 DB 인증이 실패한다.
163 lines
7.7 KiB
TypeScript
163 lines
7.7 KiB
TypeScript
import {useState} from 'react';
|
|
import {ExternalLink, Link2, Loader2, MapPin, Search} from 'lucide-react';
|
|
import type {LinkChannel as LinkChannelCode} from '@/api';
|
|
import {LinkChannel} from '@o2o/shared';
|
|
import {Button} from '@/components/ui/button';
|
|
import {Input} from '@/components/ui/input';
|
|
|
|
/**
|
|
* 채널 URL 직접 등록 — **수집의 1순위 경로다.** 폴백이 아니다.
|
|
*
|
|
* ★ 왜 자동 발견보다 이게 먼저인가 (실측 2026-08-27)
|
|
* · Perplexity 는 네이버 플레이스를 **한 건도** 못 찾았다(도플로·버터브루·힐튼 가든 인
|
|
* 서울 강남 전부 야놀자만 물어옴). 도메인 필터를 넓혔더니 이번엔 네이버 도움말 페이지
|
|
* (pages.map.naver.com/useful-tips)를 이 가게 채널로 등록했다. 그래서 기본 경로에서 뺐다.
|
|
* · 상호로 place id 를 자동 해석하는 경로도 4곳 중 3곳만 성공한다('롯데호텔 서울' 실패).
|
|
* · 반면 **네이버 플레이스 URL 만 있으면 100% 된다** — 어댑터가 fact 5~7건 + 사진 10장을
|
|
* 안정적으로 가져온다(힐튼·스테이머뭄·롯데월드에서 검증).
|
|
* 그리고 사장님은 자기 가게 주소를 이미 알고 있다. 추측하게 두는 것보다 물어보는 것이
|
|
* 빠르고 정확하고 공짜다.
|
|
*
|
|
* 그래서 이 컴포넌트는 "네이버 지도 주소"를 전제로 말한다 — 다른 채널도 받지만,
|
|
* 사장님이 따라 할 동작 하나는 네이버 지도에서 주소를 복사하는 것이다.
|
|
*
|
|
* 붙여넣은 URL 은 그 자체로 사장님이 확인한 값이므로 등록과 동시에 확정한다.
|
|
*/
|
|
|
|
/** 호스트로 채널을 판정한다. 서버도 같은 규칙을 쓰지만, 화면이 먼저 라벨을 보여줘야 한다. */
|
|
function guessChannel(url: string): {channel: LinkChannelCode; label: string} | null {
|
|
const u = url.toLowerCase();
|
|
if (u.includes('naver.com') || u.includes('naver.me')) {
|
|
// 블로그·카페는 채널이 아니라 후기다 — 크롤링 대상이 아니므로 받지 않는다.
|
|
if (u.includes('blog.naver') || u.includes('cafe.naver')) return null;
|
|
return {channel: LinkChannel.NAVER_PLACE, label: '네이버 플레이스'};
|
|
}
|
|
if (u.includes('yanolja.com')) return {channel: LinkChannel.YANOLJA, label: '야놀자'};
|
|
if (u.includes('goodchoice.kr') || u.includes('yeogi.com')) {
|
|
return {channel: LinkChannel.GOODCHOICE, label: '여기어때'};
|
|
}
|
|
if (u.includes('instagram.com')) return {channel: LinkChannel.INSTAGRAM, label: '인스타그램'};
|
|
return {channel: LinkChannel.OFFICIAL_SITE, label: '공식 홈페이지'};
|
|
}
|
|
|
|
/** 주소를 어디서 복사해 오는지 — 사장님이 그대로 따라 할 수 있게 동작 단위로 쪼갠다. */
|
|
const HOW_TO = [
|
|
'네이버 지도(또는 네이버 검색)에서 내 가게를 찾습니다',
|
|
'가게 이름 옆 [공유] 를 누릅니다',
|
|
'[URL 복사] 를 눌러 주소를 복사합니다',
|
|
'아래 칸에 붙여넣고 [이 주소로 가져오기] 를 누릅니다',
|
|
];
|
|
|
|
export function ChannelUrlInput({
|
|
onAdd,
|
|
isBusy,
|
|
searchQuery,
|
|
}: {
|
|
onAdd: (url: string, channel: LinkChannelCode, title: string) => Promise<void>;
|
|
isBusy?: boolean;
|
|
/** 이 상호로 네이버 지도를 바로 연다 — 사장님이 새 탭에서 직접 검색어를 치지 않게. */
|
|
searchQuery?: string;
|
|
}) {
|
|
const [url, setUrl] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const guess = url.trim() ? guessChannel(url.trim()) : null;
|
|
const isNaver = guess?.channel === LinkChannel.NAVER_PLACE;
|
|
|
|
const submit = async () => {
|
|
const value = url.trim();
|
|
if (!value) return;
|
|
if (!/^https?:\/\//i.test(value)) {
|
|
setError('https:// 로 시작하는 주소를 붙여넣어 주세요.');
|
|
return;
|
|
}
|
|
if (!guess) {
|
|
setError('블로그·카페 글은 채널이 아니라 후기라 수집 대상이 아닙니다.');
|
|
return;
|
|
}
|
|
setError(null);
|
|
await onAdd(value, guess.channel, guess.label);
|
|
setUrl('');
|
|
};
|
|
|
|
return (
|
|
<div className="rounded-2xl border border-primary/40 bg-primary/5 p-5">
|
|
<p className="flex items-center gap-1.5 text-sm font-bold">
|
|
<MapPin className="size-4 text-primary" />
|
|
네이버 지도에서 내 가게 주소를 복사해 붙여넣어 주세요
|
|
</p>
|
|
<p className="mt-1.5 text-[11px] leading-relaxed text-muted-foreground">
|
|
가장 확실한 방법입니다. 주소 하나만 있으면 <strong>영업시간·전화·편의시설과 사진</strong>
|
|
까지 그대로 가져옵니다.
|
|
</p>
|
|
|
|
{/* ★ 여기에 바로가기가 없으면 사장님은 새 탭을 열고 상호를 다시 타이핑해야 한다.
|
|
"찾아 보세요" 라고만 하고 갈 곳을 안 주면 그 자리에서 멈춘다. */}
|
|
{searchQuery?.trim() && (
|
|
<a
|
|
href={`https://map.naver.com/p/search/${encodeURIComponent(searchQuery.trim())}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-primary/40 bg-background px-3 py-2.5 text-xs font-semibold transition-colors hover:bg-accent"
|
|
>
|
|
<ExternalLink className="size-3.5" />
|
|
<span>네이버 지도에서 '{searchQuery.trim()}' 열기</span>
|
|
</a>
|
|
)}
|
|
|
|
<ol className="mt-3 space-y-1.5">
|
|
{HOW_TO.map((step, index) => (
|
|
<li key={step} className="flex items-start gap-2 text-[11px] leading-relaxed">
|
|
<span className="mt-px flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-[9px] font-bold text-primary-foreground">
|
|
{index + 1}
|
|
</span>
|
|
<span>{step}</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
|
|
<div className="mt-3 flex items-center gap-1.5">
|
|
<Input
|
|
value={url}
|
|
onChange={(e) => {
|
|
setUrl(e.target.value);
|
|
setError(null);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') void submit();
|
|
}}
|
|
placeholder="https://map.naver.com/p/entry/place/1273971279"
|
|
disabled={isBusy}
|
|
/>
|
|
{/* ★ '추가' 가 아니다. 이 버튼은 등록에서 끝나지 않고 **그 주소에서 정보를 가져온다.**
|
|
'추가' 라고 쓰면 사장님은 목록에 한 줄 늘기를 기다리다가 아무 변화가 없어 실패로 읽는다. */}
|
|
<Button size="sm" variant="primary" onClick={() => void submit()} disabled={isBusy || !url.trim()}>
|
|
{isBusy ? <Loader2 className="animate-spin" /> : <Search />}
|
|
<span>{isBusy ? '가져오는 중' : '이 주소로 가져오기'}</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<p
|
|
className={`mt-1.5 text-[11px] leading-relaxed ${
|
|
error ? 'text-destructive' : isNaver ? 'text-primary' : 'text-muted-foreground'
|
|
}`}
|
|
>
|
|
{error ??
|
|
(isNaver
|
|
? '네이버 플레이스 주소입니다 — 이 경로가 가장 많이, 가장 정확하게 가져옵니다.'
|
|
: guess
|
|
? `${guess.label} 주소로 등록됩니다. 네이버 지도 주소가 있으면 그쪽이 더 많이 가져옵니다.`
|
|
: 'naver.me 단축 주소도 됩니다. 야놀자·여기어때·공식 홈페이지 주소도 받습니다.')}
|
|
</p>
|
|
|
|
<p className="mt-2 flex items-start gap-1 border-t border-primary/20 pt-2 text-[10px] leading-relaxed text-muted-foreground">
|
|
<Link2 className="mt-px size-3 shrink-0" />
|
|
<span>
|
|
붙여넣은 주소는 사장님이 확인한 것으로 보고 <strong>바로 수집 대상</strong>이 됩니다.
|
|
수집된 값은 그래도 전부 확인 대기로 들어갑니다.
|
|
</span>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|