확장 프로그램 없이 트윗 대량 삭제하기

지난 X 게시물을 정리하려고 크롬 콘솔에 붙여 넣는 무료 스크립트입니다: 먼저 시뮬레이션, 최근 트윗은 보호, 상한을 둔 묶음 단위 삭제. 확장 프로그램도, 제삼자 서비스도 없고, 어떤 것도 브라우저 밖으로 나가지 않습니다.

시작하기 전에: 트윗 삭제는 되돌릴 수 없습니다. 이 스크립트는 시뮬레이션 모드(DRY_RUN = true)로 제공됩니다. 아무것도 건드리지 않고 삭제될 목록만 보여 줍니다. 미리 X 보관 데이터를 내려받고, 시뮬레이션을 확인한 다음에만 실제 삭제로 넘어가세요. 웹 인터페이스를 자동화하는 것은 X 이용 약관에서 예정한 방식이 아닙니다. 천천히, 본인 책임으로 진행하세요.

진행 방법

  1. 프로필을 여세요: 계정에 로그인한 채 x.com/your_username게시물 탭으로 이동하세요.
  2. 콘솔을 여세요: F12(맥에서는 Cmd+Option+J)를 누른 다음 콘솔 탭을 클릭하세요.
  3. 스크립트를 설정하세요: 아래 코드를 복사한 뒤 your_username을 자신의 X 아이디(@ 제외)로 바꾸고, KEEP_RECENT(남길 최근 트윗 개수)를 조정하세요. 필요하면 KEEP_STATUS_IDS에 절대 삭제하지 않을 트윗의 식별자를 넣으세요.
  4. 시뮬레이션하세요: 스크립트를 콘솔에 붙여 넣고 Enter로 실행하세요. DRY_RUN = true(기본 설정)이면 아무것도 삭제하지 않고 삭제될 트윗 목록만 표시합니다.
  5. 삭제하세요: 시뮬레이션 결과가 괜찮으면 DRY_RUNfalse로 바꾸고 다시 실행하세요. 스크립트는 한 번에 최대 40개까지 삭제하며, 6초에서 14초 사이의 대기 시간을 둡니다.
  6. 반복하세요: 남은 트윗을 묶음 단위로 처리하려면 필요한 만큼 스크립트를 다시 실행하세요.

스크립트

