This commit is contained in:
2026-08-18 11:28:54 +09:00
parent 2177452f52
commit d73a1dda56
7 changed files with 1573 additions and 1148 deletions
@@ -0,0 +1,152 @@
// 페이지 로딩 완료 후 실행
window.addEventListener('load', async () => {
// 1. URL 패턴 확인
const match = window.location.pathname.match(/\/novel\/(\d+)\/(\d+)/);
if (!match) return;
const bookId = match[1];
const chapterId = match[2];
console.log(`웹소설 스크래퍼: 책 ID [${bookId}] 감지됨.`);
// ⏳ 2. 1~2초 동안 '페이지 다운(80%)' 스크롤 실행
const initialScrollTime = Math.floor(Math.random() * (10000 - 1000 + 1)) + 8000;
console.log(`${(initialScrollTime / 1000).toFixed(1)}초 동안 큼직하게 훑어보며 대기합니다... 👀`);
await pageSkimScroll(initialScrollTime);
// 3. 데이터 스크래핑
const scrapedData = scrapePage();
if (!scrapedData) return;
// 4. 책 ID별 Storage 저장
chrome.storage.local.get(['scrapedBooks'], async (result) => {
let scrapedBooks = result.scrapedBooks || {};
if (!scrapedBooks[bookId]) {
scrapedBooks[bookId] = {
id: 'book_' + bookId,
fileName: scrapedData.novelTitle,
chapters: []
};
}
let bookData = scrapedBooks[bookId];
if (scrapedData.novelTitle !== '제목없음') {
bookData.fileName = scrapedData.novelTitle;
}
// 중복 챕터 검사
const isDuplicate = bookData.chapters.some(chap => chap.chapterId === chapterId);
if (isDuplicate) {
console.log(`⚠️ 이미 저장된 챕터입니다 (ID: ${chapterId}). 자동 진행을 중지합니다.`);
return;
}
// 챕터 추가
bookData.chapters.push({
chapterId: chapterId,
title: scrapedData.chapterTitle,
content: scrapedData.content
});
// Storage에 저장
scrapedBooks[bookId] = bookData;
chrome.storage.local.set({
scrapedBooks: scrapedBooks,
currentBookId: bookId
}, async () => {
console.log(`✅ [${scrapedData.novelTitle} - ${scrapedData.chapterTitle}] 저장 완료!`);
// ⏳ 5. 저장 완료 후 2~3초 랜덤 대기
const waitDelay = Math.floor(Math.random() * (5000 - 2000 + 1)) + 4000;
console.log(`${(waitDelay / 1000).toFixed(1)}초 대기 후 다음 챕터로 이동합니다... ⏳`);
await new Promise(resolve => setTimeout(resolve, waitDelay));
// '다음화' 버튼 클릭
const buttons = Array.from(document.querySelectorAll('a.btn.btn-black.btn-sm'));
const nextButton = buttons.find(btn => btn.innerText.trim().includes('다음화'));
if (nextButton) {
console.log("👉 다음 화 버튼 클릭 진행");
nextButton.click();
} else {
console.log("⚠️ '다음화' 버튼을 찾을 수 없거나 마지막 화입니다.");
}
});
});
});
// ==========================================
// 🚀 화면 높이의 약 80%씩 큼직하게 스크롤하는 함수
// ==========================================
function pageSkimScroll(duration) {
return new Promise((resolve) => {
let elapsed = 0;
const scrollLoop = () => {
if (elapsed >= duration) {
resolve();
return;
}
// 400ms ~ 700ms 사이의 랜덤한 대기 시간 (큼직하게 내리기 때문에 간격을 조금 더 줌)
const stepTime = Math.floor(Math.random() * (1700 - 400 + 1)) + 1400;
// 현재 브라우저 창 높이(Viewport)를 구함
const screenHeight = window.innerHeight;
// 75% ~ 85% 사이의 랜덤한 비율 설정 (평균 80%)
const scrollRatio = 0.75 + (Math.random() * 0.1);
const stepAmount = screenHeight * scrollRatio;
// 부드럽게 스크롤
window.scrollBy({ top: stepAmount, behavior: 'smooth' });
elapsed += stepTime;
setTimeout(scrollLoop, stepTime);
};
// 루프 시작
scrollLoop();
});
}
// ==========================================
// 스크래핑 헬퍼 함수
// ==========================================
function scrapePage() {
const titleEl = document.querySelector('.page-title h2');
let novelTitle = titleEl ? titleEl.innerText.trim() : '제목없음';
let chapterTitle = document.querySelector('.theme-novel-title');
chapterTitle = chapterTitle ? chapterTitle.innerText.trim() : '1화';
let novelContent = '';
const contentHost = document.querySelector('.theme-novel-content');
if (contentHost) {
if (contentHost.shadowRoot) {
const pTags = contentHost.shadowRoot.querySelectorAll('p');
const textArray = Array.from(pTags).map(p => p.innerText.trim());
novelContent = textArray.join('\n\n');
} else {
const template = contentHost.querySelector('template[shadowrootmode="open"]');
if (template && template.content) {
const pTags = template.content.querySelectorAll('p');
const textArray = Array.from(pTags).map(p => p.textContent.trim());
novelContent = textArray.join('\n\n');
}
}
}
if (!novelContent) {
console.warn('본문을 추출할 수 없습니다.');
return null;
}
return {
novelTitle: novelTitle,
chapterTitle: chapterTitle,
content: novelContent
};
}
@@ -0,0 +1,29 @@
{
"manifest_version": 3,
"name": "웹소설 스크래퍼 (나만의 서재용)",
"version": "1.2",
"description": "웹소설 챕터를 수집하여 서재 리더기용 JSON으로 다운로드합니다.",
"action": {
"default_popup": "popup.html"
},
"permissions": [
"activeTab",
"scripting",
"storage",
"downloads",
"unlimitedStorage"
],
"host_permissions": [
"https://api.telegram.org/*"
],
"content_scripts": [
{
"matches": [
"*://newtoki1.org/novel/*",
"*://*.newtoki1.org/novel/*"
],
"js": ["content.js"],
"run_at": "document_end"
}
]
}
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
body { width: 280px; padding: 15px; font-family: 'Malgun Gothic', sans-serif; background: #f9f9f9; }
h3 { margin-top: 0; text-align: center; color: #333; }
button { width: 100%; padding: 10px; margin-bottom: 8px; cursor: pointer; border: none; border-radius: 6px; font-weight: bold; font-size: 13px; transition: 0.2s; }
button:hover { opacity: 0.9; }
#btn-save { background: #3498db; color: white; }
#btn-download { background: #27ae60; color: white; }
#btn-telegram { background: #0088cc; color: white; } /* 텔레그램 시그니처 색상 */
#btn-clear { background: #e74c3c; color: white; }
.tg-config { background: #fff; padding: 10px; border-radius: 6px; margin-bottom: 10px; border: 1px solid #ddd; }
.tg-config input { width: 92%; padding: 6px; margin-top: 4px; margin-bottom: 8px; font-size: 11px; border: 1px solid #ccc; border-radius: 4px; }
.tg-config label { font-size: 11px; font-weight: bold; color: #555; }
#status { font-size: 12px; color: #666; text-align: center; margin-top: 5px; padding: 10px; background: #eee; border-radius: 6px; }
</style>
</head>
<body>
<h3>📖 소설 스크래퍼</h3>
<div class="tg-config">
<label>Bot Token:</label>
<input type="password" id="tg-token" placeholder="BotFather에게 받은 토큰">
<label>Chat ID:</label>
<input type="text" id="tg-chatid" placeholder="숫자로 된 Chat ID">
<button id="btn-save-config" style="background:#666; color:#fff; padding:5px; font-size:11px; margin-bottom:0;">⚙️ 텔레그램 설정 저장</button>
</div>
<button id="btn-save"> 현재 화면(챕터) 저장하기</button>
<button id="btn-download">📥 리더기용 JSON 다운로드</button>
<button id="btn-telegram">🚀 텔레그램으로 JSON 전송</button>
<button id="btn-clear">🗑️ 현재 소설 데이터 초기화</button>
<div id="status">대기 중...</div>
<script src="popup.js"></script>
</body>
</html>
@@ -0,0 +1,267 @@
document.addEventListener('DOMContentLoaded', () => {
updateStatus();
loadTelegramConfig();
// 1. [설정 저장] 버튼
document.getElementById('btn-save-config').addEventListener('click', saveTelegramConfig);
// 2. [저장하기] 버튼
// 2. [저장하기] 버튼
document.getElementById('btn-save').addEventListener('click', async () => {
let [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
chrome.scripting.executeScript({
target: { tabId: tab.id },
function: scrapePage
}, (results) => {
if (results && results[0] && results[0].result) {
const data = results[0].result;
const match = tab.url ? tab.url.match(/\/novel\/(\d+)\/(\d+)/) : null;
const bookId = match ? match[1] : 'manual_' + Date.now();
const chapterId = match ? match[2] : 'manual_chap_' + Date.now(); // 챕터 ID 추가
// chapterId를 같이 넘겨줍니다.
saveToExtensionStorage(data, bookId, chapterId);
}
});
});
// 3. [다운로드] 버튼
document.getElementById('btn-download').addEventListener('click', downloadJson);
// 4. [텔레그램 전송] 버튼
document.getElementById('btn-telegram').addEventListener('click', sendToTelegram);
// 5. [초기화] 버튼
document.getElementById('btn-clear').addEventListener('click', clearStorage);
});
// 텔레그램 설정 불러오기
function loadTelegramConfig() {
chrome.storage.local.get(['tgToken', 'tgChatId'], (result) => {
if (result.tgToken) document.getElementById('tg-token').value = result.tgToken;
if (result.tgChatId) document.getElementById('tg-chatid').value = result.tgChatId;
});
}
// 텔레그램 설정 저장
function saveTelegramConfig() {
const token = document.getElementById('tg-token').value.trim();
const chatId = document.getElementById('tg-chatid').value.trim();
chrome.storage.local.set({ tgToken: token, tgChatId: chatId }, () => {
alert("✅ 텔레그램 설정이 저장 되었습니다!");
});
}
// 🚀 텔레그램 API로 JSON 파일 전송 함수
function sendToTelegram() {
chrome.storage.local.get(['scrapedBooks', 'currentBookId', 'tgToken', 'tgChatId'], async (result) => {
const token = result.tgToken;
const chatId = result.tgChatId;
if (!token || !chatId) {
alert("⚠️ 먼저 텔레그램 Bot Token과 Chat ID를 설정하고 저장해주세요!");
return;
}
const books = result.scrapedBooks || {};
const currentBookId = result.currentBookId;
const bookData = currentBookId ? books[currentBookId] : null;
if (!bookData || bookData.chapters.length === 0) {
alert("전송할 데이터가 없습니다.");
return;
}
// 리더기 포맷 페이로드 생성
const payload = {
type: 'single_book',
book: bookData,
progress: { chapter: 0, page: 0, tags: [], memo: "스크래퍼 확장프로그램으로 수집됨", isFavorite: false, isCompleted: false }
};
const jsonString = JSON.stringify(payload, null, 2);
const safeFileName = bookData.fileName.replace(/[\\/:*?"<>|]/g, '_');
// JSON 문자열을 Blob 파일 객체로 변환
const blob = new Blob([jsonString], { type: 'application/json' });
// Multipart FormData 생성 (텔레그램 API 전송용)
const formData = new FormData();
formData.append('chat_id', chatId);
formData.append('document', blob, `book_${safeFileName}.json`);
formData.append('caption', `📖 [${bookData.fileName}]\n${bookData.chapters.length}화 수집 완료 JSON 파일입니다.`);
// 버튼 상태 변경
const btnTg = document.getElementById('btn-telegram');
btnTg.innerText = "⏳ 전송 중...";
btnTg.disabled = true;
try {
// Telegram sendDocument API 호출
const response = await fetch(`https://api.telegram.org/bot${token}/sendDocument`, {
method: 'POST',
body: formData
});
const resData = await response.json();
if (resData.ok) {
alert(`🚀 [${bookData.fileName}] 텔레그램으로 전송 성공!`);
} else {
alert(`❌ 전송 실패: ${resData.description}`);
}
} catch (error) {
console.error(error);
alert("❌ 네트워크 오류가 발생했습니다.");
} finally {
btnTg.innerText = "🚀 텔레그램으로 JSON 전송";
btnTg.disabled = false;
}
});
}
// 스크래핑 함수 (수동 저장용)
function scrapePage() {
const titleEl = document.querySelector('.page-title h2');
let novelTitle = titleEl ? titleEl.innerText.trim() : '제목없음';
let chapterTitle = document.querySelector('.theme-novel-title');
chapterTitle = chapterTitle ? chapterTitle.innerText.trim() : '1화';
let novelContent = '';
const contentHost = document.querySelector('.theme-novel-content');
if (contentHost) {
if (contentHost.shadowRoot) {
const pTags = contentHost.shadowRoot.querySelectorAll('p');
const textArray = Array.from(pTags).map(p => p.innerText.trim());
novelContent = textArray.join('\n\n');
} else {
const template = contentHost.querySelector('template[shadowrootmode="open"]');
if (template && template.content) {
const pTags = template.content.querySelectorAll('p');
const textArray = Array.from(pTags).map(p => p.textContent.trim());
novelContent = textArray.join('\n\n');
}
}
}
if (!novelContent) {
alert('본문을 추출할 수 없습니다.');
return null;
}
return {
novelTitle: novelTitle,
chapterTitle: chapterTitle,
content: novelContent
};
}
// 수동 저장 함수
function saveToExtensionStorage(scrapedData, bookId) {
chrome.storage.local.get(['scrapedBooks'], (result) => {
let scrapedBooks = result.scrapedBooks || {};
if (!scrapedBooks[bookId]) {
scrapedBooks[bookId] = {
id: 'book_' + bookId,
fileName: scrapedData.novelTitle,
chapters: []
};
}
let bookData = scrapedBooks[bookId];
// 챕터 ID 기준으로 중복 검사
const isDuplicate = bookData.chapters.some(chap => chap.chapterId === chapterId);
if (isDuplicate) {
alert('⚠️ 이미 저장된 챕터입니다!');
return;
}
bookData.chapters.push({
chapterId: chapterId,
title: scrapedData.chapterTitle,
content: scrapedData.content
});
scrapedBooks[bookId] = bookData;
chrome.storage.local.set({ scrapedBooks: scrapedBooks, currentBookId: bookId }, () => {
updateStatus();
alert(`✅ [${scrapedData.chapterTitle}] 저장 완료!`);
});
});
}
// 다운로드 함수
function downloadJson() {
chrome.storage.local.get(['scrapedBooks', 'currentBookId'], (result) => {
const books = result.scrapedBooks || {};
const currentBookId = result.currentBookId;
const bookData = currentBookId ? books[currentBookId] : null;
if (!bookData || bookData.chapters.length === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
const payload = {
type: 'single_book',
book: bookData,
progress: { chapter: 0, page: 0, tags: [], memo: "스크래퍼 확장프로그램으로 수집됨", isFavorite: false, isCompleted: false }
};
const jsonString = JSON.stringify(payload, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const safeFileName = bookData.fileName.replace(/[\\/:*?"<>|]/g, '_');
chrome.downloads.download({
url: url,
filename: `book_${safeFileName}.json`,
saveAs: true
});
});
}
// 초기화 함수
function clearStorage() {
chrome.storage.local.get(['scrapedBooks', 'currentBookId'], (result) => {
const books = result.scrapedBooks || {};
const currentBookId = result.currentBookId;
if (!currentBookId || !books[currentBookId]) {
alert("삭제할 수집 데이터가 없습니다.");
return;
}
const bookTitle = books[currentBookId].fileName;
if(confirm(`[${bookTitle}] 수집 데이터를 삭제하시겠습니까?`)) {
delete books[currentBookId];
chrome.storage.local.set({ scrapedBooks: books, currentBookId: null }, () => {
updateStatus();
});
}
});
}
// 상태창 업데이트
function updateStatus() {
chrome.storage.local.get(['scrapedBooks', 'currentBookId'], (result) => {
const books = result.scrapedBooks || {};
const currentBookId = result.currentBookId;
const book = currentBookId ? books[currentBookId] : null;
const count = book ? book.chapters.length : 0;
const title = book ? book.fileName : '없음';
document.getElementById('status').innerHTML = `
<strong>현재 선택 소설:</strong> ${title}<br>
<strong style="color:#27ae60; font-size:14px;">모인 챕터: ${count}화</strong>
`;
});
}
@@ -0,0 +1,85 @@
// ==========================================
// 1. 챕터를 로컬 스토리지에 누적 저장하는 함수
// ==========================================
function saveChapterToLocal(bookTitle, chapterTitle, chapterContent) {
const STORAGE_KEY = 'webReader_scraper_temp';
let bookData = JSON.parse(localStorage.getItem(STORAGE_KEY));
// 기존 데이터가 없거나, 다른 책을 스크랩하기 시작한 경우 초기화
if (!bookData || bookData.fileName !== bookTitle) {
bookData = {
id: 'book_scraped_' + Date.now(), // 고유 ID 생성
fileName: bookTitle,
chapters: []
};
console.log(`새로운 책 [${bookTitle}] 스크랩을 시작합니다.`);
}
// 중복 저장 방지 로직 (동일한 챕터 제목이 있는지 검사)
const isDuplicate = bookData.chapters.some(chap => chap.title === chapterTitle);
if (isDuplicate) {
console.warn(`⚠️ 이미 저장된 챕터입니다: ${chapterTitle}`);
return false;
}
// 챕터 데이터 추가
bookData.chapters.push({
title: chapterTitle,
content: chapterContent
});
// 변경된 데이터를 다시 스토리지에 저장
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookData));
console.log(`✅ [${bookTitle}] ${chapterTitle} 저장 완료! (현재 총 ${bookData.chapters.length}화)`);
return true;
}
// ==========================================
// 2. 누적된 데이터를 리더기 호환 JSON으로 다운로드하는 함수
// ==========================================
function downloadBookAsJson() {
const STORAGE_KEY = 'webReader_scraper_temp';
const bookData = JSON.parse(localStorage.getItem(STORAGE_KEY));
if (!bookData || !bookData.chapters || bookData.chapters.length === 0) {
alert("다운로드할 챕터 데이터가 없습니다. 먼저 챕터를 저장해주세요.");
return;
}
// ★ 리더기의 '개별 책 복원' 형식과 완벽히 일치하도록 페이로드 구성
const payload = {
type: 'single_book',
book: bookData,
progress: {
chapter: 0,
page: 0,
tags: [], // 리더기에서 불러올 때 재분석 기능으로 태그를 달 수 있습니다.
memo: "스크랩을 통해 추가된 소설입니다.",
isFavorite: false,
isCompleted: false
}
};
// JSON 파일로 변환 및 다운로드 처리
const jsonString = JSON.stringify(payload, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// 파일명에 사용할 수 없는 특수문자 제거
const safeFileName = bookData.fileName.replace(/[\\/:*?"<>|]/g, '_');
a.download = `book_${safeFileName}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// 다운로드 후 임시 데이터 정리
if (confirm("파일 다운로드가 완료되었습니다.\n다음 스크랩을 위해 임시 저장된 데이터를 초기화할까요?")) {
localStorage.removeItem(STORAGE_KEY);
console.log("임시 데이터가 초기화되었습니다.");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff