fix: 사이트 관리 페이지 개선 + 디스커버리 크롤링 + 광고 슬롯 선택화
- 디스커버리 크롤링: 태그/목록 페이지 URL에서 최신 글 링크 자동 탐색 (parse_rules.discovery.link_selector로 Read more 링크 찾기) - AdSense 슬롯 ID 선택사항: client_id만 있으면 자동 광고 동작 - 사이트 관리: 저장 후 목록 즉시 갱신 (await loadSites) - 사이트 관리: 크롤링 스케줄 설정 UI 추가 (크론 프리셋 버튼) - 사이트 관리: 미리보기 버튼 추가 (렌더링 + 파싱 데이터 확인) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+50
-29
@@ -17,45 +17,68 @@ const axiosInstance = axios.create({
|
|||||||
* 사이트를 크롤링하고 DB에 저장
|
* 사이트를 크롤링하고 DB에 저장
|
||||||
*/
|
*/
|
||||||
async function crawlSite(siteId) {
|
async function crawlSite(siteId) {
|
||||||
// 사이트 정보 조회
|
|
||||||
const { rows } = await db.query('SELECT * FROM sites WHERE id = $1', [siteId]);
|
const { rows } = await db.query('SELECT * FROM sites WHERE id = $1', [siteId]);
|
||||||
if (rows.length === 0) throw new Error(`Site ${siteId} not found`);
|
if (rows.length === 0) throw new Error(`Site ${siteId} not found`);
|
||||||
const site = rows[0];
|
const site = rows[0];
|
||||||
|
const parseRules = site.parse_rules || {};
|
||||||
|
|
||||||
await logCrawl(siteId, 'crawl_start', `크롤링 시작: ${site.url}`);
|
await logCrawl(siteId, 'crawl_start', `크롤링 시작: ${site.url}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. HTML 가져오기
|
// 1. 실제 크롤링할 URL 결정 (디스커버리 or 직접)
|
||||||
const response = await axiosInstance.get(site.url);
|
let targetUrl = site.url;
|
||||||
|
|
||||||
|
if (parseRules.discovery) {
|
||||||
|
// 디스커버리: 목록 페이지에서 최신 글 URL을 자동 탐색
|
||||||
|
await logCrawl(siteId, 'discovery', `목록 페이지에서 최신 글 탐색: ${site.url}`);
|
||||||
|
const listResponse = await axiosInstance.get(site.url);
|
||||||
|
const $list = cheerio.load(listResponse.data);
|
||||||
|
|
||||||
|
const selector = parseRules.discovery.link_selector || 'a.read-more, a.more-link, .entry-title a, article a';
|
||||||
|
const linkEl = $list(selector).first();
|
||||||
|
|
||||||
|
if (linkEl.length > 0) {
|
||||||
|
const href = linkEl.attr('href');
|
||||||
|
if (href) {
|
||||||
|
// 상대 URL -> 절대 URL
|
||||||
|
targetUrl = new URL(href, site.url).toString();
|
||||||
|
await logCrawl(siteId, 'discovery', `최신 글 발견: ${targetUrl}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await logCrawl(siteId, 'discovery_warn', `최신 글 링크를 찾을 수 없음. 원본 URL 사용: ${site.url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. HTML 가져오기
|
||||||
|
const response = await axiosInstance.get(targetUrl);
|
||||||
const rawHtml = response.data;
|
const rawHtml = response.data;
|
||||||
|
|
||||||
// 2. 파싱 규칙에 따라 데이터 추출
|
// 3. 파싱 규칙에 따라 데이터 추출
|
||||||
const parseRules = site.parse_rules || {};
|
|
||||||
const parsedData = parseHtml(rawHtml, parseRules);
|
const parsedData = parseHtml(rawHtml, parseRules);
|
||||||
|
parsedData.meta._crawled_url = targetUrl;
|
||||||
|
|
||||||
// 3. 렌더링용 HTML 생성
|
// 4. 렌더링용 HTML 생성
|
||||||
const adsenseConfig = await getAdsenseConfig(site.adsense_config_id);
|
const adsenseConfig = await getAdsenseConfig(site.adsense_config_id);
|
||||||
const renderedHtml = renderPublicPage(site, parsedData, adsenseConfig);
|
const renderedHtml = renderPublicPage(site, parsedData, adsenseConfig);
|
||||||
|
|
||||||
// 4. DB 저장
|
// 5. DB 저장
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO crawl_results (site_id, raw_html, parsed_data, rendered_html, status)
|
`INSERT INTO crawl_results (site_id, raw_html, parsed_data, rendered_html, status)
|
||||||
VALUES ($1, $2, $3, $4, 'success')`,
|
VALUES ($1, $2, $3, $4, 'success')`,
|
||||||
[siteId, rawHtml, JSON.stringify(parsedData), renderedHtml]
|
[siteId, rawHtml, JSON.stringify(parsedData), renderedHtml]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. 사이트 최종 크롤링 시간 업데이트
|
// 6. 사이트 최종 크롤링 시간 업데이트
|
||||||
await db.query(
|
await db.query(
|
||||||
'UPDATE sites SET last_crawled_at = NOW(), updated_at = NOW() WHERE id = $1',
|
'UPDATE sites SET last_crawled_at = NOW(), updated_at = NOW() WHERE id = $1',
|
||||||
[siteId]
|
[siteId]
|
||||||
);
|
);
|
||||||
|
|
||||||
await logCrawl(siteId, 'crawl_success', `크롤링 완료. ${parsedData.items?.length || 0}개 항목 추출`);
|
await logCrawl(siteId, 'crawl_success', `크롤링 완료 (${targetUrl}). ${parsedData.items?.length || 0}개 항목 추출`);
|
||||||
|
|
||||||
return { success: true, itemCount: parsedData.items?.length || 0 };
|
return { success: true, itemCount: parsedData.items?.length || 0, crawledUrl: targetUrl };
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 에러 기록
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO crawl_results (site_id, status, error_message)
|
`INSERT INTO crawl_results (site_id, status, error_message)
|
||||||
VALUES ($1, 'error', $2)`,
|
VALUES ($1, 'error', $2)`,
|
||||||
@@ -67,22 +90,16 @@ async function crawlSite(siteId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTML 파싱 - parse_rules에 따라 데이터 추출
|
* HTML 파싱
|
||||||
*
|
*
|
||||||
* parse_rules 형식:
|
* parse_rules 형식:
|
||||||
* {
|
* {
|
||||||
* "container": "table.easy-table tbody tr", // 반복 항목 컨테이너 CSS 셀렉터
|
* "discovery": { // (선택) 목록 페이지에서 최신 글 자동 탐색
|
||||||
* "fields": {
|
* "link_selector": "a.read-more" // "Read more" 링크 CSS 셀렉터
|
||||||
* "rank": { "selector": "td:nth-child(1)", "type": "text" },
|
|
||||||
* "name": { "selector": "td:nth-child(2)", "type": "text" },
|
|
||||||
* "url": { "selector": "td:nth-child(3) a", "type": "attr", "attr": "href" },
|
|
||||||
* "url_text": { "selector": "td:nth-child(3)", "type": "text" },
|
|
||||||
* "features": { "selector": "td:nth-child(4)", "type": "text" }
|
|
||||||
* },
|
* },
|
||||||
* "meta": {
|
* "container": "table.easy-table tbody tr",
|
||||||
* "title": { "selector": "h1.entry-title", "type": "text" },
|
* "fields": { ... },
|
||||||
* "date": { "selector": "time.entry-date", "type": "attr", "attr": "datetime" }
|
* "meta": { ... }
|
||||||
* }
|
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
function parseHtml(html, rules) {
|
function parseHtml(html, rules) {
|
||||||
@@ -160,7 +177,6 @@ function renderPublicPage(site, parsedData, adsenseConfig) {
|
|||||||
const rank = item.rank || item._index || (idx + 1);
|
const rank = item.rank || item._index || (idx + 1);
|
||||||
const rankClass = rank == 1 ? 'r1' : rank == 2 ? 'r2' : rank == 3 ? 'r3' : '';
|
const rankClass = rank == 1 ? 'r1' : rank == 2 ? 'r2' : rank == 3 ? 'r3' : '';
|
||||||
|
|
||||||
// 별점 + 태그
|
|
||||||
const stars = (item.features || '').match(/★/g);
|
const stars = (item.features || '').match(/★/g);
|
||||||
const starCount = stars ? stars.length : 0;
|
const starCount = stars ? stars.length : 0;
|
||||||
const tagText = (item.features || '').replace(/★/g, '').trim();
|
const tagText = (item.features || '').replace(/★/g, '').trim();
|
||||||
@@ -180,7 +196,7 @@ function renderPublicPage(site, parsedData, adsenseConfig) {
|
|||||||
|
|
||||||
// 5번째 뒤 중간 광고
|
// 5번째 뒤 중간 광고
|
||||||
if (idx === 4 && ads.client_id) {
|
if (idx === 4 && ads.client_id) {
|
||||||
cardsHtml += renderAdBlock(ads.client_id, ads.slots?.middle || '');
|
cardsHtml += renderAdBlock(ads.client_id, ads.slots?.middle);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,12 +222,13 @@ function renderPublicPage(site, parsedData, adsenseConfig) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdSense: client_id만 있으면 자동광고 동작
|
||||||
const adsenseScript = ads.client_id
|
const adsenseScript = ads.client_id
|
||||||
? `<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${escapeHtml(ads.client_id)}" crossorigin="anonymous"></script>`
|
? `<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${escapeHtml(ads.client_id)}" crossorigin="anonymous"></script>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const topAd = ads.client_id ? renderAdBlock(ads.client_id, ads.slots?.top || '') : '';
|
const topAd = ads.client_id ? renderAdBlock(ads.client_id, ads.slots?.top) : '';
|
||||||
const bottomAd = ads.client_id ? renderAdBlock(ads.client_id, ads.slots?.bottom || '') : '';
|
const bottomAd = ads.client_id ? renderAdBlock(ads.client_id, ads.slots?.bottom) : '';
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
@@ -297,12 +314,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans KR',sans
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 광고 블록 렌더링 - slotId 없으면 자동 광고만
|
||||||
|
*/
|
||||||
function renderAdBlock(clientId, slotId) {
|
function renderAdBlock(clientId, slotId) {
|
||||||
if (!clientId) return '';
|
if (!clientId) return '';
|
||||||
|
// slotId가 없으면 자동 광고 (AdSense가 알아서 배치)
|
||||||
|
const slotAttr = slotId ? ` data-ad-slot="${escapeHtml(slotId)}"` : '';
|
||||||
return `
|
return `
|
||||||
<div class="ad-box">
|
<div class="ad-box">
|
||||||
<div class="ad-label">Advertisement</div>
|
<ins class="adsbygoogle" style="display:block" data-ad-client="${escapeHtml(clientId)}"${slotAttr} data-ad-format="auto" data-full-width-responsive="true"></ins>
|
||||||
<ins class="adsbygoogle" style="display:block" data-ad-client="${escapeHtml(clientId)}" data-ad-slot="${escapeHtml(slotId)}" data-ad-format="auto" data-full-width-responsive="true"></ins>
|
|
||||||
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
|
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-21
@@ -11,32 +11,55 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 미리보기 모달 -->
|
||||||
|
<div class="modal-overlay" id="previewModal">
|
||||||
|
<div class="modal" style="max-width:900px">
|
||||||
|
<div class="flex-between mb-1">
|
||||||
|
<h3>공개 페이지 미리보기</h3>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="document.getElementById('previewModal').classList.remove('active')">닫기</button>
|
||||||
|
</div>
|
||||||
|
<div id="preview-tabs" class="flex mb-1">
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="showTab('rendered')">렌더링</button>
|
||||||
|
<button class="btn btn-sm btn-outline" onclick="showTab('parsed')">파싱 데이터</button>
|
||||||
|
</div>
|
||||||
|
<div id="tab-rendered"><iframe id="preview-iframe" style="width:100%;height:500px;border:1px solid var(--border);border-radius:var(--radius);background:#fff"></iframe></div>
|
||||||
|
<div id="tab-parsed" style="display:none"><pre id="preview-parsed" style="background:var(--bg);padding:1rem;border-radius:var(--radius);max-height:500px;overflow:auto;font-size:.78rem;white-space:pre-wrap"></pre></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 사이트 추가/수정 모달 -->
|
<!-- 사이트 추가/수정 모달 -->
|
||||||
<div class="modal-overlay" id="siteModal">
|
<div class="modal-overlay" id="siteModal">
|
||||||
<div class="modal">
|
<div class="modal" style="max-width:700px">
|
||||||
<h3 id="modal-title">사이트 추가</h3>
|
<h3 id="modal-title">사이트 추가</h3>
|
||||||
<input type="hidden" id="edit-id">
|
<input type="hidden" id="edit-id">
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>사이트명 *</label>
|
<label>사이트명 *</label>
|
||||||
<input id="f-name" placeholder="예: 토렌트 순위">
|
<input id="f-name" placeholder="예: 토렌트 순위">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>슬러그 (공개 URL용)</label>
|
<label>슬러그 (공개 URL: /s/여기)</label>
|
||||||
<input id="f-slug" placeholder="예: torrent-rank">
|
<input id="f-slug" placeholder="예: torrent-rank">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>크롤링 URL *</label>
|
<label>크롤링 URL *</label>
|
||||||
<input id="f-url" placeholder="http://jaewook.net/archives/2613">
|
<input id="f-url" placeholder="http://jaewook.net/archives/tag/토렌트순위">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>설명</label>
|
<label>설명</label>
|
||||||
<input id="f-desc" placeholder="사이트 설명">
|
<input id="f-desc" placeholder="사이트 설명 (공개 페이지 meta description)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>파싱 규칙 (JSON) - 아래 예시를 참고하세요</label>
|
<label>파싱 규칙 (JSON)</label>
|
||||||
<textarea id="f-rules" rows="12" style="font-size:.78rem">{
|
<textarea id="f-rules" rows="14" style="font-size:.78rem">{
|
||||||
|
"discovery": {
|
||||||
|
"link_selector": ".entry-title a"
|
||||||
|
},
|
||||||
"container": "table.easy-table tbody tr",
|
"container": "table.easy-table tbody tr",
|
||||||
"fields": {
|
"fields": {
|
||||||
"rank": { "selector": "td:nth-child(1)", "type": "text" },
|
"rank": { "selector": "td:nth-child(1)", "type": "text" },
|
||||||
@@ -50,11 +73,39 @@
|
|||||||
"date": { "selector": "time.entry-date", "type": "attr", "attr": "datetime" }
|
"date": { "selector": "time.entry-date", "type": "attr", "attr": "datetime" }
|
||||||
}
|
}
|
||||||
}</textarea>
|
}</textarea>
|
||||||
|
<div style="margin-top:.4rem;font-size:.75rem;color:var(--muted)">
|
||||||
|
<strong>discovery</strong>: 목록 페이지 URL을 넣고, 최신 글 링크를 자동 탐색.<br>
|
||||||
|
예) URL에 태그 페이지를 넣으면 → "Read more" 링크를 찾아 → 본문 크롤링
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 크롤링 스케줄 -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>크롤링 스케줄 (크론 표현식)</label>
|
||||||
|
<div class="form-row">
|
||||||
|
<input id="f-cron" placeholder="0 6 * * * (비워두면 수동)">
|
||||||
|
<div style="display:flex;align-items:center;gap:.5rem">
|
||||||
|
<label style="display:inline-flex;align-items:center;gap:.4rem;cursor:pointer;font-size:.85rem;white-space:nowrap">
|
||||||
|
<input type="checkbox" id="f-sched-active" style="width:auto"> 활성화
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cron-presets">
|
||||||
|
<span class="preset" onclick="document.getElementById('f-cron').value='*/5 * * * *'">5분마다</span>
|
||||||
|
<span class="preset" onclick="document.getElementById('f-cron').value='0 * * * *'">매시간</span>
|
||||||
|
<span class="preset" onclick="document.getElementById('f-cron').value='0 */6 * * *'">6시간마다</span>
|
||||||
|
<span class="preset" onclick="document.getElementById('f-cron').value='0 6 * * *'">매일 06시</span>
|
||||||
|
<span class="preset" onclick="document.getElementById('f-cron').value='0 6,12,18 * * *'">하루 3회</span>
|
||||||
|
<span class="preset" onclick="document.getElementById('f-cron').value='0 0 * * *'">매일 자정</span>
|
||||||
|
<span class="preset" onclick="document.getElementById('f-cron').value='0 6 * * 1'">매주 월요일</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>AdSense 설정</label>
|
<label>AdSense 설정</label>
|
||||||
<select id="f-adsense"><option value="">없음</option></select>
|
<select id="f-adsense"><option value="">없음</option></select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex" style="justify-content:flex-end;gap:.5rem;margin-top:1rem">
|
<div class="flex" style="justify-content:flex-end;gap:.5rem;margin-top:1rem">
|
||||||
<button class="btn btn-outline" onclick="closeModal()">취소</button>
|
<button class="btn btn-outline" onclick="closeModal()">취소</button>
|
||||||
<button class="btn btn-primary" onclick="saveSite()">저장</button>
|
<button class="btn btn-primary" onclick="saveSite()">저장</button>
|
||||||
@@ -64,10 +115,13 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
let sites = [];
|
let sites = [];
|
||||||
|
let adsenseList = [];
|
||||||
|
|
||||||
async function loadSites() {
|
async function loadSites() {
|
||||||
sites = await api('GET', '/api/sites');
|
[sites, adsenseList] = await Promise.all([
|
||||||
const adsenseList = await api('GET', '/api/adsense');
|
api('GET', '/api/sites'),
|
||||||
|
api('GET', '/api/adsense'),
|
||||||
|
]);
|
||||||
|
|
||||||
// AdSense 드롭다운
|
// AdSense 드롭다운
|
||||||
const sel = document.getElementById('f-adsense');
|
const sel = document.getElementById('f-adsense');
|
||||||
@@ -79,15 +133,17 @@ async function loadSites() {
|
|||||||
const sched = s.schedule_active
|
const sched = s.schedule_active
|
||||||
? '<span class="badge badge-success">' + s.cron_schedule + '</span>'
|
? '<span class="badge badge-success">' + s.cron_schedule + '</span>'
|
||||||
: '<span class="badge badge-danger">OFF</span>';
|
: '<span class="badge badge-danger">OFF</span>';
|
||||||
|
const hasResult = parseInt(s.crawl_count) > 0;
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td>' + s.id + '</td>' +
|
'<td>' + s.id + '</td>' +
|
||||||
'<td><a href="/admin/sites/' + s.id + '" style="color:var(--primary);font-weight:600">' + s.name + '</a></td>' +
|
'<td><strong style="color:var(--text)">' + s.name + '</strong></td>' +
|
||||||
'<td class="text-muted" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + s.url + '</td>' +
|
'<td class="text-muted" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + s.url + '</td>' +
|
||||||
'<td>' + (s.slug || '<span class="text-muted">-</span>') + '</td>' +
|
'<td>' + (s.slug ? '<a href="/s/' + s.slug + '" target="_blank" style="color:var(--primary)">' + s.slug + '</a>' : '<span class="text-muted">-</span>') + '</td>' +
|
||||||
'<td>' + sched + '</td>' +
|
'<td>' + sched + '</td>' +
|
||||||
'<td>' + timeAgo(s.last_crawled_at) + '</td>' +
|
'<td>' + timeAgo(s.last_crawled_at) + '</td>' +
|
||||||
'<td class="flex">' +
|
'<td class="flex" style="flex-wrap:nowrap">' +
|
||||||
'<button class="btn btn-success btn-sm" onclick="doCrawl(' + s.id + ',this)">크롤링</button>' +
|
'<button class="btn btn-success btn-sm" onclick="doCrawl(' + s.id + ',this)">크롤링</button>' +
|
||||||
|
(hasResult ? '<button class="btn btn-outline btn-sm" onclick="previewSite(' + s.id + ')">미리보기</button>' : '') +
|
||||||
'<button class="btn btn-outline btn-sm" onclick="editSite(' + s.id + ')">수정</button>' +
|
'<button class="btn btn-outline btn-sm" onclick="editSite(' + s.id + ')">수정</button>' +
|
||||||
'<button class="btn btn-danger btn-sm" onclick="deleteSite(' + s.id + ')">삭제</button>' +
|
'<button class="btn btn-danger btn-sm" onclick="deleteSite(' + s.id + ')">삭제</button>' +
|
||||||
'</td></tr>';
|
'</td></tr>';
|
||||||
@@ -101,7 +157,10 @@ function openAddModal() {
|
|||||||
document.getElementById('f-url').value = '';
|
document.getElementById('f-url').value = '';
|
||||||
document.getElementById('f-slug').value = '';
|
document.getElementById('f-slug').value = '';
|
||||||
document.getElementById('f-desc').value = '';
|
document.getElementById('f-desc').value = '';
|
||||||
|
document.getElementById('f-cron').value = '';
|
||||||
|
document.getElementById('f-sched-active').checked = false;
|
||||||
document.getElementById('f-adsense').value = '';
|
document.getElementById('f-adsense').value = '';
|
||||||
|
// 기본 파싱 규칙은 textarea에 이미 있으므로 건드리지 않음
|
||||||
document.getElementById('siteModal').classList.add('active');
|
document.getElementById('siteModal').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +174,8 @@ function editSite(id) {
|
|||||||
document.getElementById('f-slug').value = s.slug || '';
|
document.getElementById('f-slug').value = s.slug || '';
|
||||||
document.getElementById('f-desc').value = s.description || '';
|
document.getElementById('f-desc').value = s.description || '';
|
||||||
document.getElementById('f-rules').value = JSON.stringify(s.parse_rules || {}, null, 2);
|
document.getElementById('f-rules').value = JSON.stringify(s.parse_rules || {}, null, 2);
|
||||||
|
document.getElementById('f-cron').value = s.cron_schedule || '';
|
||||||
|
document.getElementById('f-sched-active').checked = s.schedule_active || false;
|
||||||
document.getElementById('f-adsense').value = s.adsense_config_id || '';
|
document.getElementById('f-adsense').value = s.adsense_config_id || '';
|
||||||
document.getElementById('siteModal').classList.add('active');
|
document.getElementById('siteModal').classList.add('active');
|
||||||
}
|
}
|
||||||
@@ -128,7 +189,7 @@ async function saveSite() {
|
|||||||
try {
|
try {
|
||||||
rules = JSON.parse(document.getElementById('f-rules').value || '{}');
|
rules = JSON.parse(document.getElementById('f-rules').value || '{}');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
toast('파싱 규칙 JSON이 올바르지 않습니다', 'error');
|
toast('파싱 규칙 JSON이 올바르지 않습니다: ' + e.message, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,38 +205,68 @@ async function saveSite() {
|
|||||||
if (!data.name || !data.url) { toast('사이트명과 URL은 필수입니다', 'error'); return; }
|
if (!data.name || !data.url) { toast('사이트명과 URL은 필수입니다', 'error'); return; }
|
||||||
|
|
||||||
const editId = document.getElementById('edit-id').value;
|
const editId = document.getElementById('edit-id').value;
|
||||||
|
let result;
|
||||||
if (editId) {
|
if (editId) {
|
||||||
await api('PUT', '/api/sites/' + editId, data);
|
result = await api('PUT', '/api/sites/' + editId, data);
|
||||||
toast('사이트가 수정되었습니다');
|
|
||||||
} else {
|
} else {
|
||||||
await api('POST', '/api/sites', data);
|
result = await api('POST', '/api/sites', data);
|
||||||
toast('사이트가 추가되었습니다');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.error) { toast('저장 실패: ' + result.error, 'error'); return; }
|
||||||
|
|
||||||
|
// 스케줄 저장
|
||||||
|
const siteId = editId || result.id;
|
||||||
|
const cronVal = document.getElementById('f-cron').value;
|
||||||
|
const schedActive = document.getElementById('f-sched-active').checked;
|
||||||
|
if (cronVal || schedActive) {
|
||||||
|
await api('PUT', '/api/sites/' + siteId + '/schedule', {
|
||||||
|
cron_schedule: cronVal,
|
||||||
|
schedule_active: schedActive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toast(editId ? '사이트 수정 완료' : '사이트 추가 완료');
|
||||||
closeModal();
|
closeModal();
|
||||||
loadSites();
|
await loadSites();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSite(id) {
|
async function deleteSite(id) {
|
||||||
if (!confirm('정말 삭제하시겠습니까? 모든 크롤링 데이터가 삭제됩니다.')) return;
|
if (!confirm('정말 삭제하시겠습니까? 모든 크롤링 데이터가 삭제됩니다.')) return;
|
||||||
await api('DELETE', '/api/sites/' + id);
|
await api('DELETE', '/api/sites/' + id);
|
||||||
toast('삭제되었습니다');
|
toast('삭제되었습니다');
|
||||||
loadSites();
|
await loadSites();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doCrawl(id, btn) {
|
async function doCrawl(id, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = '크롤링 중...';
|
btn.textContent = '진행중...';
|
||||||
try {
|
try {
|
||||||
const r = await api('POST', '/api/sites/' + id + '/crawl');
|
const r = await api('POST', '/api/sites/' + id + '/crawl');
|
||||||
if (r.error) throw new Error(r.error);
|
if (r.error) throw new Error(r.error);
|
||||||
toast('크롤링 완료! ' + (r.itemCount || 0) + '개 항목');
|
toast('크롤링 완료! ' + (r.itemCount || 0) + '개 항목' + (r.crawledUrl ? ' (' + r.crawledUrl.substring(0, 50) + ')' : ''));
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
toast('크롤링 실패: ' + e.message, 'error');
|
toast('크롤링 실패: ' + e.message, 'error');
|
||||||
}
|
}
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = '크롤링';
|
btn.textContent = '크롤링';
|
||||||
loadSites();
|
await loadSites();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewSite(siteId) {
|
||||||
|
// 최신 크롤링 결과 가져오기
|
||||||
|
const results = await api('GET', '/api/sites/' + siteId + '/results?limit=1');
|
||||||
|
if (!results.length) { toast('크롤링 결과가 없습니다', 'error'); return; }
|
||||||
|
|
||||||
|
const detail = await api('GET', '/api/results/' + results[0].id);
|
||||||
|
document.getElementById('preview-iframe').srcdoc = detail.rendered_html || '<p>렌더링 데이터 없음</p>';
|
||||||
|
document.getElementById('preview-parsed').textContent = JSON.stringify(detail.parsed_data, null, 2);
|
||||||
|
document.getElementById('previewModal').classList.add('active');
|
||||||
|
showTab('rendered');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTab(name) {
|
||||||
|
document.getElementById('tab-rendered').style.display = name === 'rendered' ? 'block' : 'none';
|
||||||
|
document.getElementById('tab-parsed').style.display = name === 'parsed' ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSites();
|
loadSites();
|
||||||
|
|||||||
Reference in New Issue
Block a user