크롬 콘솔 · x.com
(() => {
  // ============ 설정 ============
  const HANDLE          = "your_username"; // 자신의 X 아이디, @ 제외
  const KEEP_RECENT     = 15;           // 가장 최근 트윗 15개(맨 위)는 절대 삭제하지 않음
  const KEEP_STATUS_IDS = [             // 추가 보호(선택): 절대 삭제하지 않을 식별자
    "1234567890123456789",
  ];
  let   DRY_RUN         = true;         // true = 시뮬레이션(목록만). false = 실제 삭제.
  const MIN_DELAY_MS    = 6000;
  const MAX_DELAY_MS    = 14000;
  const MAX_DELETIONS   = 40;           // 실행당 상한(계속하려면 다시 실행)
  // ================================

  const me = HANDLE.toLowerCase();
  const KEEP = new Set(KEEP_STATUS_IDS.map(String));
  const protectedRecent = new Set();
  const RX_DELETE = /(삭제|delete|supprimer)/i;
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  const rand  = (a, b) => Math.floor(a + Math.random() * (b - a));
  const skipped = new Set();

  if (!location.pathname.toLowerCase().startsWith("/" + me)) {
    console.warn(`%c[중지] https://x.com/${HANDLE} (게시물 탭)로 이동한 뒤 다시 실행하세요.`,
      "color:#e0245e;font-weight:bold");
    return;
  }

  const articles = () => [...document.querySelectorAll('article[data-testid="tweet"]')];

  const idOf = (art) => {
    const href = [...art.querySelectorAll('a[href*="/status/"]')]
      .map((x) => x.getAttribute("href"))
      .find((h) => new RegExp(`^/${HANDLE}/status/\\d+`, "i").test(h));
    if (!href) return null;
    const m = href.match(/status\/(\d+)/);
    return m ? m[1] : null;
  };

  // 가장 최근 트윗 KEEP_RECENT개(맨 위)를 불러와 보호함.
  async function protectRecent() {
    const order = [], seen = new Set();
    let noNew = 0;
    window.scrollTo(0, 0);
    await sleep(1200);
    while (order.length < KEEP_RECENT && noNew < 6) {
      let added = 0;
      for (const art of articles()) {
        const id = idOf(art);
        if (id && !seen.has(id)) { seen.add(id); order.push(id); added++; }
      }
      noNew = added ? 0 : noNew + 1;
      if (order.length >= KEEP_RECENT) break;
      const cs = articles();
      (cs[cs.length - 1] || document.body).scrollIntoView({ block: "end" });
      await sleep(1400);
    }
    for (const id of order.slice(0, KEEP_RECENT)) protectedRecent.add(id);
    return order.slice(0, KEEP_RECENT);
  }

  const eligible = (art) => {
    const id = idOf(art);
    if (!id) return null;
    if (protectedRecent.has(id)) return null;   // 최근 트윗 중 하나
    if (KEEP.has(id)) return null;              // 추가로 보호된 ID
    if (skipped.has(id)) return null;
    return id;
  };

  async function deleteOne(art, id) {
    const caret = art.querySelector('[data-testid="caret"]');
    if (!caret) { skipped.add(id); return false; }
    caret.click();
    await sleep(700);
    const del = [...document.querySelectorAll('[role="menuitem"]')]
      .find((i) => RX_DELETE.test((i.innerText || "").trim()));
    if (!del) { document.body.click(); skipped.add(id); return false; }
    del.click();
    await sleep(700);
    const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
    if (!confirmBtn) { skipped.add(id); return false; }
    confirmBtn.click();
    await sleep(1500);
    return true;
  }

  async function dryScan() {
    const seen = new Set();
    let noNew = 0;
    console.log("%c[시뮬레이션] 아무것도 삭제되지 않습니다. 집계 중...", "color:#1d9bf0;font-weight:bold");
    while (noNew < 4) {
      let added = 0;
      for (const art of articles()) {
        const id = eligible(art);
        if (id && !seen.has(id)) { seen.add(id); added++; }
      }
      noNew = added ? 0 : noNew + 1;
      const cs = articles();
      (cs[cs.length - 1] || document.body).scrollIntoView({ block: "end" });
      await sleep(1200);
    }
    console.log(`%c[시뮬레이션] 트윗 ${seen.size}개가 삭제될 예정입니다(최근 트윗은 보호됨).`,
      "color:#1d9bf0;font-weight:bold");
    console.log([...seen].slice(0, 60));
  }

  async function run() {
    let count = 0, guard = 0, noHit = 0, started = false;
    while (count < MAX_DELETIONS && guard++ < 1500) {
      const pair = articles().map((a) => [a, eligible(a)]).find(([, id]) => id);
      if (!pair) {
        if ((started && noHit >= 6) || noHit >= 40) break;
        const cs = articles();
        (cs[cs.length - 1] || document.body).scrollIntoView({ block: "end" });
        await sleep(1500);
        noHit++;
        continue;
      }
      started = true; noHit = 0;
      const [node, id] = pair;
      node.scrollIntoView({ block: "center" });
      await sleep(600);
      const ok = await deleteOne(node, id);
      if (ok) count++;
      console.log(`${ok ? "삭제됨" : "건너뜀"} ${id}  (합계: ${count}/${MAX_DELETIONS})`);
      await sleep(rand(MIN_DELAY_MS, MAX_DELAY_MS));
    }
    console.log(`%c[완료] 이번 회차에 ${count}개 삭제됨. 남은 것이 있으면 스크립트를 다시 실행하세요.`,
      "color:#00ba7c;font-weight:bold");
  }

  (async () => {
    console.log(`가장 최근 트윗 ${KEEP_RECENT}개를 불러와 보호하는 중...`);
    const recent = await protectRecent();
    if (recent.length < KEEP_RECENT) {
      console.warn(`%c[중지] 트윗을 ${recent.length}개만 불러왔습니다(X가 속도를 제한함). 페이지를 새로 고침(F5)한 뒤 다시 시도하세요.`,
        "color:#e0245e;font-weight:bold");
      return;
    }
    console.log("%c보호됨(가장 최근 트윗):", "color:#00ba7c;font-weight:bold", recent);
    if (DRY_RUN) { await dryScan(); return; }
    await run();
  })();
})();

