JanitorAI Chat Copier

Automatically scroll up and copy chat to clipboard.

// ==UserScript==
// @name         JanitorAI Chat Copier
// @namespace    https://gf.qytechs.cn/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();
  }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址