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

Anchor 3 — Content script가 page sensor

~13 min · content-script, dom, viewport, case-study, v0.2.1

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"ChromeEmbed의 content-script.js가 DOM walk, viewport 안 텍스트 picking, form control과 ARIA label layer, 매 의미 있는 event 마다 SW로 ship. Lesson 3가 일곱 track의 foundation 후 fresh eye로 그 220-줄 file read 하기."

Walker 패턴

Core 추출 loop:

const walker = document.createTreeWalker(
  document.body,
  NodeFilter.SHOW_TEXT,
  {
    acceptNode(node) {
      if (!inlineText(node.textContent)) return NodeFilter.FILTER_REJECT;
      if (!isVisibleElement(node.parentElement)) return NodeFilter.FILTER_REJECT;
      return NodeFilter.FILTER_ACCEPT;
    }
  }
);

TreeWalker가 text node 만 visit. 각자에 acceptNode filter가 parent가 hidden (display:none, visibility:hidden 등) 인 node와 blank 인 node drop. Walker가 budget 으로 wrap: MAX_VISIBLE_TEXT_NODES = 900, MAX_TEXT_NODE_VISITS = 20000. Cap이 병적 page (10MB 텍스트) 가 content script freeze 안 시키게 방지.

Visibility-rect 확인

Walker가 node accept 후 script가 bounding rect가 실제로 viewport와 intersect 하는지 확인:

function visibleTextRect(textNode) {
  const range = document.createRange();
  try {
    range.selectNodeContents(textNode);
    for (const rect of range.getClientRects()) {
      if (rectIntersectsViewport(rect)) return rect;
    }
  } finally {
    range.detach?.();
  }
  return null;
}

Track 7 Lesson 3에서 본 getClientRects()를 거름망으로 쓰는 거야. 부모가 display:block 이어도 화면 밖으로 밀려난 텍스트 노드는 겹치는 사각형이 없거든. 그런 건 그냥 건너뛰어. 그래서 남는 건 user가 지금 실제로 볼 수 있는 글자뿐이야.

Control layer

텍스트 content 너머, script가 form control과 ARIA-label 된 element capture:

const selector = 'input, textarea, select, img[alt], [role="button"], [aria-label]';
for (const element of document.querySelectorAll(selector)) {
  if (!isVisibleElement(element)) continue;
  const rect = element.getBoundingClientRect();
  if (!rectIntersectsViewport(rect)) continue;
  const text = controlLabel(element);
  if (!text) continue;
  entries.push({ top: rect.top, left: rect.left, text });
}

덕분에 피파는 user가 이 페이지에서 읽을 수 있는 것뿐 아니라 수 있는 것까지 알게 돼. 이미지의 alt 텍스트, 버튼의 aria-label, 입력창의 placeholder가 전부 맥락으로 들어오거든. 요소 종류별로 이름을 뽑아내는 로직은 controlLabel() 안에 있어.

Spatial sort

Entry가 모인 후 위에서 아래, 왼쪽에서 오른쪽으로 sort:

entries.sort((a, b) => (a.top - b.top) || (a.left - b.left));

이게 flat 한 텍스트 fragment 모음에서 읽기 순서 회복. 현대 page가 source 순서 wildly 벗어난 layout (CSS Grid, reverse 가진 Flexbox) 가지지만, visual 위-아래 순서가 user가 perceive 하는 것. Rect 좌표로 sort가 DOM 순서 아닌 visual 순서 존중.

Throttle

세 event listener가 context update trigger:

  • scroll: scheduleContext로 350 ms 쯤 잠잠해질 때까지 기다렸다 보내. 워낙 자주 터지는 event 라 모아서 보내는 게 중요해.
  • selectionchange, mouseup, pointerup, keyup, touchend — selection 관련. scheduleSelectionContext가 0-ms setTimeout (microtask) 으로 즉시 fire, selection이 panel 에 가능한 한 real-time 가깝게 도착.
  • focus — window가 focus 되찾음. re-snapshot.
  • 초기 호출 — file 끝의 sendContext(). Injection 순간 상태 capture.

Scroll 에 throttle vs selection 에 즉시 가 맞는 거래. Scroll이 single drag 동안 수백 번 fire. selection이 user가 실제로 highlight 할 때만 fire. 다른 cadence, 다른 handler.

Selection cache

