내가 팔로우하는데 나를 팔로우하지 않는 사람, 나를 팔로우하는데 내가 팔로우하지 않는 사람을 찾아냅니다. 모든 처리는 브라우저 안에서만 이뤄집니다.
스레드가 이미 주고받는 응답을 옆에서 받아 적는 방식입니다. 우리 쪽에서 따로 요청을 보내지 않기 때문에 계정에 부담이 가지 않습니다.
(()=>{if(window.__ledgerStop){console.log('이미 켜져 있습니다');return}
const U=new Map();const BYNAME=new Map();let RUN=false,STALE=0,ARMED=false,T0=0,N0=0,MODE='followers',OID=null;
const TOT={followers:null,following:null},DOMT={followers:null,following:null};
const SEEN={followers:new Set(),following:new Set()};
let FEDI=null;
const DONE={followers:false,following:false};
// 바닥까지 내렸는지. 총원과 맞는지와는 별개다
const ENDED={followers:false,following:false};
const settled=k=>DONE[k]||ENDED[k];
let MSG='목록을 연 뒤 시작을 누르세요';
let OW=location.pathname.split('/@')[1]||'';
const OWNER=(OW.split('/')[0].split('?')[0].split('#')[0])||null;
const LABEL={followers:'팔로워',following:'팔로잉'};
const got=()=>SEEN[MODE].size;
// 화면에 적힌 숫자에서 총원 읽기 — 정규식 없이 자릿수만 훑는다
// 키워드에서 가장 가까운 숫자를 고른다 (긴 숫자 우선이면 조회수를 집어감)
const nearNum=(t,ki,kl,af)=>{const runs=[];let cur='',st=-1;
for(let i=0;i<=t.length;i++){const c=i<t.length?t.charAt(i):'';
if(c>='0'&&c<='9'){if(st<0)st=i;cur+=c}
else if(c===','&&st>=0){}
else{if(cur)runs.push({v:parseInt(cur,10),p:st,e:i});cur='';st=-1}}
let best=0,bd=999999;
// 한국어는 '팔로워21' 처럼 뒤, 영어는 '21followers' 처럼 앞에 숫자가 온다
if(af){const end=ki+kl;
for(let i=0;i<runs.length;i++){const d=runs[i].p-end;
if(d>=0&&d<bd){bd=d;best=runs[i].v}}}
else{for(let i=0;i<runs.length;i++){const d=ki-runs[i].e;
if(d>=0&&d<bd){bd=d;best=runs[i].v}}}
if(best)return best;
bd=999999;
for(let i=0;i<runs.length;i++){const d=Math.abs(runs[i].p-ki);
if(d<bd){bd=d;best=runs[i].v}}
return best};
const scanDOM=()=>{
if((TOT.followers||DOMT.followers)&&(TOT.following||DOMT.following))return;
const els=document.querySelectorAll('a,span,div,h1,h2,button');
let bF=null,bG=null;
for(let i=0;i<els.length;i++){const el=els[i];
const t=(el.textContent||'').trim();
if(!t||t.length>26)continue;
const low=t.toLowerCase();
let kiG=t.indexOf('팔로잉'),klG=3,afG=true;
if(kiG<0){kiG=low.indexOf('following');klG=9;afG=false}
let kiF=t.indexOf('팔로워'),klF=3,afF=true;
if(kiF<0){kiF=low.indexOf('follower');klF=8;afF=false}
if(kiG>=0){const n=nearNum(t,kiG,klG,afG);
if(n>0&&(!bG||t.length<bG.len))bG={n:n,len:t.length}}
if(kiF>=0){const n=nearNum(t,kiF,klF,afF);
if(n>0&&(!bF||t.length<bF.len))bF={n:n,len:t.length}}}
if(bG&&!DOMT.following){DOMT.following=bG.n;console.log('화면에서 팔로잉 총원 '+bG.n)}
if(bF&&!DOMT.followers){DOMT.followers=bF.n;console.log('화면에서 팔로워 총원 '+bF.n)}};
// 응답을 놓친 사람을 화면에서 건진다. 지금 열려 있는 탭이 관계의 증거다.
const NAME=new RegExp('^[A-Za-z0-9._]{1,30}$');
const sweepDOM=()=>{const s=scroller();const root=s||document.querySelector('[role="dialog"]');
if(!root)return 0;let n=0;
const links=root.querySelectorAll('a[href^="/@"],a[href^="/"]');
for(let i=0;i<links.length;i++){
let t=links[i].getAttribute('href')||'';
if(t.charAt(0)!=='/')continue;
t=t.slice(1);
if(t.charAt(0)==='@')t=t.slice(1);
if(t.charAt(t.length-1)==='/')t=t.slice(0,-1);
if(!t||t.indexOf('/')>=0)continue;
if(!NAME.test(t))continue;const name=t;
if(name===OWNER)continue;
const known=BYNAME.get(name);
// 이미 아는 사람이라도 건너뛰면 안 된다. 지금 열려 있는 탭이 그 사람의
// 반대 방향 관계를 알려주기 때문. 맞팔인 사람이 여기서 빠지곤 했다.
if(known){if(!SEEN[MODE].has(known)){SEEN[MODE].add(known);n++}
const q=U.get(known)||{};
U.set(known,{...q,id:known,username:name,
followsMe:MODE==='followers'||!!q.followsMe,
iFollow:MODE==='following'||!!q.iFollow});continue}
const id='dom:'+name;BYNAME.set(name,id);SEEN[MODE].add(id);
U.set(id,{id:id,username:name,fullName:'',avatar:'',
followsMe:MODE==='followers',iFollow:MODE==='following',verified:false});n++}
if(n)console.log('화면에서 '+n+'명 추가로 건짐 ('+LABEL[MODE]+')');
return n};
const tgt=()=>TOT[MODE]||DOMT[MODE]||null;
const isOwner=o=>(OID&&(String(o.id)===OID||String(o.pk)===OID))||(OWNER&&o.username===OWNER);
// 응답 어디에 있든 소유자 노드를 찾아 카운트를 긁는다
const dig=(o,d)=>{if(!o||typeof o!=='object'||d>6)return;
if(Array.isArray(o)){for(let i=0;i<o.length&&i<60;i++)dig(o[i],d+1);return}
if(isOwner(o)){
if(typeof o.follower_count==='number'&&TOT.followers!==o.follower_count){
TOT.followers=o.follower_count;console.log('팔로워 총원 '+o.follower_count)}
if(typeof o.following_count==='number'&&TOT.following!==o.following_count){
TOT.following=o.following_count;console.log('팔로잉 총원 '+o.following_count)}}
for(const k in o){const v=o[k];if(v&&typeof v==='object')dig(v,d+1)}};
const P=t=>{try{
const j=JSON.parse(t);if(!j||!j.data)return;
const u=j.data.user;
if(u){if(OWNER&&u.username===OWNER&&(u.id||u.pk))OID=String(u.id||u.pk);
else if(!OID&&(u.id||u.pk)&&(u.followers||u.following))OID=String(u.id||u.pk)}
if(t.indexOf('follower_count')>=0||t.indexOf('following_count')>=0)dig(j.data,0);
if(j.data.counts&&typeof j.data.counts.total_fediverse_followers_count==='number')
FEDI=j.data.counts.total_fediverse_followers_count;
if(!u){B();return}
let c=null;
if(u.followers&&u.followers.edges){c=u.followers;MODE='followers'}
else if(u.following&&u.following.edges){c=u.following;MODE='following'}
else if(u.mutual_followers&&u.mutual_followers.edges){c=u.mutual_followers}
if(!c){B();return}
const SRC=(c===u.following)?'following':'followers';
if(c.page_info&&typeof c.page_info.has_next_page==='boolean')
DONE[SRC]=!c.page_info.has_next_page;
for(const e of c.edges){const n=e.node||{},f=n.friendship_status||{};const id=String(n.pk||n.id||'');
if(!id||!n.username)continue;
// 화면에서 먼저 주워둔 임시 항목이 있으면 합치고 지운다
const old=BYNAME.get(n.username);
let p=U.get(id)||{};
if(old&&old!==id){p={...(U.get(old)||{}),...p};U.delete(old);
['followers','following'].forEach(k=>{if(SEEN[k].delete(old))SEEN[k].add(id)})}
BYNAME.set(n.username,id);
SEEN[SRC].add(id);
// 목록에 있다는 사실 자체가 관계의 증거 — 플래그가 비어 와도 보정
const rel=SRC==='following'?{iFollow:true}:{followsMe:true};
U.set(id,{id:id,username:n.username,fullName:n.full_name||p.fullName||'',
avatar:n.profile_pic_url||p.avatar||'',
followsMe:!!f.followed_by||!!p.followsMe||!!rel.followsMe,
iFollow:!!f.following||!!p.iFollow||!!rel.iFollow,
verified:!!n.is_verified||!!p.verified})}
ARMED=false;B()}catch(e){}};
const OF=window.fetch;window.fetch=function(){return OF.apply(this,arguments).then(r=>{
try{r.clone().text().then(P)}catch(e){}return r})};
const OS=XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.send=function(){
this.addEventListener('load',()=>{try{P(this.responseText)}catch(e){}});return OS.apply(this,arguments)};
const sleep=m=>new Promise(r=>setTimeout(r,m));
const scroller=()=>{let best=null,bn=0;
document.querySelectorAll('div').forEach(el=>{
if(el.scrollHeight<=el.clientHeight+40)return;
const o=getComputedStyle(el).overflowY;if(o!=='auto'&&o!=='scroll')return;
const n=el.querySelectorAll('a[href^="/@"]').length;if(n>bn){bn=n;best=el}});
return best};
const dur=sec=>{if(sec<60)return Math.max(1,Math.round(sec))+'초';
const m=Math.floor(sec/60);return m+'분 '+Math.round(sec%60)+'초'};
const eta=()=>{if(!RUN)return'';const T=tgt(),g=got();
const el=(Date.now()-T0)/1000,done=g-N0;
if(el<4||done<1)return' · 속도 재는 중';
const rate=done/el;
if(!T)return' · 분당 '+Math.round(rate*60)+'명';
const left=T-g;if(left<=0)return'';
return' · 약 '+dur(left/rate)+' 남음'};
const box=document.createElement('div');
box.style.cssText='position:fixed;z-index:999999;right:16px;bottom:16px;background:#16171a;color:#f7f6f2;'+
'font:13px/1.5 ui-monospace,Menlo,monospace;padding:12px 14px;border-radius:5px;min-width:258px;'+
'box-shadow:0 6px 20px rgba(0,0,0,.4)';
const tag=document.createElement('div');
tag.style.cssText='font-size:10px;letter-spacing:.12em;opacity:.5;margin-bottom:3px';
const num=document.createElement('div');num.style.cssText='font-weight:600;font-size:15px';
const track=document.createElement('div');
track.style.cssText='height:4px;background:rgba(255,255,255,.18);border-radius:2px;margin:8px 0 6px;overflow:hidden';
const fill=document.createElement('div');
fill.style.cssText='height:100%;width:0%;background:#7ee0a8;transition:width .3s';
track.appendChild(fill);
const sub=document.createElement('div');sub.style.cssText='opacity:.65;font-size:11px;margin-bottom:9px';
const bar=document.createElement('div');bar.style.cssText='display:flex;gap:6px';
const mk=(t,bg,fg)=>{const b=document.createElement('button');b.textContent=t;
b.style.cssText='flex:1;font:600 12px ui-monospace,monospace;padding:7px 8px;border:0;border-radius:3px;'+
'cursor:pointer;background:'+bg+';color:'+fg;return b};
const go=mk('시작','#e8481f','#fff'),cp=mk('복사','#f7f6f2','#16171a');
bar.appendChild(go);bar.appendChild(cp);
[tag,num,track,sub,bar].forEach(e=>box.appendChild(e));
const B=()=>{const g=got();
// 수집 수가 총원을 넘으면 그 총원은 틀린 것 — 버리고 다시 찾는다
if(DOMT[MODE]&&!TOT[MODE]&&g>DOMT[MODE]){
console.log('총원 '+DOMT[MODE]+' 이 수집 수 '+g+' 보다 작아 폐기');DOMT[MODE]=null}
const T=tgt();
tag.textContent=LABEL[MODE]+' 수집';
// 다 내렸으면 끝난 것이다. 프로필 숫자와 다른 건 목록에 안 나오는 계정이 있어서지 실패가 아니다
if(settled(MODE)){const gap=T?Math.max(0,T-g):0;
num.textContent=g.toLocaleString()+'명 수집 완료'+(gap?' (목록에 없음 '+gap+')':'');
fill.style.width='100%';fill.style.background='#7ee0a8'}
else if(T){const pct=Math.min(100,Math.round(g/T*100));
num.textContent=g.toLocaleString()+' / '+T.toLocaleString()+' '+pct+'%';
fill.style.width=pct+'%'}
else{num.textContent=g.toLocaleString()+'명 수집';fill.style.width='0%'}
sub.textContent=MSG+eta();
go.textContent=RUN?'중지':'시작';go.style.background=RUN?'#8a3a60':'#e8481f';
cp.style.background=ARMED?'#8a3a60':'#f7f6f2';cp.style.color=ARMED?'#fff':'#16171a';
cp.textContent=ARMED?'그래도 복사':'복사'};
const complete=()=>{if(settled(MODE))return true;const T=tgt();return T?got()>=T:false};
let ABORT=false;
// 모달(대화상자) 범위 — 이 밖의 요소는 절대 클릭하지 않는다
const modalRoot=()=>{const d=document.querySelector('[role="dialog"]');if(d)return d;
const s=scroller();if(!s)return null;
let n=s;for(let i=0;i<6&&n.parentElement;i++)n=n.parentElement;return n};
// 텍스트가 맞는 요소들을 짧은 순으로 모은다
const findAll=(root,names,exact)=>{if(!root)return[];
const els=root.querySelectorAll('div,span,a,button');const hits=[];
for(let i=0;i<els.length;i++){const el=els[i];
const t=(el.textContent||'').trim();
if(!t||t.length>26)continue;
let hit=false;
for(let k=0;k<names.length;k++){
if(exact?t===names[k]:t.indexOf(names[k])>=0)hit=true}
if(!hit)continue;
if(el.querySelectorAll('*').length>6)continue;
hits.push({el:el,len:t.length,txt:t})}
hits.sort((a,b)=>a.len-b.len);
return hits};
// 실제로 클릭을 받는 조상을 찾아 누른다 (span 은 눌러도 반응 없음)
const hit=el=>{let n=el;
for(let i=0;i<6&&n;i++){
const tg=(n.tagName||'').toLowerCase();
const role=n.getAttribute?n.getAttribute('role'):null;
if(tg==='a'||tg==='button'||role==='button'||role==='link'||
(n.getAttribute&&n.getAttribute('tabindex')!==null)){n.click();return true}
n=n.parentElement}
if(typeof el.click==='function'){el.click();return true}
return false};
// 모달 안의 팔로잉 탭으로 전환 (모달이 없으면 아무것도 누르지 않음)
const clickFollowingTab=()=>{const root=modalRoot();
if(!root||root===document.body)return false;
const hits=findAll(root,['팔로잉','Following'],true);
for(let i=0;i<hits.length&&i<4;i++){
if(hit(hits[i].el)){console.log('팔로잉 탭 클릭: "'+hits[i].txt+'"');return true}}
return false};
// 프로필 화면에서 팔로워 목록 열기 — 후보를 차례로 눌러본다
const openFollowers=async()=>{
const hits=findAll(document.body,['팔로워','Followers'],false);
if(!hits.length){console.log('팔로워 버튼을 못 찾았습니다');return false}
for(let i=0;i<hits.length&&i<8;i++){
console.log('팔로워 열기 시도: "'+hits[i].txt+'"');
if(!hit(hits[i].el))continue;
const t=Date.now();
while(Date.now()-t<2500){if(scroller())return true;await sleep(250)}}
return !!scroller()};
const waitFor=async(fn,ms)=>{const t=Date.now();
while(Date.now()-t<ms){if(fn())return true;await sleep(300)}return false};
const short=k=>LABEL[k]+' '+SEEN[k].size+'명';
const summary=()=>short('followers')+' · '+short('following');
const runPhase=async()=>{const s=scroller();
if(!s){MSG='목록을 찾지 못했습니다 — 창을 열어주세요';return false}
MSG=LABEL[MODE]+' 내리는 중';STALE=0;T0=Date.now();N0=got();DONE[MODE]=false;scanDOM();
let last=U.size,h=0;
while(!ABORT&&STALE<6&&!complete()){s.scrollTop=s.scrollHeight;await sleep(1100);scanDOM();
sweepDOM();
if(U.size>last||s.scrollHeight>h){last=U.size;h=s.scrollHeight;STALE=0}else STALE++;B()}
sweepDOM();
// 멈춘 게 아니라 더 이상 안 나오는 것 — 이 단계는 끝난 것으로 본다
if(!ABORT)ENDED[MODE]=true;
B();return true};
const run=async()=>{ABORT=false;
// 목록 창이 없으면 프로필에서 팔로워를 눌러 연다
if(!scroller()){MSG='팔로워 목록 여는 중';B();
const opened=await openFollowers();
if(!opened){MSG='팔로워 목록을 못 열었습니다 — 직접 열고 시작을 누르세요';RUN=false;B();return}
await sleep(800)}
await runPhase();
// 팔로워를 마쳤고 팔로잉은 아직이면 모달 탭을 눌러 이어서 수집
if(!ABORT&&MODE==='followers'&&SEEN.following.size===0){
MSG='팔로잉 탭으로 이동 중';B();
if(!clickFollowingTab()){
MSG='팔로잉 탭을 못 찾았습니다 — 직접 열고 시작을 누르세요';RUN=false;B();return}
const ok=await waitFor(()=>SEEN.following.size>0,7000);
if(!ok){MSG='팔로잉 목록이 안 열립니다 — 직접 열고 시작을 누르세요';RUN=false;B();return}
MODE='following';B();await runPhase()}
RUN=false;
if(!ABORT){['followers','following'].forEach(k=>{
const T=TOT[k]||DOMT[k],g=SEEN[k].size;
if(T&&g<T)console.log(LABEL[k]+' 총원 '+T+' 중 '+g+'명만 응답에 있음 · 차이 '+(T-g)+
(FEDI?' · 페디버스 팔로워 '+FEDI+'명 (목록에 안 나옴)':' · 탈퇴·정지·페디버스 계정일 수 있음'))});}
const gp=gaps();
MSG=ABORT?('멈췄습니다 · '+summary()):('수집 완료 · '+summary()+(gp.length?' · '+gp.join(' · '):''));
B()};
go.onclick=()=>{if(RUN){ABORT=true;RUN=false;B()}else{ARMED=false;RUN=true;B();run()}};
const toClip=txt=>{if(navigator.clipboard&&window.isSecureContext)
return navigator.clipboard.writeText(txt);
return new Promise((res,rej)=>{const a=document.createElement('textarea');
a.value=txt;a.setAttribute('readonly','');a.style.cssText='position:fixed;top:-9999px;opacity:0';
document.body.appendChild(a);a.select();
let ok=false;try{ok=document.execCommand('copy')}catch(e){}
document.body.removeChild(a);ok?res():rej()})};
// 어느 쪽이든 덜 받은 목록이 있는지
const missing=()=>{const out=[];['followers','following'].forEach(k=>{
if(SEEN[k].size===0)out.push(LABEL[k]+' 아직 안 모음');
else if(!settled(k))out.push(LABEL[k]+' 아직 내리는 중')});
return out};
// 프로필 숫자와의 차이. 탈퇴·정지·페디버스 계정이라 목록에 안 나오는 사람들이다
const gaps=()=>{const out=[];['followers','following'].forEach(k=>{
const T=TOT[k]||DOMT[k],g=SEEN[k].size;
if(T&&g<T&&settled(k))out.push(LABEL[k]+' '+(T-g)+'명은 목록에 안 나옴')});
return out};
const payload=()=>JSON.stringify({__ledger:1,at:Date.now(),users:Array.from(U.values())});
window.__ledger=()=>({followers:SEEN.followers.size,following:SEEN.following.size,fediverse:FEDI,
total:U.size,totals:{followers:TOT.followers||DOMT.followers,following:TOT.following||DOMT.following},
users:Array.from(U.values()),json:payload()});
cp.onclick=()=>{const miss=missing();
if(miss.length&&!ARMED){ARMED=true;MSG=miss.join(' · ')+' · 그래도 복사하려면 한 번 더';B();return}
ARMED=false;const out=payload();window.__ledgerResult=out;
toClip(out).then(()=>{MSG='복사했습니다 — 맞팔 확인에 붙여넣으세요';B()})
.catch(()=>{MSG='복사 실패 — copy(__ledger().json)';B()})};
scanDOM();B();document.body.appendChild(box);
setTimeout(()=>{scanDOM();B()},1500);
// 자동 수집이 놓쳤을 때 화면에 보이는 사람만 손으로 건지는 용도
window.__ledgerSweep=()=>{const n=sweepDOM();B();return n};
window.__ledgerStop=()=>{RUN=false;window.fetch=OF;XMLHttpRequest.prototype.send=OS;box.remove();
delete window.__ledgerStop;console.log('수집기 껐습니다')};
console.log('%c수집기 켜짐','font:600 13px monospace');
console.log('내 프로필에서 시작을 누르면 팔로워·팔로잉을 이어서 수집합니다.');
console.log('현재 상태 확인: __ledger() / 끄려면: __ledgerStop()')})()threads.com/@아이디)로 이동탭 자동 전환에 실패하면 팔로잉 목록을 직접 열고 시작을 한 번 더 누르세요. 수가 모자라면 목록을 손으로 끝까지 내린 뒤 __ledgerSweep()를 치면 화면에 보이는 사람을 다시 훑습니다. 진행 상태는 __ledger(), 끄려면 __ledgerStop().
수집기가 복사해준 내용을 그대로 붙여넣으세요. 붙여넣으면 바로 읽습니다.
내가 팔로우하는데 나를 팔로우하지 않는 사람. 팔로잉 목록까지 모아야 채워집니다.
저장해둔 수집이 없습니다. 지금 저장해두면 다음에 모았을 때 누가 떠났는지 알려줍니다.
저장한 기록은 이 브라우저에만 남고 서버로 가지 않습니다.