diff --git a/src/services/crawler.js b/src/services/crawler.js index c2f27e2..f11d2a9 100644 --- a/src/services/crawler.js +++ b/src/services/crawler.js @@ -17,45 +17,68 @@ const axiosInstance = axios.create({ * 사이트를 크롤링하고 DB에 저장 */ async function crawlSite(siteId) { - // 사이트 정보 조회 const { rows } = await db.query('SELECT * FROM sites WHERE id = $1', [siteId]); if (rows.length === 0) throw new Error(`Site ${siteId} not found`); const site = rows[0]; + const parseRules = site.parse_rules || {}; await logCrawl(siteId, 'crawl_start', `크롤링 시작: ${site.url}`); try { - // 1. HTML 가져오기 - const response = await axiosInstance.get(site.url); + // 1. 실제 크롤링할 URL 결정 (디스커버리 or 직접) + 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; - // 2. 파싱 규칙에 따라 데이터 추출 - const parseRules = site.parse_rules || {}; + // 3. 파싱 규칙에 따라 데이터 추출 const parsedData = parseHtml(rawHtml, parseRules); + parsedData.meta._crawled_url = targetUrl; - // 3. 렌더링용 HTML 생성 + // 4. 렌더링용 HTML 생성 const adsenseConfig = await getAdsenseConfig(site.adsense_config_id); const renderedHtml = renderPublicPage(site, parsedData, adsenseConfig); - // 4. DB 저장 + // 5. DB 저장 await db.query( `INSERT INTO crawl_results (site_id, raw_html, parsed_data, rendered_html, status) VALUES ($1, $2, $3, $4, 'success')`, [siteId, rawHtml, JSON.stringify(parsedData), renderedHtml] ); - // 5. 사이트 최종 크롤링 시간 업데이트 + // 6. 사이트 최종 크롤링 시간 업데이트 await db.query( 'UPDATE sites SET last_crawled_at = NOW(), updated_at = NOW() WHERE id = $1', [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) { - // 에러 기록 await db.query( `INSERT INTO crawl_results (site_id, status, error_message) VALUES ($1, 'error', $2)`, @@ -67,22 +90,16 @@ async function crawlSite(siteId) { } /** - * HTML 파싱 - parse_rules에 따라 데이터 추출 + * HTML 파싱 * * parse_rules 형식: * { - * "container": "table.easy-table tbody tr", // 반복 항목 컨테이너 CSS 셀렉터 - * "fields": { - * "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" } + * "discovery": { // (선택) 목록 페이지에서 최신 글 자동 탐색 + * "link_selector": "a.read-more" // "Read more" 링크 CSS 셀렉터 * }, - * "meta": { - * "title": { "selector": "h1.entry-title", "type": "text" }, - * "date": { "selector": "time.entry-date", "type": "attr", "attr": "datetime" } - * } + * "container": "table.easy-table tbody tr", + * "fields": { ... }, + * "meta": { ... } * } */ function parseHtml(html, rules) { @@ -160,7 +177,6 @@ function renderPublicPage(site, parsedData, adsenseConfig) { const rank = item.rank || item._index || (idx + 1); const rankClass = rank == 1 ? 'r1' : rank == 2 ? 'r2' : rank == 3 ? 'r3' : ''; - // 별점 + 태그 const stars = (item.features || '').match(/★/g); const starCount = stars ? stars.length : 0; const tagText = (item.features || '').replace(/★/g, '').trim(); @@ -180,7 +196,7 @@ function renderPublicPage(site, parsedData, adsenseConfig) { // 5번째 뒤 중간 광고 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) { `; } + // AdSense: client_id만 있으면 자동광고 동작 const adsenseScript = ads.client_id ? `` : ''; - 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 topAd = ads.client_id ? renderAdBlock(ads.client_id, ads.slots?.top) : ''; + const bottomAd = ads.client_id ? renderAdBlock(ads.client_id, ads.slots?.bottom) : ''; return ` @@ -297,12 +314,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans KR',sans `; } +/** + * 광고 블록 렌더링 - slotId 없으면 자동 광고만 + */ function renderAdBlock(clientId, slotId) { if (!clientId) return ''; + // slotId가 없으면 자동 광고 (AdSense가 알아서 배치) + const slotAttr = slotId ? ` data-ad-slot="${escapeHtml(slotId)}"` : ''; return `
-
Advertisement
- +
`; } diff --git a/views/admin/sites.ejs b/views/admin/sites.ejs index e3e8d37..423deb9 100644 --- a/views/admin/sites.ejs +++ b/views/admin/sites.ejs @@ -11,32 +11,55 @@ + + +