스크립트가 여러분을 지키는 방법

네 가지 안전장치가 들어 있습니다. 먼저 기본 시뮬레이션입니다. DRY_RUNtrue인 동안에는 아무것도 삭제되지 않고, 대상 트윗 목록만 얻습니다. 다음은 최근 트윗 보호입니다. 스크립트는 가장 먼저 최근 트윗(기본값 15개)을 기억해 두고 절대 건드리지 않습니다. 그다음은 화이트리스트입니다. KEEP_STATUS_IDS에 넣은 식별자는 손대지 않으므로, 고정한 트윗이나 공지에 유용합니다. 마지막은 신중한 속도입니다. 삭제 사이에 6초에서 14초의 무작위 대기를 두고 40개 후 자동으로 멈춰, X의 사용 제한을 유발하지 않도록 합니다.

대청소를 하기 전에 소중한 것은 보관해 두세요: 이메일 및 URL 추출기는 트윗 내보내기에서 모든 링크를 뽑아 주고, 단어 수 카운터는 여러 해에 걸친 게시물의 분량을 알려 주며, 텍스트 정리기는 보관 파일에서 복사한 텍스트를 깔끔하게 다듬어 줍니다. 앞으로의 트윗에는 유니코드 스타일 텍스트대소문자 변환기가 함께합니다. 도구 모음의 모든 도구는 이 가이드처럼 브라우저 안에서 작동하며, 저희 개인정보 처리방침의 정신을 따릅니다.

자주 묻는 질문

삭제한 트윗을 되돌릴 수 있나요?

아니요. 삭제한 트윗은 영구적으로 사라지며, X에는 휴지통이 없습니다. 그래서 이 스크립트는 시뮬레이션 모드(DRY_RUN = true)로 제공됩니다. 먼저 그대로 실행해 삭제될 목록을 확인하고, 검토한 뒤 확신이 설 때만 false로 바꾸세요. 그 전에 X 보관 데이터도 내려받아 두는 것이 좋습니다(설정, 내 계정, 내 데이터 보관 파일 다운로드 순).

이 스크립트로 다른 사람의 트윗도 삭제할 수 있나요?

아니요. X는 자신의 게시물에만 삭제 버튼을 표시합니다. 따라서 스크립트는 로그인한 계정에서, 자신의 프로필 페이지에 있는 게시물에만 작동합니다. 다른 사람의 답글이나 제삼자의 콘텐츠는 건드리지 않습니다.

삭제 40개 상한과 무작위 대기 시간은 왜 있나요?

플랫폼에 부담을 주지 않기 위해서입니다. 너무 빠르거나 너무 많은 삭제는 X의 사용 제한을 유발하거나 계정을 일시적으로 차단시킬 수 있습니다. 이 스크립트는 삭제 사이에 6초에서 14초를 기다리고 40개 후에 멈춥니다. 이어서 하려면 다시 실행해 묶음 단위로 진행하세요.

이것이 X에서 허용되나요?

자신의 콘텐츠를 삭제하는 것은 여러분의 권리입니다. 다만 웹 인터페이스를 자동화하는 것은 X 이용 약관에서 예정한 방식이 아닙니다. 이 스크립트는 절제해서, 본인 책임으로 사용하세요. 공식적인 대안은 수동 삭제이거나, API를 이용하는 전용 서비스입니다.

특정 트윗을 어떻게 보호하나요?

두 가지 보호가 함께 적용됩니다. KEEP_RECENT는 가장 최근 트윗(기본값 15개)을 자동으로 지켜 주고, KEEP_STATUS_IDS는 절대 삭제하지 않을 식별자 목록을 받습니다. 트윗의 식별자는 주소 끝에 있는 긴 숫자입니다: x.com/your_username/status/1234567890123456789.