# 핀노트(Pinnote) 설계 문서 > 어떤 웹사이트에든 스크립트 한 줄로 붙인다. 사람들은 화면 요소에 핀을 꽂아 의견을 남긴다. 여러 사이트의 의견은 한곳에 모인다. > 개발자(사람이나 AI)가 "어느 사이트·화면·요소에 대한 의견인지" 정확히 알고 고친다. 고친 뒤에는 건마다 완료 응답을 남긴다. > 요소가 바뀌거나 사라져도 의견을 잃지 않는다. - 주소: https://pinnote.itemview.co.kr (관리 `/admin/`, 데모 `/demo/`, 문서 `/docs/`) - 첫 적용 대상: 뉴팅 데모 `https://imgx.kr` --- ## 1. 비슷한 서비스 조사 2026-09-16에 각 서비스의 공식 문서와 도움말을 확인했다. 확인하지 못한 내용은 **[미확인]**으로 표시한다. | 서비스 | 요소 고정 방식 | 상태 흐름 | 개발자 연동 | 보고자 순환 | 개인정보 | |---|---|---|---|---|---| | BugHerd | 요소에 핀. 기능 소개에 "CSS selector" 수집이 적혀 있음. 페이지 URL로 묶어서 SPA는 `BugHerdConfig.location`을 직접 갱신해야 함. 요소가 사라질 때 동작 [미확인] | Backlog·Todo·Doing·Done·Closed(열 편집 가능) | REST API v2, 웹훅, Jira·GitHub·Linear 등 양방향, 공식 MCP 서버 | 게스트에게 "완료" 메일. 공개 위젯으로 온 익명 보고자에게는 알림 없음 | 세션 리플레이 [미확인] | | Marker.io | 스크린샷 중심(주석 달린 캡처 + 콘솔·네트워크·환경). 선택자는 메타데이터로만 있음. 실제 페이지에 다시 붙이지 않음 | Open·In Progress·Waiting for Approval·Resolved·Closed | **공개 REST API 없음**. 웹훅, 이슈 트래커 동기화, MCP(베타, 스크린샷 서명 URL 제공) | Resolved가 되면 보고자에게 메일 | `.mk-mask`·`.mk-exclude`로 캡처 전에 브라우저에서 가림. 비밀번호는 항상 가림 | | Userback | 스크린샷 + URL + 요소 강조 | 사용자 정의. 처음(Open)과 끝(Resolved)은 고정 | REST API, 웹훅, JS SDK, MCP | "Resolved & Auto-reply". 보고자가 답하면 다시 열림 | 이메일·비밀번호 입력 기본 가림, `.userback-block` | | Usersnap | 스크린샷 위 핀. 스크린샷은 서버에서 렌더링 | open 등 [전체 목록 미확인] | 게이트된 REST API(JWT), 커서 페이지 나누기, HMAC 서명 웹훅 | [미확인] | `usprivacy` 클래스는 렌더러로 보내지 않음 | | Pastel | 프록시 캔버스에서 요소에 핀 + 스크린샷. 사이트가 바뀌면 "버전"을 새로 만들고 옛 버전은 읽기 전용 | Active·In Progress·In Review·Resolved | Trello·Jira 내보내기, 웹훅, 에이전트용 MCP | 상태가 바뀔 때마다 작성자에게 알림 | 리플레이 없음 | | Ruttl | 요소를 골라 댓글. 버전마다 댓글 이력 유지. 페이지 승인 기능 | 칸반 [상태 이름 미확인] | API·웹훅·Jira 등 | 링크로 게스트 댓글 | [미확인] | | Hotjar Feedback | 요소 강조 + 스크린샷. 2024-09에 Surveys로 합쳐짐 | 작업 흐름 없음(분석 도구) | API·웹훅 | 없음 | `data-hj-suppress` 기본 입력 가림 | 출처: - BugHerd: bugherd.com/features, support.bugherd.com/en/articles/12812946 (SPA location), updates.bugherd.com (게스트 알림), bugherd.com/feature/mcp - Marker.io: help.marker.io/en/articles/6572843 (공개 API 없음), /3738778 (웹훅), /9657817 (가림), /14034657 (MCP) - Userback: support.userback.io/en/articles/5209239 (작업 흐름), /5762135 (리플레이·가림), docs.userback.io - Usersnap: help.usersnap.com/reference/rest-api, /docs/webhook, /docs/privacy-and-security-faq - Pastel: help.usepastel.com/en/articles/5721890 (버전), /5324362 (상태), usepastel.com/agents - Ruttl: ruttl.com/features/comment-on-website - Hotjar: help.hotjar.com (suppress) ### 배운 점 1. **요소를 찾는 방법을 하나만 저장하면 핀이 떠돈다.** 어떤 서비스도 "다시 찾는 방법"을 공개하지 않았다. 경쟁사는 BugHerd의 핀이 떠돈다고 공격한다([미확인] 주장). 그래서 찾는 방법을 여러 겹 저장한다. 어떤 방법으로 찾았는지와 신뢰도도 남긴다. 2. **"못 찾음"은 조용히 넘기면 안 되는 상태다.** 좌표로 대충 붙이면 엉뚱한 요소에 의견이 달린다. 차라리 "사라진 요소" 목록에 모은다. 원래 모습(캡처)을 보여 주고 사람이 [다시 연결]하게 한다. 3. **처음 모습(스크린샷·HTML)이 기준 자료다.** Marker·Usersnap·Pastel·Userback 모두 캡처를 붙인다. AI 연동(MCP)도 결국 "선택자 + 스크린샷"을 넘긴다. DOM이 바뀐 뒤에도 의견의 뜻을 살리는 것은 캡처다. 4. **페이지는 URL이 아니라 "화면 키"로 묶는다.** BugHerd의 SPA 문제가 그 증거다. 경로 규칙(`/t/:token`)으로 정규화하고, 앱 버전(`data-version`)을 함께 저장한다. 그래야 "요소가 바뀐 것"과 "다른 화면인 것"을 구분할 수 있다. 5. **상태는 짧게 두고, 처음과 끝을 고정하고, 모든 전이를 기록한다.** Marker·Pastel·Userback은 해결되면 보고자에게 알리지만, 우리는 알리지 않는다(처리 시점 추적 방지, §6). 6. **가림은 캡처 전에 브라우저에서 한다.** `.mk-mask`나 `data-hj-suppress`처럼 클래스·속성으로 받는다. 입력값은 기본으로 가린다. 7. **웹훅만으로는 부족하다. 진짜 REST API와 AI가 바로 읽을 문서가 필요하다.** Marker의 가장 큰 약점이 공개 API가 없다는 점이다. 우리는 토큰 REST, CLI, 사이트→화면→요소→의견 마크다운 내보내기를 기본으로 둔다. --- ## 2. 전체 구조 ``` 워크스페이스 ─ 프로젝트 ─ 사이트(허용 도메인, 공개 키 pk_…, 환경 demo/staging/prod, 경로 규칙) └ 페이지(정규화된 화면 키) └ 요소 앵커(찾는 방법 여러 겹 + 원래 모습 + 상태) └ 의견(아이템) ─ 댓글 · 공감 · 첨부(요소 캡처) └ 히스토리(모든 변경) ``` - **서버**: PHP 8.2 + MariaDB. 프레임워크·빌드 도구 없음. `lib/` 도메인 로직, `public/` 웹 루트. - **위젯**: 순수 JS. `/w.js`는 캐시하지 않는 약 1KB 로더다. 로더가 버전이 붙은 본체 `/widget/pinnote.js?v=…`를 불러오고, 본체는 1년 캐시한다. Cloudflare가 `.js` 캐시 시간을 4시간으로 늘려 버리는 문제를 이렇게 피했다. - **API 두 갈래** - 위젯 API `/api/w/*`: 공개 키 + Origin 검사, 쿠키 없음 - 개발자 API `/api/v1/*`: 토큰 - 권한 모델과 한도가 다르므로 나눴다. ### 데이터 표 (db/migrations/001_init.sql) | 표 | 역할 | |---|---| | workspaces, projects, sites | 구조. `sites.domains`·`page_rules`·`query_keys`·`capture_mode`·`missing_visits/days`·`rate_per_hour` | | members | 관리자 이름 + 역할(OWNER/ADMIN/MEMBER/VIEWER). 지금은 VIEWER만 쓰기 금지 | | pages | `(site_id, page_key)` 유일. 제목·예시 주소·방문 수 | | anchors | 찾는 방법(`ui_key`, `elem_id`, `selector`, `dom_path`, `tag`, `role`, `aria_name`, `text_fp`, `landmark`, `rel_x/y`, `locators` JSON), 원래 모습(`snapshot_html/style/rect`, `shot_id`), 상태(`status`, `miss_count`, `first_missed_at`, `last_visit_id`, `relocated_count`, `missing_resolution`) | | items | 종류·상태·중요도·본문·보고자·담당자, 문맥(URL·버전·환경·화면 크기·스크롤·기기·언어·JS 오류·사용자 문맥), 완료 응답(`resolution_note`, `resolved_version`, `resolved_ref`), `status_reason`, `duplicate_of`, 공감·댓글 수, `client_uid`(재전송 중복 방지) | | item_labels, comments, votes, attachments | 라벨, 댓글, 공감(보고자당 1), 캡처 파일(웹 루트 밖 `storage/shots`) | | reporters | 브라우저 비밀 키의 HMAC 해시 + 이름. 원문 키는 저장하지 않는다 | | history | 감사 기록: entity(item/anchor/site/token), action, 이전→이후, 메모, data, 작업자(MEMBER/REPORTER/API/CLI/WIDGET/SYSTEM + 이름), request_id | | api_tokens | 토큰 해시만 저장(원문은 만들 때 한 번만 보여 줌), read/write | | rate_limits, activity_logs | 시간당 제한 칸, 요청·보안·오류 기록 | --- ## 3. 수집하는 구분자 | 층 | 값 | 어디서 | |---|---|---| | 사이트 | 공개 키 → 사이트, Origin 도메인 | 설치 한 줄 `data-site` | | 페이지 | 전체 URL, 정규화된 화면 키, 제목 | 위젯 → 서버가 키 계산 | | 앱 | `data-version`, `data-env` | 설치 한 줄 | | 요소 | 아래 "찾는 방법" 9가지 | 위젯 | | 원래 모습 | outerHTML 앞 3500자(가림), 주요 계산 스타일 18개, 크기·위치, 요소 주변 캡처(JPEG) | 위젯 | | 환경 | 화면 크기@DPR, 스크롤, UA, 언어, 최근 JS 오류 5개 | 위젯 | | 사용자 문맥 | `Pinnote.setContext({screen, journey_id, …})` 최대 30개 키 | 사이트 코드 | ### 화면 키 정규화 (`lib/site.php` `page_key_for`) 1. 경로만 쓴다. 해시 라우팅(`#/…`)은 경로로 본다. `index.php`와 끝 슬래시는 뗀다. 2. 사이트 경로 규칙에서 앞에서부터 처음 맞는 규칙이 키가 된다. `:이름`은 한 조각, `*`은 나머지 전부와 맞는다. 예: `/t/abc123` → `/t/:token`. 3. 맞는 규칙이 없으면 숫자·UUID·20자 넘는 토큰 조각을 `:id`로 바꾼다. 4. `query_keys`에 적힌 쿼리만 정렬해서 붙인다. 나머지 쿼리(`pinnote_focus`, 추적 파라미터 등)는 버린다. ### 요소를 찾는 방법 (저장, `widget/pinnote.js` `makeLoc`) 1. `data-ui` 속성: 사이트가 붙이는 **안정 식별자**. 1순위다. 2. id: `\d{4,}`, 해시, `react`·`radix` 같은 자동 생성 id는 제외한다. 3. 짧은 CSS 선택자: 가까운 `data-ui`/id 조상 + `tag.class` / `:nth-of-type`. 유일해질 때까지 최대 6단계. 상태 클래스(`active`, `is-*`, `css-*` 해시)는 제외한다. 4. 전체 DOM 경로: `html > body > main:nth-of-type(1) > …` 5. 태그·역할·접근성 이름: aria-label, labelledby, label[for], alt, title, placeholder, 버튼 텍스트 6. 텍스트 지문: 공백을 정리한 innerText 앞 120자 7. 가까운 기준 요소: 가장 가까운 section/form/nav/…/[data-ui] 안의 첫 제목. 예: `h2 "장바구니"` 8. 페이지 기준 좌표 비율 `rx, ry`: 요소 중심 / 문서 크기 9. 보조 속성: 안정 클래스 최대 8개, href 경로, name, type, placeholder, alt, src 파일명 --- ## 4. 요소가 바뀌거나 사라질 때 ### 다시 찾기 (위젯 `findAnchor`) - **1순위(ATTACHED)**: 저장된 가장 강한 식별자가 그대로 맞는 경우다. data-ui가 있으면 data-ui, 없으면 id, 그것도 없으면 선택자(태그가 같고 텍스트 유사도 ≥ 0.5)를 본다. - **보조(RELOCATED)** - 선택자나 DOM 경로가 유일하게 맞고, 태그가 같고, 텍스트 유사도 ≥ 0.8이면 찾은 것으로 본다. - 그래도 안 되면 같은 태그 후보(최대 3000개)에 점수를 매긴다. - 텍스트 같음 45 / 비슷함 30 - 접근성 이름 20 - 속성 최대 24 - 클래스 겹침 15 - 기준 요소 15 - 역할 5 - 좌표 거리 10 - 최고 점수가 55 이상이고 2등보다 10점 이상 높을 때만 인정한다. **애매하면 못 찾음으로 본다.** 엉뚱한 요소에 붙이는 것보다 낫다. - **못 찾음**: 늦게 그려지는 화면을 고려해 1.2초 뒤 한 번 보고, 없으면 2.5초 뒤 한 번 더 본다. ### 보고와 상태 전이 (서버 `anchor_report`) - 위젯은 방문(visit id)마다 결과를 **한 번에 묶어** 보낸다. 같은 방문에서 온 중복 보고는 무시한다. - **표본 추출**: 서버는 앵커마다 `check`를 내려 준다. 상태가 ATTACHED이고, 최근 60초 안에 누군가 확인했고, 못 찾은 적이 없는 건강한 앵커는 보고를 건너뛴다. 핀은 그래도 그린다. - `primary` → ATTACHED. 못 찾음 횟수를 0으로 되돌린다. 이전 상태가 달랐다면 히스토리에 "다시 찾음"을 남긴다. - `fallback` → RELOCATED - **새 찾는 방법으로 갱신**하고 히스토리에 이전→이후 선택자와 점수를 남긴다. - 다음 방문에 새 식별자로 1순위에 맞으면 ATTACHED로 돌아간다. 위치 갱신 횟수는 남는다. - `none` → `miss_count` +1. **방문 N번(기본 3) 연속** 못 찾거나, 처음 못 찾은 뒤 **N일(기본 3)**이 지나면 MISSING이 된다. 사이트마다 바꿀 수 있다. - 원래 모습(snapshot, 첫 캡처)은 절대 덮어쓰지 않는다. ### 사라진 요소 목록 (`/admin/missing.php`) - 기본으로는 **열린 의견이 있는** MISSING 요소만 보여 준다. 해결된 뒤 요소가 사라지는 것은 정상이므로 "해결 뒤 사라진 것도 보기"에서만 나온다. - 카드에는 캡처 이미지, 격리된 iframe에 넣은 원래 HTML(스크립트 금지 sandbox + CSP), 선택자·텍스트·기준 요소, 의견 목록을 보여 준다. - 조치 - **[수정으로 해결됨]**: 완료 응답이 필수다. 열린 의견을 모두 RESOLVED로 바꾸고 앵커는 ARCHIVED(`missing_resolution=FIXED`)가 된다. - **[다시 연결]**: 서명된 24시간 링크 `?pinnote=1&pinnote_relink=앵커.만료.서명`으로 실제 페이지를 연다. 위젯이 재지정 모드로 들어가 원래 모습을 보여 준다. 새 요소를 누르고 [연결]하면 앵커가 ATTACHED가 된다(`RELINKED`, 히스토리에 이전→이후). - **[보관]**: ARCHIVED가 되고 더 이상 찾지 않는다. - API로 선택자를 직접 지정할 수도 있다: `POST /api/v1/items/{id}/relink {"selector": "..."}` --- ## 5. 상태 흐름 ``` NEW ─┬→ TRIAGED ─┬→ IN_PROGRESS ─┬→ RESOLVED ─┬→ VERIFIED ─→ REOPENED │ │ │ (완료 응답 필수)└→ REOPENED ─→ (TRIAGED/IN_PROGRESS/…) ├→ NEEDS_INFO (메모) │ ├→ WONT_FIX (사유 필수) ─→ REOPENED └→ DUPLICATE (원본 번호 필수) ─→ REOPENED ``` - 허용되는 이동은 `lib/item.php` `PN_TRANSITIONS` 한곳에 정의한다. 관리 화면·REST·CLI·위젯이 모두 같은 함수(`item_transition`)를 쓴다. - **RESOLVED**: `note`(무엇을 어떻게 고쳤는지)가 없으면 422 `RESOLUTION_REQUIRED`. 버전과 커밋 참조는 선택. - **WONT_FIX**: 사유 필수. **DUPLICATE**: 같은 사이트의 다른 의견 번호가 필수이고, 서로를 가리키는 순환은 금지. - **동시성**: 트랜잭션 안에서 `FOR UPDATE`로 현재 상태를 다시 읽는다. 다르면 409 `CONFLICT`. - **히스토리**: 등록·상태·중요도·담당자·종류·라벨·댓글(id만, 내용 복사 안 함)·공감·캡처·요소 상태·위치 갱신·다시 연결·보관·사이트 설정·토큰. 누가(유형+이름), 언제(ms), 이전→이후, 메모, 부가 데이터를 남긴다. - 의견 상세 타임라인은 그 의견의 기록과, 의견이 달린 요소의 기록(등록 이후)을 합쳐 보여 준다. --- ## 6. 보고자 (로그인 없음) - **보고자에게 처리 시각을 보여 주지 않는다(작업 시간 노출 방지).** 위젯 API는 의견·댓글의 작성·처리 시각(`created_at`·`updated_at`·`resolved_at`·`verified_at`)을 응답에 넣지 않고, 위젯도 "N분 전"을 그리지 않는다. 관리 화면·REST·CLI는 시각을 그대로 보여 준다. - **보고자 알림·보고자 확인은 작업 시점 추적이 가능해 제거함.** 예전의 "내가 남긴 의견 N건이 처리됐어요" 알림(`init`의 `notices`)과 위젯의 [확인했어요]/[아직이에요](`/api/w/verify`)는 없다. VERIFIED·REOPENED는 관리 화면·REST·CLI에서만 바꾼다. - 위젯은 `localStorage`에 32자 비밀 키(rk)를 만든다. 서버는 HMAC 해시만 저장한다. 이 키는 **의견 작성자 구분(내 의견 표시)과 공감 중복 방지에만** 쓴다. 이름은 한 번 적으면 기억한다. - **이 키는 사이트 도메인마다 따로다.** 브라우저 저장소가 출처마다 나뉘기 때문이다. 제3자 저장소 분할 때문에 사이트를 넘나드는 식별은 하지 않는다. --- ## 6-1. 위젯 열기·닫기 (데모 운영) - 데모가 끝나면 관리 화면(첫 화면 위젯 줄·사이트 목록)의 **[위젯 닫기]**, 다음 데모 때 **[위젯 열기]**. `sites.is_active` 토글 + 히스토리(`widget_closed`/`widget_opened`, 누가). - 닫힌 사이트의 위젯 API는 어떤 동작이든 `200 {closed:true}`만 준다(허용 도메인 검사는 먼저 한다). 위젯은 이것을 받으면 **버튼·핀·토스트를 그리지 않고, 콘솔 출력·재시도·보관함 재전송도 하지 않는다.** 보관함(localStorage)의 전송 대기 의견은 지우지 않고 다음에 열렸을 때 보낸다. - 닫아도 의견·앵커·히스토리·스냅샷은 지우지 않는다. 다시 열면 상태·완료 응답이 그대로 보인다. ## 7. 위젯 - **격리** - ``(크기 0, fixed)를 `` 끝에 붙이고 그 안의 Shadow DOM에만 그린다. - 스타일은 생성형 스타일시트(`adoptedStyleSheets`)로 넣는다. 그래서 호스트 CSP가 인라인 style을 막아도 동작한다. 지원하지 않는 브라우저에서는 `