핀노트(Pinnote) 설계 문서

어떤 웹사이트에든 스크립트 한 줄로 붙인다. 사람들은 화면 요소에 핀을 꽂아 의견을 남긴다. 여러 사이트의 의견은 한곳에 모인다.
개발자(사람이나 AI)가 "어느 사이트·화면·요소에 대한 의견인지" 정확히 알고 고친다. 고친 뒤에는 건마다 완료 응답을 남긴다.
요소가 바뀌거나 사라져도 의견을 잃지 않는다.

---

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·ResolvedTrello·Jira 내보내기, 웹훅, 에이전트용 MCP상태가 바뀔 때마다 작성자에게 알림리플레이 없음
Ruttl요소를 골라 댓글. 버전마다 댓글 이력 유지. 페이지 승인 기능칸반 [상태 이름 미확인]API·웹훅·Jira 등링크로 게스트 댓글[미확인]
Hotjar Feedback요소 강조 + 스크린샷. 2024-09에 Surveys로 합쳐짐작업 흐름 없음(분석 도구)API·웹훅없음data-hj-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, 경로 규칙)
                             └ 페이지(정규화된 화면 키)
                                 └ 요소 앵커(찾는 방법 여러 겹 + 원래 모습 + 상태)
                                     └ 의견(아이템) ─ 댓글 · 공감 · 첨부(요소 캡처)
                                                    └ 히스토리(모든 변경)

데이터 표 (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)

보고와 상태 전이 (서버 anchor_report)

사라진 요소 목록 (/admin/missing.php)

---

5. 상태 흐름

NEW ─┬→ TRIAGED ─┬→ IN_PROGRESS ─┬→ RESOLVED ─┬→ VERIFIED ─→ REOPENED
     │           │               │ (완료 응답 필수)└→ REOPENED ─→ (TRIAGED/IN_PROGRESS/…)
     ├→ NEEDS_INFO (메모)         │
     ├→ WONT_FIX (사유 필수) ─→ REOPENED
     └→ DUPLICATE (원본 번호 필수) ─→ REOPENED

---

6. 보고자 (로그인 없음)

---

6-1. 위젯 열기·닫기 (데모 운영)

7. 위젯

---

8. 개발자·AI 연동

---

9. 관리 화면

---

10. 보안

위협대응
남의 사이트에서 위젯 API 호출공개 키 + Origin 헤더 필수 + 허용 도메인(와일드카드 *.x) 검사. 보고하는 url의 호스트도 검사. 허용된 Origin에만 CORS 헤더. 쿠키를 쓰지 않음(credentials omit)
스팸·폭주IP 해시(HMAC) 기준 시간당 제한. 사이트의 rate_per_hour에 동작별 배수(의견 ×1, 댓글 ×2, 공감 ×4), 조회·보고 1200, 사이트 전체. 본문 크기 제한(의견 96KB, 캡처 420KB, 나머지 8~64KB), 본문 2000자
잘못된 MISSING 보고방문 id당 1회만 인정, N번·N일 기준, 보고 시간당 제한. 공개 키 보유자가 거짓 보고를 할 수는 있다(한계)
XSS서버 출력은 전부 htmlspecialchars. 위젯은 textContent만 사용. 스냅샷 HTML은 스크립트 없는 sandbox iframe + 내부 CSP로만 보여 줌. 서버에서 script/style/on*·민감 속성을 한 번 더 제거
민감 정보위젯에서 input/textarea 값, select 선택, password, data-*token/key/secret/pass/auth/session/phone/email/card…, [data-pinnote-mask]/.pn-mask, href/src 쿼리를 가림. 전화번호·이메일 문자열도 가림. 서버에서 전화번호를 한 번 더 가림
파일 노출설정·lib·storage·db·bin은 웹 루트 밖. 캡처는 shot.php의 서명 URL(7일) 또는 관리자 로그인으로만 볼 수 있음. 밑줄 파일·숨김 파일 차단
토큰 유출해시만 저장, 원문은 한 번만 표시, 폐기 가능, 읽기 전용 토큰
관리 화면CSRF 이중 제출, 서명 쿠키, 로그인 제한, 보안 기록(activity_logs SECURITY)

---

11. 한계와 다음 할 일

모달(<dialog>) 안에서 쓰기

showModal() 로 연 모달은 브라우저의 최상위 레이어에 떠서, z-index 를 아무리 높여도 그 위에 그릴 수 없다.

게다가 모달이 열려 있는 동안 바깥 화면은 눌리지 않는다(inert). 그래서 위젯 버튼조차 누를 수 없었다.

해결: 열린 모달 안으로 위젯(<pinnote-root>)을 옮긴다.