은근히 성가신 UX 문제가 하나 있어. user가 side panel을 열려고 클릭하는 순간 골라 뒀던 게 풀려 버리는 거야. 그래서 미리 챙겨 둬:

function currentSelectionText() {
  const current = truncate(window.getSelection?.().toString() || '', MAX_SELECTION_CHARS);
  if (current) {
    lastSelection = current;
    lastSelectionAt = Date.now();
    return current;
  }
  if (lastSelection && Date.now() - lastSelectionAt <= SELECTION_CACHE_MS) {
    return lastSelection;
  }
  return '';
}

Live selection이 비었지만 지난 5 분 안에 capture 된 거 있으면 cached version 반환. User가 '그거 highlight 하고 Pippa 한테 물었음' 생각 — 'oh, panel 여는 click이 selection clear 함' 안 생각. Cache가 경험을 intent와 매칭.

SW가 받는 것

sendContext() 호출 당 payload 하나, Lesson 6에서 자세히 설명된 shape. Content script가 sensor. SW가 bus. panel이 consumer. 각 layer가 한 job. 함께 cwkPippa를 user가 지금 읽고 있는 것으로 먹임.

Walker + visibility filter + control label + spatial sort + throttle + selection cache. 여섯 idea, 한 220-줄 file. 결과는 Pippa가 read 할 수 있는 JSON payload 로의 'user가 실제로 보는 것'.
manifest의 all_frames가 여기서 왜 중요하냐면. content script가 모든 frame에서 돌거든. 안쪽 frame에서 도는 녀석은 isTopFrame: false를 달고 자기 맥락을 따로 올려 보내. 그럼 Lesson 2의 SW 합치기 로직이 안쪽 frame의 선택은 받아들이면서 맨 위 frame의 본문 텍스트는보존. 이렇게 YouTube embed 안 텍스트 highlight 하는 user가 여전히 Pippa 에 올바르게 닿음.

Code

content-script.js — 두 throttle 전략 가진 event wiring·javascript
// embeds/chrome/content-script.js — throttled context push loop
const QUIET_MS = 350;
let timer = null;

function scheduleContext() {
  if (timer) window.clearTimeout(timer);
  timer = window.setTimeout(() => {
    timer = null;
    sendContext();
  }, QUIET_MS);
}

function scheduleSelectionContext() {
  window.setTimeout(sendContext, 0);  // selection event 엔 즉시
}

window.addEventListener('scroll', scheduleContext, { passive: true });
document.addEventListener('selectionchange', scheduleSelectionContext, true);
document.addEventListener('mouseup', scheduleSelectionContext, true);
document.addEventListener('pointerup', scheduleSelectionContext, true);
document.addEventListener('keyup', scheduleSelectionContext, true);
document.addEventListener('touchend', scheduleSelectionContext, true);
window.addEventListener('focus', scheduleContext);
sendContext();  // 초기 snapshot
v0.2.1 checkpoint — content-script.js — top-frame viewport와 subframe selection 계약·javascript
function extractContext({ freshSelection = false } = {}) {
  if (isPippaEmbedPanelUrl()) return null;
  const topFrame = isTopFrame();
  const selection = currentSelectionText({ allowCache: !freshSelection });
  if (!topFrame) {
    if (!freshSelection || !selection) return null;
    return { type: "pippa:host-context", payload: {
      host_kind: "web-page", selection, selectionFresh: true,
      viewportText: "", readableText: "", wordCount: 0,
      frameUrl: location.href, isTopFrame: false
    }};
  }
  const viewportText = collectViewportText();
  return { type: "pippa:host-context", payload: {
    host_kind: "web-page", source: location.href,
    human_label: document.title || location.hostname,
    selection, selectionFresh: freshSelection,
    viewportText, readableText: viewportText,
    frameUrl: location.href, isTopFrame: true
  }};
}

External links

Exercise

실제 content-script.js에서 isPippaEmbedPanelUrl, extractContext의 top-frame/subframe split, 250ms selection debounce, extractDocument, insertTextIntoLastEditable 다섯 boundary를 찾아. 각각 어떤 stale 또는 self-reference failure를 막는지 설명해.
Hint
지금 파일이 옛 prototype보다 큰 건 dock, overlay, document capture, assisted insertion이 실제로 나갔기 때문이야. Line 수 말고 boundary를 봐.

Progress

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

댓글 0

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

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