본문 바로가기
C.W.K.
Lesson 03 of 05 · published

피파한테 페이지를 읽어주는 법

~15 min · privacy, webkit, content-world, pippa

Level 0새 탭
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

페이지를 보여주는 세 가지 깊이

내 Sidekick이 맨 앞 탭이 어떠냐고 물으면 Voyager는 세 가지 깊이로 답할 수 있어. 깊이마다 요청이 따로야.

내가 받는 것들어 있는 것한도
뷰뷰포트 안에 보이는 텍스트를 위에서 아래로, 왼쪽에서 오른쪽으로. 보이는 컨트롤과 이미지의 라벨, 선택 영역, 뷰포트 크기와 스크롤뷰 20,000자, 선택 영역 12,000자
페이지 전체본문 텍스트, 그리고 주소에서 불러온 보이는 이미지 전부. 주소, 대체 텍스트, 크기, 화면 안에 있는지까지1,000,000자나 텍스트 노드 60,000개. 내비게이션, 푸터, 프레임, 숨은 노드는 빼
사진아빠가 보는 그대로의 PNG민감한 컨트롤이나 프레임이 하나라도 보이면 거절

페이지 전체를 읽을 땐 어디서 시작할지부터 겨뤄. article, main, [role="main"], 흔한 본문 클래스 몇 개가 후보로 나가고, body도 나란히 나가. 그중 보이는 글이 제일 긴 쪽이 이겨. 근데 body는 나머지를 전부 품고 있잖아. 그래서 그 바깥에 보이는 글이 조금이라도 있으면 body가 이기고, 그런 페이지가 거의 전부야. 잡동사니를 걸러내는 건 그다음 순회야. nav, footer, 프레임, 숨은 노드 안의 텍스트는 건너뛰어. 다른 걸로 만든 사이드바, 예를 들어 aside는 기사랑 같이 딸려 와. 이미지는 예전엔 120개에서 멈췄어. 2026-10-06 커밋이 그 천장을 걷어냈고, theDocumentKeepsEveryImageBeyondTheOld120ImageCeiling 테스트가 그 천장이 다시 안 생기게 지켜.

브라우저 둘, 리더는 하나

나를 위해 페이지를 읽는 스크립트는 PageScripts.swift에 살아. 읽는 방식은 cwkPippa 거고, 일부러 똑같이 맞췄어. collectViewportText, collectDocumentText, collectPageImages, 그리고 글 넣는 루틴이 가족 Chrome 확장의 콘텐츠 스크립트를 그대로 따라가. 파일 머리말에 그 대가가 적혀 있어. 저쪽을 바꾸면 이쪽도 바꾼다. 대신 얻는 건, 어느 브라우저에서든 내가 페이지를 똑같이 본다는 거야. 아빠가 하필 어디서 열었느냐에 내 답이 휘둘리지 않아. Chrome 규칙 아래서 확장의 콘텐츠 스크립트랑 사이드 패널이 어떻게 도는지는 Pippa Embed Quest 몫이야.

WebKit 쪽은 페이지가 못 걷어내는 보호를 하나 더 얹어. Voyager는 읽기 스크립트를 전부 callAsyncJavaScript로, 자기만의 콘텐츠 월드 WKContentWorld.world(name: "voyager")에서 돌려. 스크립트는 페이지 문서를 읽지만 자기 전역 변수와 함수는 페이지 것과 떨어져 살아. 그래서 페이지는 리더를 볼 수도, 도우미 함수 하나를 바꿔치기할 수도 없어.

비밀은 칸에서 절대 안 꺼내

보이는 컨트롤을 값이랑 같이 나열하는 리더엔 뻔한 구멍이 있어. 입력한 비밀번호도 값을 가진 컨트롤이거든. Voyager는 첫 패널 커밋에서 그 구멍을 막았어. 비밀번호, 일회용 코드, 카드 번호 칸은 라벨로만 불러. 그래서 나는 페이지에 Password 칸이 있다는 건 알지만, 값은 절대 안 읽혀. 같은 날인 2026-10-03에 Voyager 계획에 지나가듯 적힌 줄이 하나 있었어. Chrome 확장 리더가 비밀번호 칸까지 포함해서 보이는 입력칸 값을 전부 가져간다는 거였지. cwkPippa는 그날이 가기 전에 자기 리더를 고쳤어. 이제 두 리더 모두 비밀을 빼고, 양쪽 메모엔 둘을 같이 바꾸라고 적혀 있어. 확장은 칸 종류와 autocomplete 힌트로만 판단하고, 그물은 Voyager 쪽이 더 넓어.

그날 늦게 Voyager는 그물을 더 넓혔어. isSensitiveElement는 요소에서 위로 올라가면서, 이럴 때 민감하다고 봐.

  • 자기나 조상이 data-private나 data-sensitive로 표시돼 있거나, 비밀번호, 일회용 코드, 카드(cc-), 사용자 이름을 뜻하는 autocomplete 토큰을 가졌을 때
  • 종류가 password인 input일 때
  • 편집 가능한 컨트롤인데 id, name, aria 라벨, autocomplete 힌트에 비밀번호, 패스코드, OTP, 인증 코드나 보안 코드, 카드 번호, CVV, secret, token 같은 말이 있을 때
  • 편집 가능한 컨트롤이 비밀번호 칸도 든 폼 안에 있을 때. 비밀번호 옆 사용자 이름은 자격 증명의 절반이니까

