JanitorAI Chat Copier

Automatically scroll up and copy chat to clipboard.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         JanitorAI Chat Copier
// @namespace    https://greasyfork.org/users/your-name
// @version      1.4
// @description  Automatically scroll up and copy chat to clipboard.
// @match        https://janitorai.com/chats/*
// @match        https://www.janitorai.com/chats/*
// @run-at       document-idle
// @grant        none
// @license      MIT
// ==/UserScript==

(() => {
  "use strict";

  const STEP_MS   = 200;
  const PAGE_FRAC = 0.9;
  const UP_CAP    = 800;
  const MIN_LEN   = 3;
  const NAME_MAX  = 60;
  const DROP_PAT  = /^(typing|loading|thinking|processing|\.\.\.)$/i;

  const BTN_ID = "ja-copy-chat-btn";

  const ZW_RE = /\u200b|\u200c|\u200d|\ufeff/g;
  const stripZW = s => s.replace(ZW_RE, "");
  const tidy = s => s ? stripZW(s).replace(/\r/g,"").replace(/\s+\n/g,"\n").replace(/\n{3,}/g,"\n\n").trim() : "";
  const normKey = s => stripZW(s).toLowerCase().replace(/\s+/g,"");
  const msgKey = (name, body) => normKey((name||"")+"::"+body);
  const fmt = n => n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");

  const MSG_WRAPPER_SEL = `
    [data-message-id],
    [data-testid*="message"],
    [class*="message"],
    [class*="Message"],
    [class*="chatline"],
    [class*="chatLine"],
    [class*="bubble"],
    article,
    li[role="article"]
  `;
  const NAME_SEL = `
    [data-testid*="name"],
    [data-testid*="author"],
    [data-author],
    [data-user-name],
    [data-username],
    [data-speaker],
    [class*="name"],
    [class*="Name"],
    [class*="author"],
    [class*="Author"],
    [class*="username"],
    header,
    h1,h2,h3,strong,b
  `;
  const BODY_SEL = `
    [data-testid*="message-text"],
    [data-testid*="content"],
    .message-text,
    .msg-text,
    .prose,
    .markdown,
    .content
  `;

  function isVisible(el){
    if(!(el instanceof Element)) return false;
    const st=getComputedStyle(el);
    if(st.display==="none"||st.visibility==="hidden"||+st.opacity===0) return false;
    const r=el.getBoundingClientRect();
    if(!r || r.height<12 || r.width<2) return false;
    const vh=window.innerHeight;
    if(r.bottom < -vh*0.25) return false;
    if(r.top    >  vh*1.25) return false;
    return true;
  }

  function findScrollEl(){
    const cands=[...document.querySelectorAll("div,main,section,article")];
    let best=null,score=-1;
    for(const el of cands){
      const st=getComputedStyle(el);
      if(!/(auto|scroll)/i.test(st.overflowY)) continue;
      const h=el.scrollHeight-el.clientHeight;
      if(h<=0) continue;
      const s=h + el.querySelectorAll("*").length;
      if(s>score){score=s;best=el;}
    }
    return best || document.scrollingElement || document.body;
  }

  function extractName(wrapper){
    const label = wrapper.querySelector(NAME_SEL);
    if(label){
      const n = tidy(label.innerText || label.textContent || "");
      if(n && n.length <= NAME_MAX && !DROP_PAT.test(n)) return n;
    }
    const raw = wrapper.innerText || wrapper.textContent || "";
    if(!raw) return "";
    const lines = raw.split(/\n+/).map(l=>l.trim()).filter(Boolean);
    if(lines.length>1 && lines[0].length>0 && lines[0].length<=NAME_MAX){
      if(lines[1].length >= MIN_LEN) return lines[0];
    }
    return "";
  }

  function extractBody(wrapper, nameUsed){
    const inner = wrapper.querySelector(BODY_SEL);
    let raw;
    if(inner && inner!==wrapper){
      raw = inner.innerText || inner.textContent || "";
    }else{
      raw = wrapper.innerText || wrapper.textContent || "";
    }
    raw = tidy(raw||"");
    if(nameUsed){
      const esc = nameUsed.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
      raw = raw.replace(new RegExp("^"+esc+"\\s*\\n?"),"").trim();
    }
    return raw;
  }

  let scrollContainer=null;
  function captureStep(store, step, winScroll){
    if(document.hidden) return;
    const nodes=scrollContainer.querySelectorAll(MSG_WRAPPER_SEL);
    for(const w of nodes){
      if(!isVisible(w)) continue;

      let id = w.getAttribute("data-message-id") ||
               w.getAttribute("data-id") ||
               w.id ||
               null;

      let name = extractName(w);
      const body = tidy(extractBody(w, name));

      if(body.length < MIN_LEN) continue;
      if(DROP_PAT.test(body)) continue;
      if(!name) name="";

      const key = id ? ("id:"+id) : msgKey(name, body);
      if(store.has(key)) continue;
      store.set(key, {name, body, step});
    }
  }

  async function captureAll(){
    scrollContainer = findScrollEl();
    const winScroll=(scrollContainer===document.scrollingElement||scrollContainer===document.body);

    const getTop = ()=>winScroll?window.scrollY:scrollContainer.scrollTop;
    const setTop = v =>winScroll?window.scrollTo(0,v):(scrollContainer.scrollTop=v);
    const getStep= ()=> (winScroll?window.innerHeight:scrollContainer.clientHeight)*PAGE_FRAC;

    const store=new Map();
    let step=0;

    captureStep(store, step++, winScroll);

    for(let i=0;i<UP_CAP;i++){
      const before=getTop();
      if(before<=0) break;
      const next=Math.max(0,before-getStep());
      setTop(next);
      // eslint-disable-next-line no-await-in-loop
      await new Promise(r=>setTimeout(r,STEP_MS));
      captureStep(store, step++, winScroll);
      const after=getTop();
      if(after===before||after===0) break;
    }

    // We captured bottom->top; earliest messages seen last = highest step
    const arr=[...store.values()].sort((a,b)=>b.step-a.step);

    const lines=[];
    for(const m of arr){
      lines.push(m.name ? `${m.name}: ${m.body}` : m.body);
    }

    // fallback if nothing
    if(!lines.length){
      const raw = tidy(scrollContainer.innerText||"");
      if(raw) lines.push(raw);
    }

    return lines;
  }

  async function copyOut(str){
    const msg = `Copied Chat with ${fmt(str.length)} characters in total.`;
    try{
      await navigator.clipboard.writeText(str);
      alert(msg);
    }catch(_){
      const ta=document.createElement("textarea");
      ta.value=str;
      ta.style.position="fixed";ta.style.top="-9999px";
      document.body.appendChild(ta);ta.focus();ta.select();
      document.execCommand("copy");
      document.body.removeChild(ta);
      alert(msg + " (fallback).");
    }
  }

  async function handleCopy(){
    const btn=document.getElementById(BTN_ID);
    if(btn){btn.disabled=true;btn.textContent="Capturing…";}
    const lines=await captureAll();
    const out=lines.join("\n\n");
    await copyOut(out);
    if(btn){btn.disabled=false;btn.textContent="Copy Chat";}
  }

  function addButton(){
    let btn=document.getElementById(BTN_ID);
    if(btn) return;
    btn=document.createElement("button");
    btn.id=BTN_ID;
    btn.textContent="Copy Chat";
    btn.style.cssText=`
      position:fixed;
      bottom:12px;
      right:12px;
      z-index:2147483647;
      padding:6px 10px;
      font-size:13px;
      cursor:pointer;
      background:#2d72d9;
      color:#fff;
      border:none;
      border-radius:4px;
    `;
    btn.addEventListener("click",handleCopy);
    document.body.appendChild(btn);
  }

  if(document.readyState==="loading"){
    document.addEventListener("DOMContentLoaded",addButton,{once:true});
  }else{
    addButton();
  }
})();