그누보드5 유령봇
본문
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>GNUboard5 Ghost Visitor — 브라우저 봇</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,"Noto Sans KR",sans-serif;background:#0f1724;color:#e6eef8;padding:18px}
h1{font-size:18px;margin:0 0 10px}
.card{background:#0b1220;padding:12px;border-radius:8px;margin-bottom:12px;box-shadow:0 2px 8px rgba(0,0,0,0.5)}
label{display:block;margin:6px 0 2px;font-size:13px;color:#a8c0e0}
input[type=text], input[type=number], select {width:100%;padding:8px;border-radius:6px;border:1px solid #1f2a3a;background:#08101a;color:#e6eef8}
button{padding:8px 12px;border-radius:6px;border:none;background:#2b8be6;color:white;cursor:pointer;margin-right:8px}
.muted{color:#7f9bb0;font-size:13px}
pre{background:#071025;padding:8px;border-radius:6px;color:#cfe8ff;max-height:280px;overflow:auto}
.flex{display:flex;gap:8px}
.small{font-size:13px}
.status-dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px;vertical-align:middle}
.dot-idle{background:#999}
.dot-run{background:#2bd39b}
.dot-stop{background:#ffa94d}
.controls{margin-top:8px}
.tags{font-size:12px;color:#8fb0d8;margin-top:6px}
.footer{font-size:12px;color:#7b97b6;margin-top:12px}
a.link { color:#7fd0ff }
</style>
</head>
<body>
<h1>GNUboard5 Ghost Visitor — 브라우저용 유령 봇</h1>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<span id="statusDot" class="status-dot dot-idle"></span>
<strong id="statusText">대기 중</strong>
<div class="muted small">브라우저 탭에서 실행됩니다 — 동일 출처에서 호스팅하세요.</div>
</div>
<div>
<button id="startBtn">Start</button>
<button id="stopBtn" disabled>Stop</button>
</div>
</div>
</div>
<div class="card">
<label>시작 URL (같은 도메인 내 페이지)</label>
<input type="text" id="startUrl" value="/" placeholder="예: /, /bbs/board.php?bo_table=free" />
<label>게시판 링크 선택자 (CSS selector, 보통 게시판 목록의 링크)</label>
<input type="text" id="boardSelector" value="a[href*='board.php'], a[href*='bbs/board.php'], a.board, a.board-link" />
<label>게시글 링크 선택자 (CSS selector)</label>
<input type="text" id="postSelector" value="a[href*='view.php'], a[href*='bbs/view.php'], a.post, a.wr_subject a" />
<label>포함/제외할 href 패턴 (정규식, 쉼표로 구분, optional)</label>
<input type="text" id="includePattern" placeholder="예: /bbs/, /board.php" />
<input type="text" id="excludePattern" placeholder="예: /write.php, /login.php" />
<div class="flex">
<div style="flex:1">
<label>최소 대기(ms)</label>
<input type="number" id="minDelay" value="1500" />
</div>
<div style="flex:1">
<label>최대 대기(ms)</label>
<input type="number" id="maxDelay" value="4500" />
</div>
<div style="flex:1">
<label>최대 방문 수 (0 = 무제한)</label>
<input type="number" id="maxVisits" value="0" />
</div>
</div>
<div class="flex controls">
<button id="seedBtn">수동 시드 추가</button>
<button id="exportLogBtn">로그 내보내기</button>
<button id="clearLogBtn">로그 초기화</button>
</div>
<div class="tags">
<div>Tip: 로그인된 상태(쿠키 있음)로 실행하면 인증 페이지도 접근됩니다. 서버에 부담이 되지 않도록 딜레이를 충분히 주세요.</div>
</div>
</div>
<div class="card">
<label>진행 로그</label>
<pre id="log" aria-live="polite"></pre>
</div>
<script>
/*
GNUboard5 Ghost Visitor
- 동작 방식:
1) 시작 URL에서 HTML을 fetch
2) boardSelector, postSelector로 링크 수집
3) 방문 큐(visited set) 유지하며 FIFO 탐색
4) 각 방문마다 랜덤 지연, 페이지 fetch, 스크롤/머무르기 시뮬레이션
5) 설정으로 포함/제외 패턴, 최대 방문수 제어 가능
주의:
- 동일 출처(same-origin)에서 실행해야 합니다.
- 서버 부하를 주지 않도록 delay를 넉넉히(최소 1~1.5초 권장) 설정하세요.
*/
(function(){
// UI elements
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const logEl = document.getElementById('log');
const seedBtn = document.getElementById('seedBtn');
const exportLogBtn = document.getElementById('exportLogBtn');
const clearLogBtn = document.getElementById('clearLogBtn');
// Settings inputs
const startUrlInput = document.getElementById('startUrl');
const boardSelectorInput = document.getElementById('boardSelector');
const postSelectorInput = document.getElementById('postSelector');
const includePatternInput = document.getElementById('includePattern');
const excludePatternInput = document.getElementById('excludePattern');
const minDelayInput = document.getElementById('minDelay');
const maxDelayInput = document.getElementById('maxDelay');
const maxVisitsInput = document.getElementById('maxVisits');
// State
let running = false;
let queue = []; // URL strings to visit
let visited = new Set();
let visitCount = 0;
let logs = [];
let controller = null; // AbortController for fetches if needed
function appendLog(line){
const ts = new Date().toLocaleTimeString();
const text = `[${ts}] ${line}`;
logs.push(text);
logEl.textContent = logs.slice(-500).join('\\n'); // keep last 500 lines
logEl.scrollTop = logEl.scrollHeight;
console.log(text);
}
function setStatus(state){
if(state === 'running'){
running = true;
statusDot.className = 'status-dot dot-run';
statusText.textContent = '실행 중';
startBtn.disabled = true;
stopBtn.disabled = false;
} else if(state === 'stopped'){
running = false;
statusDot.className = 'status-dot dot-stop';
statusText.textContent = '중지됨';
startBtn.disabled = false;
stopBtn.disabled = true;
} else {
running = false;
statusDot.className = 'status-dot dot-idle';
statusText.textContent = '대기 중';
startBtn.disabled = false;
stopBtn.disabled = true;
}
}
// Utility: safe absolute URL join
function absolutize(href){
try {
return new URL(href, location.href).href;
} catch(err){
return null;
}
}
function buildRegexList(csv){
if(!csv) return null;
const parts = csv.split(',').map(s=>s.trim()).filter(Boolean);
if(parts.length === 0) return null;
// combine with OR, escape slashes? allow as patterns
try {
const joined = parts.map(p => {
// if p looks like a regex (starts and ends with /) -> use as-is
if(p.startsWith('/') && p.lastIndexOf('/')>0){
const last = p.lastIndexOf('/');
const pat = p.slice(1,last);
const flag = p.slice(last+1);
return `(${pat})`;
} else {
// escape special regex chars
const esc = p.replace(/[.*+?^${}()|[\\]\\\\]/g,"\\\\$&");
return `(${esc})`;
}
}).join('|');
return new RegExp(joined,'i');
} catch(e){
console.warn('pattern error', e);
return null;
}
}
// parse HTML text into document
function parseHTML(text){
const parser = new DOMParser();
return parser.parseFromString(text, 'text/html');
}
// random delay
function randDelay(minMs, maxMs){
const m = Math.max(0, Number(minMs) || 0);
const M = Math.max(m, Number(maxMs) || m);
return m + Math.floor(Math.random() * (M - m + 1));
}
// simulate reading/scrolling time on a fetched page
async function simulateReading(doc){
// approximate reading time based on text length
const bodyText = doc.body ? doc.body.innerText || '' : '';
const chars = Math.min(20000, bodyText.length);
// reading speed rough: 250 chars/sec => chars/250*1000 ms
const baseMs = Math.min(120000, Math.max(1000, Math.floor((chars/250)*1000)));
// add random jitter
const jitter = Math.floor(Math.random() * 2000) - 1000;
const total = Math.max(500, baseMs + jitter);
appendLog(`읽는 중... 예상 ${Math.round(total/1000)}초`);
// emulate incremental "scroll" events by waiting in chunks
const steps = Math.min(12, Math.max(1, Math.floor(total / 2000)));
for(let i=0;i<steps && running;i++){
await new Promise(r => setTimeout(r, Math.floor(total/steps)));
// dispatch a fake scroll event to the window (sometimes useful)
try{
window.dispatchEvent(new Event('scroll'));
}catch(e){}
}
}
// main visit function
async function visitUrl(url, selectors){
if(!running) return;
if(!url) return;
if(visited.has(url)) return;
// respect max visits
const maxVisits = Number(maxVisitsInput.value) || 0;
if(maxVisits > 0 && visitCount >= maxVisits) {
appendLog(`최대 방문수(${maxVisits}) 도달 — 중지 예정`);
stop();
return;
}
visited.add(url);
queue = queue.filter(x => x !== url);
visitCount++;
appendLog(`방문(${visitCount}): ${url}`);
controller = new AbortController();
const signal = controller.signal;
// add small randomized delay before fetch (simulate think/click)
const preDelay = randDelay(Number(minDelayInput.value), Number(maxDelayInput.value));
await new Promise(r => setTimeout(r, preDelay));
try {
const resp = await fetch(url, {method:'GET', credentials:'same-origin', signal});
const text = await resp.text();
const doc = parseHTML(text);
// optional: if page contains redirect meta refresh, follow once
const metaRefresh = doc.querySelector('meta[http-equiv="refresh"]');
if(metaRefresh){
const content = metaRefresh.getAttribute('content') || '';
const m = content.match(/url=(.+)/i);
if(m && m[1]){
const next = absolutize(m[1].trim());
if(next && !visited.has(next)){
appendLog('meta refresh 감지 — 리다이렉트 방문: ' + next);
queue.push(next);
}
}
}
// simulate reading/scrolling time
await simulateReading(doc);
// find links per selectors
try {
const {boardSelector, postSelector, includeRegex, excludeRegex} = selectors;
const foundAnchors = Array.from(doc.querySelectorAll('a[href]'));
for(const a of foundAnchors){
const href = a.getAttribute('href');
const abs = absolutize(href);
if(!abs) continue;
// only same origin
if(new URL(abs).origin !== location.origin) continue;
// exclude obvious assets (images, css, js) by extension
if(abs.match(/\\.(jpg|jpeg|png|gif|svg|css|js|woff2?|ico)(\\?|$)/i)) continue;
// apply include/exclude patterns
if(excludeRegex && excludeRegex.test(abs)) continue;
if(includeRegex && !includeRegex.test(abs)) continue;
// if matches board or post selector (on original doc, matching CSS is limited because we used generic anchors)
// We'll heuristically check href path for common keywords as fallback
const path = new URL(abs).pathname + (new URL(abs).search || '');
const boardHeur = /board|bbs|bo_table|bo_list|board.php|bbs\/board.php/i;
const postHeur = /view.php|wr_id|wr_subject|read.php|bbs\/view.php/i;
// Use provided selectors: if selector matches (we only had access to doc anchors), use that
let accepted = false;
try {
if(boardSelector && a.matches(boardSelector)) accepted = true;
if(postSelector && a.matches(postSelector)) accepted = true;
} catch(e){
// selector may be invalid for this document — ignore.
}
// heuristic fallback
if(!accepted){
if(boardHeur.test(path) || postHeur.test(path)) accepted = true;
}
if(accepted && !visited.has(abs) && !queue.includes(abs)){
queue.push(abs);
appendLog('큐에 추가: ' + abs);
}
}
} catch(e){
console.warn('링크 수집 실패', e);
}
} catch(err){
if(err.name === 'AbortError'){
appendLog('요청 중단: ' + url);
} else {
appendLog('방문 실패: ' + url + ' — ' + (err && err.message));
}
} finally {
controller = null;
}
}
// main loop
async function loop(){
setStatus('running');
const selectors = {
boardSelector: boardSelectorInput.value || null,
postSelector: postSelectorInput.value || null,
includeRegex: buildRegexList(includePatternInput.value),
excludeRegex: buildRegexList(excludePatternInput.value),
};
// seed with startUrl if queue empty
if(queue.length === 0){
const s = startUrlInput.value.trim() || '/';
const abs = absolutize(s) || location.href;
if(abs) queue.push(abs);
}
while(running && queue.length > 0){
const next = queue.shift();
if(!next) continue;
// skip if visited
if(visited.has(next)) continue;
// visit
await visitUrl(next, selectors);
// small randomized pause between visits (simulate human pause)
const idle = randDelay(Number(minDelayInput.value), Number(maxDelayInput.value));
await new Promise(r => setTimeout(r, idle));
// check max visits again
const maxV = Number(maxVisitsInput.value) || 0;
if(maxV > 0 && visitCount >= maxV){
appendLog('최대 방문수 도달 — 루프 종료');
break;
}
}
setStatus('stopped');
appendLog('작업 종료 — 큐 길이: ' + queue.length + ' 방문수: ' + visitCount);
}
function start(){
if(running) return;
appendLog('시작');
running = true;
visitCount = 0;
// do not clear visited by default (so it won't repeat); if you'd like fresh run, user can refresh page
loop().catch(err => {
appendLog('루프 에러: ' + (err && err.message));
setStatus('stopped');
});
}
function stop(){
if(!running) return;
appendLog('중지 요청됨 — 현재 요청을 취소합니다.');
running = false;
if(controller) {
try { controller.abort(); } catch(e){}
controller = null;
}
setStatus('stopped');
}
// seed manual (현재 startUrl 추가)
seedBtn.addEventListener('click', ()=>{
const s = prompt('시드로 추가할 URL을 입력하세요 (상대/절대 가능)', startUrlInput.value || '/');
if(!s) return;
const abs = absolutize(s);
if(!abs){ alert('잘못된 URL'); return; }
if(!queue.includes(abs) && !visited.has(abs)){ queue.push(abs); appendLog('수동 시드 추가: ' + abs); }
else appendLog('이미 큐에 존재하거나 방문됨: ' + abs);
});
startBtn.addEventListener('click', ()=>{
// small validation
const minD = Number(minDelayInput.value);
const maxD = Number(maxDelayInput.value);
if(isNaN(minD) || isNaN(maxD) || minD < 0 || maxD < 0 || maxD < minD){
alert('지연 설정이 유효하지 않습니다. Min/Max Delay를 확인하세요.');
return;
}
// ensure same origin start
const s = startUrlInput.value.trim() || '/';
const abs = absolutize(s);
if(!abs){
alert('시작 URL이 유효하지 않습니다.');
return;
}
if(new URL(abs).origin !== location.origin){
if(!confirm('시작 URL이 현재 도메인과 다릅니다. 동일 출처에서 실행해야 정상 동작합니다. 계속하시겠습니까?')) return;
}
if(!queue.includes(abs) && !visited.has(abs)) queue.unshift(abs);
start();
});
stopBtn.addEventListener('click', stop);
exportLogBtn.addEventListener('click', ()=>{
const blob = new Blob([logs.join('\\n')], {type:'text/plain;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ghost-visitor-log-' + (new Date().toISOString().slice(0,19).replace(/[:T]/g,'-')) + '.txt';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
});
clearLogBtn.addEventListener('click', ()=>{
if(!confirm('로그를 초기화 하시겠습니까?')) return;
logs = [];
logEl.textContent = '';
appendLog('로그 초기화 완료');
});
// keyboard shortcut: S to start/stop
window.addEventListener('keydown', (e)=>{
if(e.key === 's' || e.key === 'S'){
if(running) stop(); else start();
}
});
// initial status
setStatus('idle');
appendLog('로더 준비 완료 — 동일 출처에서 실행하세요.');
})();
</script>
<div class="footer">
<div>작성자: Ghost Visitor 스크립트 • 사용 전 서버 부하와 법적/윤리적 영향 확인 필수</div>
<div>문제/개선 요청 있으면 알려줘요.</div>
</div>
</body>
</html>
등록된 댓글이 없습니다.