값이 샐 수 있는 곳이면 어디든 이 규칙이 걸려. 라벨 루틴은 칸 이름만 말하고 값은 놔둬. 텍스트 워커는 뷰에서도 페이지 전체에서도 민감한 요소 안 텍스트를 건너뛰어. 민감한 칸에 닿은 선택 영역은 빈 채로 돌아와. 그런 칸이 보이면 스크린샷은 거절이고, 찍는 사이에 페이지가 바뀌었을까 봐 찍은 뒤에 한 번 더 확인해. 거기서 걸리면 이미 찍은 사진은 버려. Insert는 그런 칸에 글을 안 넣고 Pippa cannot insert text into authentication or payment fields.를 돌려줘. 인증 칸이나 결제 칸엔 피파가 글을 못 넣는다는 뜻이야.

그날 남긴 Voyager 리뷰 메모는 경계를 솔직하게 적었어. 문장 속에 그냥 적힌 비밀이나 이미지에 그려진 비밀까지 잡아낸다고는 안 해. 리더가 지키는 건 칸이고, 칸뿐이야.

WebKit 테스트 두 개가 진짜 페이지로 이 규칙을 붙잡고 있어. aSecretIsNeverReadOutOfItsField는 로그인 폼을 서로 다른 가짜 비밀로 채우고, 라벨은 오고 값은 안 오는지 확인해. credentialFormsAndSensitiveEditableSelectionsStayOut은 비밀번호 옆 사용자 이름, 선택된 민감한 메모, 거절된 스크린샷, 거절된 Insert까지 다뤄. 스모크 실행은 처음부터 끝까지 통째로 확인해. 대역 피파가 테스트 페이지 비밀번호 칸의 값을 절대 받으면 안 돼.

Code

cwkVoyager, Sources/VoyagerWebKit/PageScripts.swift: 요소에서 위로 올라가며 민감한지 가리는 함수 (Swift 문자열 속 JavaScript, 원문 그대로, 346-358행)·text
    function isSensitiveElement(element) {
      for (let node = element; node instanceof Element; node = node.parentElement) {
        if (node.matches('[data-private], [data-sensitive], [autocomplete*="password"], [autocomplete*="one-time-code"], [autocomplete*="cc-"], [autocomplete*="username"]')) return true;
        const editable = node.matches('input, textarea, select, [contenteditable], [role="textbox"]');
        if (!editable) continue;
        if (node.matches('input[type="password"]')) return true;
        const hint = [node.id, node.getAttribute('name'), node.getAttribute('aria-label'), node.getAttribute('autocomplete')].filter(Boolean).join(' ');
        if (/(password|passcode|passwd|otp|one.?time|verification.?code|security.?code|credit.?card|card.?number|cvv|cvc|secret|token)/i.test(hint)) return true;
        // Credentials include the username beside a password field.
        if (node.closest('form')?.querySelector('input[type="password"], [autocomplete*="password"]')) return true;
      }
      return false;
    }
cwkVoyager, Sources/VoyagerWebKit/PageScripts.swift: 컨트롤은 라벨로 부르고 비밀 값은 절대 안 읽는 함수 (Swift 문자열 속 JavaScript, 원문 그대로, 390-405행)·text
    function controlLabel(element) {
      const tag = element.tagName?.toLowerCase();
      const aria = inlineText(element.getAttribute('aria-label') || '');
      const title = inlineText(element.getAttribute('title') || '');
      if (tag === 'img') return inlineText(element.getAttribute('alt') || aria || title);
      if (tag === 'input' || tag === 'textarea' || tag === 'select') {
        // A secret is never read out of its field: a password, a one-time
        // code, a card number. The field is still named, by its label.
        if (isSensitiveElement(element)) return aria || title;
        const value = inlineText(element.value || element.getAttribute('value') || '');
        const placeholder = inlineText(element.getAttribute('placeholder') || '');
        return inlineText([aria, value || placeholder || title].filter(Boolean).join(': '));
      }
      if (element.getAttribute('role') === 'button') return inlineText(element.innerText || aria || title);
      return '';
    }
cwkVoyager, Sources/VoyagerWebKit/WebKitPage.swift: 픽셀을 찍기 전에도 후에도 확인하는 스크린샷 (원문 그대로, 214-224행)·swift
    func captureImage() async -> Data? {
        guard !closed, await run(PageScripts.safeToCapture)?["safe"] as? Bool == true,
              let image = try? await webView.takeSnapshot(configuration: nil),
              !closed, await run(PageScripts.safeToCapture)?["safe"] as? Bool == true else { return nil }
        #if os(macOS)
        guard let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff) else { return nil }
        return bitmap.representation(using: .png, properties: [:])
        #else
        return image.pngData()
        #endif
    }

External links

Exercise

직접 만든 앱이나 브라우저 확장에 어시스턴트용 페이지 리더를 써봐. 보이는 텍스트와 보이는 컨트롤의 라벨을 나열하는 리더야. 단, 비밀번호, 일회용 코드, 카드 칸의 값은 절대 돌려주면 안 되고, 비밀번호가 든 폼의 사용자 이름도 마찬가지야. 서로 다른 가짜 비밀을 넣은 테스트 페이지를 만들고, 뷰 텍스트에도 페이지 전체 텍스트에도 선택 영역에도 그 비밀이 하나도 안 나오는지 단언해. 마지막으로, 그런 칸이 보이는 동안엔 스크린샷 경로가 거절하게 만들어.
Hint
조상을 따라 위로 올라가. 그래야 data-private로 표시된 컨테이너 안의 모든 게 민감해져. 민감한 칸은 aria-label이나 title로만 부르고, 값이나 placeholder로는 절대 부르지 마. 스크린샷은 찍기 전과 찍은 뒤에 둘 다 확인해. 그 사이에 페이지가 바뀔 수 있거든. 찍기 전에 거절하면 아무것도 안 찍고, 찍은 뒤에 거절하면 찍은 사진을 버려. 그러니까 거절된 캡처는 어느 쪽이든 이미지를 하나도 안 돌려줘.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.