test
This commit is contained in:
33
Dockerfile
33
Dockerfile
@@ -1,24 +1,47 @@
|
|||||||
# 1. 베이스 이미지 선택 (Node.js 22 버전)
|
# 1. 베이스 이미지 선택 (Node.js 22 버전)
|
||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
|
|
||||||
# 2. 시스템 패키지 업데이트 및 필수 라이브러리 설치
|
# 2. Puppeteer 실행에 필요한 모든 시스템 의존성 라이브러리 설치 (완벽 버전)
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
gconf-service \
|
# Puppeteer Core Dependencies
|
||||||
|
ca-certificates \
|
||||||
|
fonts-liberation \
|
||||||
libasound2 \
|
libasound2 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
libatk1.0-0 \
|
libatk1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
libcups2 \
|
libcups2 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libexpat1 \
|
||||||
|
libfontconfig1 \
|
||||||
|
libgbm1 \
|
||||||
|
libgcc1 \
|
||||||
|
libgconf-2-4 \
|
||||||
libgdk-pixbuf2.0-0 \
|
libgdk-pixbuf2.0-0 \
|
||||||
|
libglib2.0-0 \
|
||||||
libgtk-3-0 \
|
libgtk-3-0 \
|
||||||
libnspr4 \
|
libnspr4 \
|
||||||
libnss3 \
|
libnss3 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libstdc++6 \
|
||||||
|
libx11-6 \
|
||||||
libx11-xcb1 \
|
libx11-xcb1 \
|
||||||
|
libxcb1 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxcursor1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxext6 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxi6 \
|
||||||
|
libxrandr2 \
|
||||||
|
libxrender1 \
|
||||||
libxss1 \
|
libxss1 \
|
||||||
libxtst6 \
|
libxtst6 \
|
||||||
ca-certificates \
|
|
||||||
fonts-liberation \
|
|
||||||
lsb-release \
|
lsb-release \
|
||||||
xdg-utils \
|
|
||||||
wget \
|
wget \
|
||||||
|
xdg-utils \
|
||||||
|
# Tesseract OCR
|
||||||
tesseract-ocr \
|
tesseract-ocr \
|
||||||
tesseract-ocr-eng \
|
tesseract-ocr-eng \
|
||||||
--no-install-recommends --quiet && \
|
--no-install-recommends --quiet && \
|
||||||
|
|||||||
204
attendance.js
Normal file
204
attendance.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
const { TesseractWorker } = require('tesseract.js');
|
||||||
|
const Jimp = require('jimp');
|
||||||
|
const db = require('./db');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const MAX_RUN_RETRIES = 5;
|
||||||
|
const MAX_ATTEND_RETRIES = 20;
|
||||||
|
|
||||||
|
class Attendance {
|
||||||
|
constructor(accountData) {
|
||||||
|
this.account = accountData;
|
||||||
|
this.browser = null;
|
||||||
|
this.page = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getISOTime() {
|
||||||
|
return new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async log(message) {
|
||||||
|
const logMessage = `[${this.account.DOMAIN_ACCNT_ID}] ${message}`;
|
||||||
|
console.log(logMessage);
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO ATNDNC_LOG (DOMAIN_SEQ_ID, DOMAIN_ACCNT_ID, ATNDNC_DT, LOG_MSG, LOG_DTTM) VALUES (?, ?, date('now', 'localtime'), ?, ?)` ,
|
||||||
|
[this.account.DOMAIN_SEQ_ID, this.account.DOMAIN_ACCNT_ID, message, this.getISOTime()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateNextStartTime() {
|
||||||
|
const { MESRMNT_STRT_TM, MESRMNT_END_TM, MESRMNT_TM_INCLSN_PRBLTY } = this.account;
|
||||||
|
const now = new Date();
|
||||||
|
now.setDate(now.getDate() + 1); // 내일 날짜
|
||||||
|
|
||||||
|
let hour;
|
||||||
|
const randomPercent = Math.random() * 100;
|
||||||
|
|
||||||
|
if (randomPercent < MESRMNT_TM_INCLSN_PRBLTY) {
|
||||||
|
// 지정된 시간 범위 내에서 랜덤 시간 생성
|
||||||
|
hour = Math.floor(Math.random() * (MESRMNT_END_TM - MESRMNT_STRT_TM + 1)) + MESRMNT_STRT_TM;
|
||||||
|
} else {
|
||||||
|
// 지정된 시간 범위 밖에서 랜덤 시간 생성
|
||||||
|
const isBefore = Math.random() < 0.5;
|
||||||
|
if (isBefore) {
|
||||||
|
hour = Math.floor(Math.random() * MESRMNT_STRT_TM);
|
||||||
|
} else {
|
||||||
|
hour = Math.floor(Math.random() * (24 - MESRMNT_END_TM)) + MESRMNT_END_TM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const minute = Math.floor(Math.random() * 60);
|
||||||
|
|
||||||
|
now.setHours(hour, minute, 0, 0);
|
||||||
|
return now.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStatus(status, isSuccess = false) {
|
||||||
|
if (isSuccess) {
|
||||||
|
const nextTime = this.calculateNextStartTime();
|
||||||
|
await db.run(
|
||||||
|
"UPDATE DOMAIN_ACCNT_LIST SET ATNDNC_STTS_CD = ?, LAST_ATNDNC_DTTM = ?, ATNDNC_STRT_DTTM = ? WHERE DOMAIN_SEQ_ID = ? AND DOMAIN_ACCNT_ID = ?",
|
||||||
|
[status, this.getISOTime(), nextTime, this.account.DOMAIN_SEQ_ID, this.account.DOMAIN_ACCNT_ID]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await db.run(
|
||||||
|
"UPDATE DOMAIN_ACCNT_LIST SET ATNDNC_STTS_CD = ? WHERE DOMAIN_SEQ_ID = ? AND DOMAIN_ACCNT_ID = ?",
|
||||||
|
[status, this.account.DOMAIN_SEQ_ID, this.account.DOMAIN_ACCNT_ID]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
let runSuccess = false;
|
||||||
|
let tryCount = 0;
|
||||||
|
|
||||||
|
while (!runSuccess && tryCount < MAX_RUN_RETRIES) {
|
||||||
|
if (tryCount > 0) {
|
||||||
|
await this.log(`${tryCount}번째 전체 재시도를 시작합니다...`);
|
||||||
|
const delay = Math.floor(Math.random() * 10000) + 1000;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
tryCount++;
|
||||||
|
|
||||||
|
const dialogListener = async (dialog) => {
|
||||||
|
await this.log(`팝업창 발견: "${dialog.message()}". 자동으로 '확인'을 누릅니다.`);
|
||||||
|
await dialog.accept();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.browser = await puppeteer.launch({ headless: "new", args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
||||||
|
this.page = await this.browser.newPage();
|
||||||
|
this.page.on('dialog', dialogListener);
|
||||||
|
|
||||||
|
await this.page.setViewport({ width: 1920, height: 1080 });
|
||||||
|
await this.page.setUserAgent(this.account.USER_AGENT);
|
||||||
|
await this.page.evaluateOnNewDocument(() => {
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalActions = await db.query("SELECT * FROM ACT_LIST WHERE DOMAIN_SEQ_ID = ? AND RETRY_YN = 'N' ORDER BY ACT_ORD", [this.account.DOMAIN_SEQ_ID]);
|
||||||
|
const retryActions = await db.query("SELECT * FROM ACT_LIST WHERE DOMAIN_SEQ_ID = ? AND RETRY_YN = 'Y' ORDER BY ACT_ORD", [this.account.DOMAIN_SEQ_ID]);
|
||||||
|
|
||||||
|
for (const action of normalActions) await this.runAction(action);
|
||||||
|
|
||||||
|
if (retryActions.length > 0) {
|
||||||
|
let attendSuccess = false;
|
||||||
|
let attendTryCount = 0;
|
||||||
|
while (!attendSuccess && attendTryCount < MAX_ATTEND_RETRIES) {
|
||||||
|
if (attendTryCount > 0) await this.log(`출석 액션 ${attendTryCount}번째 재시도를 시작합니다...`);
|
||||||
|
attendTryCount++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const action of retryActions) await this.runAction(action);
|
||||||
|
attendSuccess = true;
|
||||||
|
} catch (error) {
|
||||||
|
await this.log(`출석 액션 실패: ${error.message}`);
|
||||||
|
if (attendTryCount >= MAX_ATTEND_RETRIES) throw new Error('출석 액션 재시도 횟수를 초과했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.log('모든 행동을 성공적으로 완료했습니다.');
|
||||||
|
await this.setStatus('1', true); // 성공 상태(1) 및 다음 시간 설정
|
||||||
|
runSuccess = true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await this.log(`전체 작업 ${tryCount}차 시도 실패: ${error.message}`);
|
||||||
|
if (tryCount >= MAX_RUN_RETRIES) {
|
||||||
|
await this.log('최대 재시도 횟수를 초과하여 작업을 중단합니다.');
|
||||||
|
await this.setStatus('9'); // 최종 실패 상태(9) 설정
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (this.page) this.page.removeListener('dialog', dialogListener);
|
||||||
|
if (this.browser) await this.browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runAction(action) {
|
||||||
|
const details = JSON.parse(action.ACT_DTL_JSON);
|
||||||
|
await this.log(`[액션: ${action.ACT_TYP_CD}] 시작`);
|
||||||
|
const xpathSelector = (xpath) => `xpath/${xpath}`;
|
||||||
|
|
||||||
|
switch (action.ACT_TYP_CD) {
|
||||||
|
case 'move':
|
||||||
|
await this.page.goto(this.account.DOMAIN_ADDRS + details.location, { waitUntil: 'networkidle2' });
|
||||||
|
break;
|
||||||
|
case 'input':
|
||||||
|
await this.page.waitForSelector(xpathSelector(details.xpath));
|
||||||
|
const value = details.column ? this.account[details.column] : details.value;
|
||||||
|
await this.page.type(xpathSelector(details.xpath), value);
|
||||||
|
break;
|
||||||
|
case 'click':
|
||||||
|
await this.page.waitForSelector(xpathSelector(details.xpath));
|
||||||
|
await this.page.click(xpathSelector(details.xpath));
|
||||||
|
break;
|
||||||
|
case 'if_captcha':
|
||||||
|
const el = await this.page.$(xpathSelector(details.img_xpath));
|
||||||
|
if (el) await this.solveCaptcha(details);
|
||||||
|
else await this.log('CAPTCHA가 없어 건너뜁니다.');
|
||||||
|
break;
|
||||||
|
case 'confirm':
|
||||||
|
await this.log('팝업창 확인(confirm) 액션을 대기합니다.');
|
||||||
|
await this.page.waitForTimeout(1000);
|
||||||
|
break;
|
||||||
|
case 'exec':
|
||||||
|
await this.page.evaluate(details.script);
|
||||||
|
break;
|
||||||
|
case 'sleep':
|
||||||
|
await this.page.waitForTimeout(parseInt(details.sec, 10) * 1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await this.page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async solveCaptcha(details) {
|
||||||
|
await this.log('CAPTCHA 해결을 시작합니다...');
|
||||||
|
const xpath = (path) => `xpath/${path}`;
|
||||||
|
const el = await this.page.$(xpath(details.img_xpath));
|
||||||
|
if (!el) throw new Error('CAPTCHA 이미지를 찾을 수 없습니다.');
|
||||||
|
const buffer = await el.screenshot();
|
||||||
|
const image = await Jimp.read(buffer);
|
||||||
|
image.grayscale().contrast(1);
|
||||||
|
const pBuffer = await image.getBufferAsync(Jimp.MIME_PNG);
|
||||||
|
const worker = await Tesseract.createWorker('eng');
|
||||||
|
const { data: { text } } = await worker.recognize(pBuffer);
|
||||||
|
const ocrText = text.replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
await this.log(`OCR 결과: ${ocrText}`);
|
||||||
|
await worker.terminate();
|
||||||
|
|
||||||
|
await this.page.type(xpath(details.input_xpath), ocrText);
|
||||||
|
await this.page.click(xpath(details.click_xpath));
|
||||||
|
|
||||||
|
// 캡차 성공 여부 검증
|
||||||
|
if (details.complate_msg) {
|
||||||
|
const result = await this.page.waitForEvent('dialog');
|
||||||
|
if (result.message() !== details.complate_msg) {
|
||||||
|
throw new Error("Captcha Error: 확인 메시지가 일치하지 않습니다.");
|
||||||
|
}
|
||||||
|
await result.accept();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Attendance;
|
||||||
30
bot.js
30
bot.js
@@ -1,6 +1,7 @@
|
|||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
const AESCipher = require('./crypto');
|
const AESCipher = require('./crypto');
|
||||||
// const Attendance = require('./attendance'); // 실제 자동화 로직 파일
|
const Attendance = require('./attendance');
|
||||||
|
const ua = require('fake-useragent');
|
||||||
|
|
||||||
async function runBot() {
|
async function runBot() {
|
||||||
console.log(`[${new Date().toISOString()}] 자동 출석 체크를 시작합니다...`);
|
console.log(`[${new Date().toISOString()}] 자동 출석 체크를 시작합니다...`);
|
||||||
@@ -28,17 +29,24 @@ async function runBot() {
|
|||||||
|
|
||||||
console.log(`총 ${accountsToProcess.length}개의 계정을 처리합니다. (최대 ${limit}개)`);
|
console.log(`총 ${accountsToProcess.length}개의 계정을 처리합니다. (최대 ${limit}개)`);
|
||||||
|
|
||||||
const tasks = accountsToProcess.map(account => {
|
for (const account of accountsToProcess) {
|
||||||
account.DECRYPTED_PSWRD = AESCipher.decrypt(account.DOMAIN_ACCNT_PSWRD);
|
// 상태를 '진행중(2)'으로 먼저 업데이트하여 다른 프로세스가 중복 실행하지 않도록 잠금
|
||||||
|
await db.run(
|
||||||
// TODO: 실제 자동화 로직(Puppeteer)을 실행하는 부분
|
"UPDATE DOMAIN_ACCNT_LIST SET ATNDNC_STTS_CD = '2' WHERE DOMAIN_SEQ_ID = ? AND DOMAIN_ACCNT_ID = ?",
|
||||||
// 예: return new Attendance(account).run();
|
[account.DOMAIN_SEQ_ID, account.DOMAIN_ACCNT_ID]
|
||||||
|
);
|
||||||
console.log(`[작업 실행] 계정: ${account.DOMAIN_ACCNT_ID} / 사이트: ${account.DOMAIN_ADDRS}`);
|
|
||||||
return Promise.resolve(); // 임시로 즉시 완료
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(tasks);
|
account.DOMAIN_ACCNT_PSWRD = AESCipher.decrypt(account.DOMAIN_ACCNT_PSWRD);
|
||||||
|
|
||||||
|
// DB에 저장된 값이 없으면, 여기서 새로운 User-Agent를 생성하여 account 객체에 주입
|
||||||
|
if (!account.USER_AGENT) {
|
||||||
|
account.USER_AGENT = ua();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 준비가 끝난 account 데이터를 전달하여 Attendance 작업 실행
|
||||||
|
const attendanceTask = new Attendance(account);
|
||||||
|
await attendanceTask.run();
|
||||||
|
}
|
||||||
|
|
||||||
console.log("모든 계정 처리가 완료되었습니다.");
|
console.log("모든 계정 처리가 완료되었습니다.");
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./:/app
|
- ./:/app
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
27
index.js
27
index.js
@@ -7,13 +7,11 @@ const { runBot } = require('./bot');
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3000;
|
const PORT = 3000;
|
||||||
|
|
||||||
// JSON 요청 본문을 파싱하기 위한 미들웨어
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
|
||||||
// ### 1. 크론 스케줄러 설정 ###
|
// ### 1. 크론 스케줄러 설정 ###
|
||||||
// 5분마다 runBot 함수를 실행
|
|
||||||
cron.schedule('*/5 * * * *', () => {
|
cron.schedule('*/5 * * * *', () => {
|
||||||
console.log(`[CRON] 스케줄된 작업을 실행합니다...`);
|
console.log(`[CRON] 스케줄된 작업을 실행합니다...`);
|
||||||
runBot();
|
runBot();
|
||||||
@@ -21,8 +19,6 @@ cron.schedule('*/5 * * * *', () => {
|
|||||||
|
|
||||||
|
|
||||||
// ### 2. 웹 서버 API (라우트) 설정 ###
|
// ### 2. 웹 서버 API (라우트) 설정 ###
|
||||||
|
|
||||||
// 기본 페이지: 계정 목록 조회
|
|
||||||
app.get('/', async (req, res) => {
|
app.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const accounts = await db.query(`
|
const accounts = await db.query(`
|
||||||
@@ -31,17 +27,22 @@ app.get('/', async (req, res) => {
|
|||||||
JOIN DOMAIN_LIST dl ON dal.DOMAIN_SEQ_ID = dl.DOMAIN_SEQ_ID
|
JOIN DOMAIN_LIST dl ON dal.DOMAIN_SEQ_ID = dl.DOMAIN_SEQ_ID
|
||||||
ORDER BY dal.DOMAIN_SEQ_ID, dal.DOMAIN_ACCNT_ID
|
ORDER BY dal.DOMAIN_SEQ_ID, dal.DOMAIN_ACCNT_ID
|
||||||
`);
|
`);
|
||||||
// 간단한 HTML 테이블 형태로 결과 표시
|
|
||||||
let html = `<h1>출석 계정 목록</h1>
|
let html = `<h1>출석 계정 목록</h1>
|
||||||
<table border="1">
|
<table border="1" style="width:100%; border-collapse: collapse;">
|
||||||
<tr><th>사이트</th><th>계정 ID</th><th>상태</th><th>다음 실행</th><th>사용여부</th></tr>`;
|
<tr style="background-color:#f2f2f2;">
|
||||||
|
<th style="padding: 8px;">사이트</th>
|
||||||
|
<th style="padding: 8px;">계정 ID</th>
|
||||||
|
<th style="padding: 8px;">상태</th>
|
||||||
|
<th style="padding: 8px;">다음 실행</th>
|
||||||
|
<th style="padding: 8px;">사용여부</th>
|
||||||
|
</tr>`;
|
||||||
accounts.forEach(acc => {
|
accounts.forEach(acc => {
|
||||||
html += `<tr>
|
html += `<tr>
|
||||||
<td>${acc.DOMAIN_NM}</td>
|
<td style="padding: 8px;">${acc.DOMAIN_NM}</td>
|
||||||
<td>${acc.DOMAIN_ACCNT_ID}</td>
|
<td style="padding: 8px;">${acc.DOMAIN_ACCNT_ID}</td>
|
||||||
<td>${acc.ATNDNC_STTS_CD}</td>
|
<td style="padding: 8px;">${acc.ATNDNC_STTS_CD}</td>
|
||||||
<td>${acc.ATNDNC_STRT_DTTM}</td>
|
<td style="padding: 8px;">${acc.ATNDNC_STRT_DTTM}</td>
|
||||||
<td>${acc.USE_YN}</td>
|
<td style="padding: 8px;">${acc.USE_YN}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
html += '</table>';
|
html += '</table>';
|
||||||
@@ -51,8 +52,6 @@ app.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: 계정 정보 수정, 추가, 삭제 등의 API 엔드포인트를 여기에 추가할 수 있습니다.
|
|
||||||
|
|
||||||
|
|
||||||
// ### 3. 웹 서버 실행 ###
|
// ### 3. 웹 서버 실행 ###
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|||||||
2285
package-lock.json
generated
2285
package-lock.json
generated
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
11
package.json
11
package.json
@@ -10,11 +10,14 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^16.4.5",
|
"body-parser": "^2.2.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"fake-useragent": "^1.0.1",
|
"fake-useragent": "^1.0.1",
|
||||||
"node-cron": "^3.0.3",
|
"jimp": "^1.6.0",
|
||||||
"puppeteer": "^22.12.1",
|
"node-cron": "^4.2.1",
|
||||||
"sqlite3": "^5.1.7"
|
"puppeteer": "^24.24.0",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"tesseract.js": "^6.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user