이 곳에 소개 된 것들은 모두 여기에서 볼 수 있습니다. click!

🎬 콘텐츠 커뮤니티 애니, 영화, 웹툰, 만화 관련 콘텐츠를 정리한 커뮤니티입니다. ✨ 애니 🎬 VOD 📱 웹툰 📚 만화&소설 ✨ 애니 🎬 VOD 📱 웹툰 📚 만화&소설 더보기 body { margin:0; font-family: Arial; background:#f5f6f7; } .cafe-container { max-width:700px; margin:auto; background:white; border:1px solid #ddd; } /* 헤더 */ .cafe-header { padding:15px; border-bottom:1px solid #eee; } .header-top { font-size:20px; font-weight:bold; margin-bottom:10px; } .header-img { width:100%; max-height:160px; object-fit:cover; border-radius:6px; } .header-desc { font-size:12px; color:#999; margin-top:8px; } /* 탭 */ .tabs { display:flex; border-bottom:1px solid #ddd; background:#fff; } .bottom-tabs { border-top:1px solid #ddd; } .tab { flex:1; text-align:center; padding:12px 5px; cursor:pointer; font-size:14px; } .tab.active { font-weight:bold; border-bottom:2px solid #ff5252; color:#ff5252; } /* 게시글 */ .post { padding:15px; border-bottom:1px solid #eee; cursor:pointer; } .post:hover { background:#fafafa; } .title { font-weight:bold; margin-bottom:6px; display:flex; align-items:center; } .meta { font-size:12px; color:#888; } .preview { font-size:14px; color:#444; } .row { display:flex; justify-content:space-between; } .thumb { width:100px; height:70px; object-fit:cover; margin-left:10px; border-radius:6px; } /* 보는중 표시 */ .watching-dot { color:#03c75a; font-size:14px; margin-right:6px; } /* 버튼 */ #loadMore { display:block; margin:20px auto; padding:12px; border:none; background:#ff5252; color:white; border-radius:6px; width:200px; cursor:pointer; } /* 모바일 */ @media (max-width:768px){ body { background:#fff; } .cafe-container { max-width:100%; margin:0; border:none; } .tabs { position:sticky; top:0; z-index:10; } .header-img { border-radius:0; } #loadMore { width:90%; } } var currentLabel = ""; // 처음에는 비워둡니다 (최신 글 감지 후 설정됨) var startIndex = 1; var maxResults = 10; var loading = false; var loadedLinks = {}; /* 단일 라벨 구조 */ const labelMap = { "애니": ["애니"], "영화": ["영화" ,"Vod", "vod", "드라마"], "웹툰": ["웹툰"], "만화": ["만화", "소설"] }; /* 시간 계산 함수 */ function timeAgo(dateStr){ var diff = (new Date() - new Date(dateStr)) / 1000; if(diff ]+>/g, ""); } function getSearchTitle(title){ return title .split("~")[0] .split("(")[0] .trim(); } function jsonpFetch(url){ return new Promise((resolve,reject)=>{ const callback="jsonp_"+Date.now()+"_"+Math.floor(Math.random()*10000); window[callback]=function(data){ resolve(data); delete window[callback]; script.remove(); }; const script=document.createElement("script"); script.onerror=function(){ reject(); delete window[callback]; script.remove(); }; script.src=url+ (url.indexOf("?")>-1?"&":"?")+ "alt=json-in-script&callback="+callback; document.body.appendChild(script); }); } /* 가장 최신 글의 라벨을 감지하여 기본 탭을 설정하는 함수 */ function detectLatestTabAndStart() { var btn = document.getElementById("loadMore"); if(btn) btn.innerText = "최신 글 확인 중..."; let allTargetLabels = []; for (let key in labelMap) { allTargetLabels.push(...labelMap[key]); } let checkUrls = []; allTargetLabels.forEach(label => { // [수정] 상대 경로 호출 checkUrls.push(`/feeds/posts/default/-/${label}?alt=json&max-results=1`); // [오류 수정] hanissss 블로그스팟 주소 형식을 올바른 피드 주소 규격으로 수정했습니다. checkUrls.push(`https://hanissss.blogspot.com/feeds/posts/default/-/${encodeURIComponent(label)}?max-results=1`); }); Promise.all( checkUrls.map(url => jsonpFetch(url) .catch(()=>null) ) ).then(datas => { let latestPostTime = 0; let detectedLabel = "애니"; datas.forEach(data => { if (data && data.feed && data.feed.entry) { let entry = data.feed.entry; if (!Array.isArray(entry)) entry = [entry]; if (entry.length > 0) { let post = entry[0]; let postTime = new Date(post.published.$t).getTime(); if (postTime > latestPostTime) { latestPostTime = postTime; let categories = post.category || []; if (!Array.isArray(categories)) categories = [categories]; let postLabels = categories.map(c => String(c.term || "").toLowerCase().trim()); outerLoop: for (let tabName in labelMap) { for (let label of labelMap[tabName]) { if (postLabels.includes(String(label).toLowerCase().trim())) { detectedLabel = tabName; break outerLoop; } } } } } } }); activateTabUI(detectedLabel); }) .catch(() => { activateTabUI("애니"); }); } /* 선택된 탭의 UI를 하이라이트하고 글을 로드하는 함수 */ function activateTabUI(label) { currentLabel = label; document.querySelectorAll(".tab").forEach(t => { t.classList.toggle("active", t.dataset.label === label); }); resetFeed(); loadPosts(); } /* 복수 주소 피드 및 데이터 가공 처리 로드 함수 */ function loadPosts(){ if(loading || !currentLabel) return; loading = true; var btn = document.getElementById("loadMore"); var feed = document.getElementById("feed"); if(!btn || !feed) { loading = false; return; } btn.innerText = "로딩 중..."; btn.disabled = true; const labels = labelMap[currentLabel]; if(!labels) { loading = false; btn.innerText = "더보기"; btn.disabled = false; return; } let fetchPromises = []; labels.forEach(label => { // ① [기존 유지] 상대 경로를 통해 본진 블로그 글을 그대로 가져옵니다. fetchPromises.push( fetch(`/feeds/posts/default/-/${label}?alt=json&start-index=${startIndex}&max-results=${maxResults}`) .then(r => r.ok ? r.json() : null) .then(data => ({ type: 'original', data: data })) .catch(() => ({ type: 'original', data: null })) ); // ② [오류 수정] 추가된 주소의 데이터 요청 경로를 정상 규격으로 수정했습니다. fetchPromises.push( jsonpFetch( `https://hanissss.blogspot.com/feeds/posts/default/-/${encodeURIComponent(label)}?start-index=${startIndex}&max-results=${maxResults}` ) .then(data=>({type:"additional",data:data})) .catch(()=>({type:"additional",data:null})) ); }); Promise.all(fetchPromises).then(results => { let entries = []; results.forEach(res => { if(res.data && res.data.feed && res.data.feed.entry) { let entryList = Array.isArray(res.data.feed.entry) ? res.data.feed.entry : [res.data.feed.entry]; entryList.forEach(entry => { entry.sourceType = res.type; entries.push(entry); }); } }); // 모든 수집된 글들을 발행일 기준 최신순으로 정렬 entries.sort((a, b) => new Date(b.published.$t) - new Date(a.published.$t)); entries.forEach(post => { var link = post.link.find(l=>l.rel==="alternate")?.href; if(!link || loadedLinks[link]) return; var postLabels = (post.category || []).map(c => c.term); var isWatching = postLabels.includes("내가 보고 있는 것"); // [필터링 규칙] 추가된 hanissss 블로그 글인데 '내가 보고 있는 것' 라벨이 없으면 제외합니다. // 기존 본진 블로그 글은 라벨과 관계없이 전부 통과시킵니다. if(post.sourceType === 'additional' && !isWatching) return; loadedLinks[link] = true; // 제목의 소괄호 () 및 내용 제거 처리 var originalTitle = post.title.$t; var cleanTitle = originalTitle.replace(/\s*\(.*?\)\s*/g, "").trim(); var content = stripHTML(post.content?.$t || ""); var preview = content.substring(0,80)+"..."; var date = timeAgo(post.published.$t); var thumb = post.media$thumbnail ? post.media$thumbnail.url.replace("s72-c","s200") : ""; var el = document.createElement("div"); el.className = "post"; var badge = isWatching ? ' ● ' : ''; el.innerHTML = ` ${badge}${cleanTitle} ${date} ${preview} ${thumb ? ` ` : ""} `; // 클릭 시 현재 페이지 검색기 열기 el.onclick = function(e){ e.preventDefault(); e.stopPropagation(); const title = getSearchTitle(originalTitle); if(window.mhOpenSearchKeyword){ window.mhOpenSearchKeyword( title, thumb ); } }; feed.appendChild(el); }); startIndex += maxResults; loading = false; btn.innerText = "더보기"; btn.disabled = false; if(entries.length === 0){ btn.style.display = "none"; } }) .catch((err)=>{ console.error(err); if(btn) { btn.innerText = "다시 시도"; btn.disabled = false; } loading = false; }); } /* 피드 목록 초기화 */ function resetFeed(){ var feed = document.getElementById("feed"); if(feed) feed.innerHTML = ""; loadedLinks = {}; startIndex = 1; } document.addEventListener("DOMContentLoaded", function() { document.querySelectorAll(".tab").forEach(tab=>{ tab.addEventListener("click", function(){ const label = this.dataset.label; activateTabUI(label); var topTabs = document.getElementById("topTabs"); if(topTabs) { topTabs.scrollIntoView({ behavior:"smooth" }); } }); }); detectLatestTabAndStart(); var loadMoreBtn = document.getElementById("loadMore"); if(loadMoreBtn) { loadMoreBtn.addEventListener("click", loadPosts); } }); (function(){ let done = false; function isMobile(){ return window.innerWidth #mh-media-wrap{ width:100%; margin:15px 0; font-family:sans-serif; } #mh-media-title, #mh-search-box, #mh-results, #mh-loading{ display:none !important; } .mh-item{ border-bottom:1px solid #eee; padding:15px 5px; } .mh-source{ font-size:11px; color:#999; margin-bottom:5px; } .mh-link{ display:block; color:#222; font-size:15px; font-weight:700; text-decoration:none; line-height:1.5; margin-bottom:10px; } .mh-btns{ display:flex; flex-wrap:wrap; gap:6px; } .mh-btn{ border:none; border-radius:7px; padding:6px 10px; color:#fff; font-size:11px; cursor:pointer; font-weight:700; } .mh-topbox{ background:#f7f7f7; border-radius:10px; padding:12px; margin-bottom:15px; } /* ========================= Floating Search ========================= */ #mh-floating-search{ position:fixed; right: -10px; bottom:70px; z-index:99999; display:flex; align-items:center; justify-content:center; width:40px; height:40px; background:transparent; border:none; border-radius:999px; overflow:hidden; transition:all .3s ease; } #mh-floating-search.open{ width:340px; height:45px; background:rgba(10,10,20,.85); backdrop-filter:blur(20px); border:2px solid rgba(120,200,255,.35); box-shadow: 0 0 20px rgba(0,180,255,.25), 0 0 40px rgba(120,0,255,.15); border-radius:999px; } #mh-float-btn{ width:40px; height:40px; border:none; background:none; display:flex; align-items:center; justify-content:center; cursor:pointer; padding:0; margin:0; flex-shrink:0; } #mh-float-btn img{ width:40px; height:40px; object-fit:contain; display:block; } #mh-float-input{ width:0; opacity:0; background:transparent; border:none; outline:none; color:#fff; font-size:16px; font-weight:600; letter-spacing:0.5px; text-shadow: 0 0 6px rgba(0,180,255,.4), 0 0 12px rgba(120,0,255,.2); } #mh-floating-search.open #mh-float-input{ width:240px; opacity:1; padding:0 16px; } #mh-float-input::placeholder{ color:rgba(200,220,255,.6); } /* ========================= Results Panel ========================= */ #mh-floating-results{ position:fixed; right:0; bottom:150px; width:420px; max-height:70vh; overflow-y:auto; background:rgba(255,255,255,.72); backdrop-filter:blur(18px); -webkit-backdrop-filter:blur(18px); border:1px solid rgba(255,255,255,.35); border-radius:18px; box-shadow: 0 10px 40px rgba(0,0,0,.15), 0 2px 10px rgba(255,255,255,.25) inset; z-index:99998; display:none; } #mh-floating-results::before{ content:""; position:absolute; top:0; left:0; width:100%; height:1px; background: linear-gradient(90deg, transparent, rgba(0,255,255,.5), transparent); } #mh-floating-results .mh-item{ padding:12px; } @media(max-width:768px){ #mh-floating-results{ left:10px; right:10px; width:auto; bottom:160px; max-height:55vh; border-top:1px solid rgba(0,255,255,.2); box-shadow: 0 10px 40px rgba(0,0,0,.6), 0 0 20px rgba(0,255,255,.08); } } #mh-overlay{ display:none; } @media(max-width:768px){ #mh-overlay{ position:fixed; inset:0; /*background:rgba(0,0,0,.75);*/ z-index:99997; display:none; } #mh-overlay.show{ display:block; } } @media(max-width:768px){ #mh-floating-search.open{ left:50% !important; right:auto !important; transform:translateX(-50%); } } .mh-ready{ border:2px solid transparent !important; background: linear-gradient(#0b0b12,#0b0b12) padding-box, linear-gradient( 90deg, #4285f4, #34a853, #fbbc05, #ea4335, #4285f4 ) border-box; animation: mhRainbow 3s linear infinite; } @keyframes mhRainbow{ 0%{ filter:hue-rotate(0deg); } 100%{ filter:hue-rotate(360deg); } } .mh-star-ready{ position:relative; } .mh-star-ready::before, .mh-star-ready::after{ content:"\2726"; position:absolute; color:#fff; font-size:14px; animation: mhStarFall 1.8s infinite linear; } .mh-star-ready::before{ left:5px; top:-10px; } .mh-star-ready::after{ right:5px; top:-20px; animation-delay:.9s; } @keyframes mhStarFall{ 0%{ opacity:0; transform: translateY(-10px) scale(.4); } 20%{ opacity:1; } 100%{ opacity:0; transform: translateY(40px) scale(1.2); } } #mh-live-feed{ position:fixed; right:0; bottom:160px; width:420px; height:200px; overflow:hidden; display:none; z-index:99998; background:rgba(0,0,0,.55); backdrop-filter:blur(10px); border-radius:16px; } @media(max-width:768px){ #mh-live-feed{ left:10px; right:10px; width:auto; bottom:150px; height:200px; } } #mh-live-feed.show{ display:block; } .mh-feed-inner{ animation: mhFeedDown var(--mh-feed-duration,30s) linear infinite; } .mh-feed-item{ padding:10px 14px; color:#fff; font-size:13px; border-bottom:1px solid rgba(255,255,255,.08); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } @keyframes mhFeedDown{ 0%{ transform:translateY(-50%); } 100%{ transform:translateY(0); } } #mh-tag-suggest{ position:fixed; right:0; bottom:360px; width:420px; max-height:250px; overflow-y:auto; background:#fff; border-radius:12px; display:none; z-index:999999; box-shadow:0 5px 20px rgba(0,0,0,.2); } .mh-tag-item{ padding:10px 12px; cursor:pointer; border-bottom:1px solid #eee; } .mh-tag-item:hover{ background:#f5f5f5; } .mh-tag-count{ float:right; color:#999; font-size:11px; } #mh-autocomplete{ position:fixed; right:0; bottom:45px; width:170px; /* 기존 340px → 반으로 */ max-height:220px; overflow-y:auto; background:#fff; border-radius:12px 0 0 12px; box-shadow:0 5px 20px rgba(0,0,0,.2); display:none; z-index:100000; } .mh-auto-item{ padding:10px 12px; cursor:pointer; border-bottom:1px solid #eee; font-size:14px; line-height:1.4; word-break:break-word; /* 긴 태그 줄바꿈 */ white-space:normal; } .mh-auto-item:hover{ background:#f5f5f5; } @media(max-width:768px){ #mh-autocomplete{ left:auto; right:0; width:120px; bottom:120px; max-height:250px; } .mh-auto-item{ font-size:13px; white-space:normal; word-break:break-word; } } @media(max-width:768px){ /* 결과창 박스 자체의 하단 패딩을 늘려 내부 콘텐츠를 위로 밀어 올림 */ #mh-floating-results { padding-bottom: 10px !important; /* 검색창 글자 높이만큼 여백 확보 */ } /* 가장 아래에 있는 1층 아이템의 하단 마진을 주어 테두리에 붙지 않게 처리 */ #mh-floating-results .mh-item:last-child { margin-bottom: 1px !important; } } 🎬 미디어 검색기 🔍 게시글 수집 중... (function () { 'use strict'; /* ========================================================= 기본 설정 ========================================================= */ const MH_DB = []; const MH_SEEN = new Set(); const MH_CACHE_KEY = 'mh_media_cache_v3'; const ALLOWED_LABELS = new Set([ '애니', 'vod', '영화', '드라마', '웹툰', '만화', '소설' ]); const MH_BUTTONS = [ '애니', 'vod', '웹툰', '만화', '소설' ]; const MH_LABEL_MAP = { '애니': '애니', 'vod': 'VOD', '영화': 'VOD', '드라마': 'VOD', '웹툰': '웹툰', '만화': '만화', '소설': '소설' }; const MH_BUTTON_COLORS = { '애니': '#ff6b6b', 'VOD': '#4dabf7', '웹툰': '#20c997', '만화': '#f59f00', '소설': '#845ef7' }; const BASE_MAP = { main: 'https://xehostel.blogspot.com/p/go.html', webtoon: 'https://hanissss.blogspot.com/p/webtoons-page.html', ani: 'https://hanissss.blogspot.com/p/anis-page.html' }; const SCROLL_ID = '#redirect-bridge'; /* ========================================================= DOM 헬퍼 ========================================================= */ const $ = selector => document.querySelector(selector); const byId = id => document.getElementById(id); /* ========================================================= 문자열 / 제목 처리 ========================================================= */ function mhEscape(str) { return String(str ?? '') .replace(/\\/g, '\\\\') .replace(/'/g, "\\'") .replace(/"/g, '\\"'); } /* * 사이트별 제목 정리 * * xehostel: * 제목 ~ 이후 제거 * * hanissss: * 제목 ( 이후 제거 */ function mhCleanTitle(title, source) { const value = String(title ?? ''); if (source === 'xehostel') { return value .split('~')[0] .trim(); } return value .split('(')[0] .trim(); } /* * 비교용 제목 정규화 */ function mhNormalizeTitle(title) { return String(title ?? '') .split('~')[0] .split('(')[0] .toLowerCase() .replace(/[^\p{L}\p{N}]/gu, '') .trim(); } /* * 검색 URL에 사용할 제목 */ function mhGetSearchTitle(post) { if (!post) return ''; return mhCleanTitle( post.title, post.source ); } /* ========================================================= 라벨 처리 ========================================================= */ function mhGetLabels(post) { return Array.isArray(post?.labels) ? post.labels : []; } function mhHasAllowedLabel(post) { return mhGetLabels(post) .map(label => String(label).toLowerCase().trim() ) .some(label => ALLOWED_LABELS.has(label) ); } function isAllowedPost(post) { return mhHasAllowedLabel(post); } /* * 최신 피드용 추가 필터 * * hanissss의 '야...' 라벨 제외 */ function isAllowedLatestPost(post) { if (!mhHasAllowedLabel(post)) { return false; } if (post.source === 'hanissss') { const blocked = mhGetLabels(post) .map(label => String(label).toLowerCase().trim() ) .some(label => label.startsWith('야') ); if (blocked) { return false; } } return true; } /* * en:영문명 라벨 추출 */ function mhGetEnLabel(labels) { if (!Array.isArray(labels)) { return ''; } for (const label of labels) { const value = String(label ?? ''); if ( value .toLowerCase() .startsWith('en:') ) { return value .substring(3) .trim(); } } return ''; } /* ========================================================= 외부 애니 제목 검색 ========================================================= */ async function mhSearchAniList(query) { if (!query) { return ''; } try { const response = await fetch( 'https://graphql.anilist.co', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: ` query ($search: String) { Page(perPage: 5) { media(search: $search) { title { romaji english native } } } } `, variables: { search: query } }) } ); if (!response.ok) { return ''; } const data = await response.json(); const media = data?.data?.Page?.media || []; for (const item of media) { const title = item?.title; const result = title?.romaji || title?.english || title?.native || ''; if (result) { return result; } } } catch (error) { console.log( 'AniList 검색 실패:', error ); } return ''; } async function mhSearchJikan(query) { if (!query) { return ''; } try { const url = 'https://api.jikan.moe/v4/anime?' + new URLSearchParams({ q: query, limit: 5 }); const response = await fetch(url); if (!response.ok) { return ''; } const data = await response.json(); const items = data?.data || []; for (const item of items) { const result = item.title_english || item.title || ''; if (result) { return result; } } } catch (error) { console.log( 'Jikan 검색 실패:', error ); } return ''; } async function mhResolveBestTitle(post) { const enLabel = mhGetEnLabel( post?.labels ); if (enLabel) { return enLabel; } const title = mhCleanTitle( post?.title, post?.source ); if (!title) { return ''; } /* * 이미 영문 제목이면 API 호출하지 않음 */ if (!/[가-힣]/.test(title)) { return title; } const aniListTitle = await mhSearchAniList(title); if (aniListTitle) { return aniListTitle; } const jikanTitle = await mhSearchJikan(title); if (jikanTitle) { return jikanTitle; } return title; } /* ========================================================= Danbooru 검색용 태그 ========================================================= */ function mhToDanbooruTag(title) { if (!title) { return ''; } return String(title) .trim() .toLowerCase() .replace(/['"]/g, '') .replace(/[^a-z0-9가-힣\s_-]/gi, '') .replace(/\s+/g, '_'); } async function mhGetGalleryTag(post) { const enLabel = mhGetEnLabel( post?.labels ); if (enLabel) { return enLabel .trim() .toLowerCase() .replace(/\s+/g, '_'); } const title = await mhResolveBestTitle(post); return mhToDanbooruTag(title); } async function mhFallbackGalleryTag(post) { const title = mhCleanTitle( post?.title, post?.source ); if (!title) { return ''; } if (!/[가-힣]/.test(title)) { return mhToDanbooruTag(title); } let english = await mhSearchAniList(title); if (!english) { english = await mhSearchJikan(title); } if (!english) { return ''; } return mhToDanbooruTag(english); } /* ========================================================= 화면 준비 효과 ========================================================= */ function mhReadyEffect() { const wrap = byId('mh-floating-search'); const btn = byId('mh-float-btn'); wrap?.classList.add('mh-ready'); btn?.classList.add('mh-star-ready'); } /* ========================================================= 검색 자동완성 ========================================================= */ window.mhSelectSuggest = function (text) { const input = byId('mh-float-input'); if (!input) { return; } input.value = text; const box = byId('mh-autocomplete'); if (box) { box.style.display = 'none'; } mhSearch(); }; async function mhAutoComplete(query) { const box = byId('mh-autocomplete'); if (!box) { return; } const q = String(query ?? '').trim(); if (q.length { const title = mhCleanTitle( post.title, post.source ); if ( title .toLowerCase() .includes(q.toLowerCase()) ) { list.push(title); const en = mhGetEnLabel( post.labels ); if (en) { list.push(en); } } }); /* * 중복 제거 */ let suggestions = [...new Set(list)] .slice(0, 5); /* * AniList 추가 검색 */ try { const english = await mhSearchAniList(q); if ( english && !suggestions.some( value => value.toLowerCase() === english.toLowerCase() ) ) { suggestions.unshift( english ); } } catch (error) {} const html = suggestions .map(item => ` ${item} `) .join(''); box.innerHTML = html; box.style.display = html ? 'block' : 'none'; } /* ========================================================= 실시간 최신 피드 ========================================================= */ function mhShowLiveFeed() { const box = byId('mh-live-feed'); if ( !box || MH_DB.length === 0 ) { return; } const mediaLabels = [ '애니', 'vod', '영화', '드라마', '웹툰', '만화', '소설' ]; const latest = [...MH_DB] .filter( isAllowedLatestPost ) .filter(post => { const labels = mhGetLabels(post) .map(label => String(label) .toLowerCase() ); return labels.some( label => mediaLabels.includes(label) ); }) .sort( (a, b) => (b.date || 0) - (a.date || 0) ) .slice(0, 20); const duration = Math.max( latest.length * 2, 10 ); document.documentElement .style .setProperty( '--mh-feed-duration', duration + 's' ); /* * 두 번 복제하여 무한 스크롤 */ const items = [...latest, ...latest]; const html = ` ${ items.map(post => ` ✦ ${post.title} `).join('') } `; box.innerHTML = html; box.classList.add('show'); } function mhHideLiveFeed() { byId('mh-live-feed') ?.classList.remove('show'); } window.mhSelectFeed = function ( event, title, source ) { event?.stopPropagation(); const input = byId('mh-float-input'); if (!input) { return; } input.value = mhCleanTitle( title, source ); mhHideLiveFeed(); mhSearch(); }; /* ========================================================= 캐시 ========================================================= */ function mhSaveCache() { try { localStorage.setItem( MH_CACHE_KEY, JSON.stringify(MH_DB) ); } catch (error) { console.log( '캐시 저장 실패:', error ); } } function mhLoadCache() { try { const cache = JSON.parse( localStorage.getItem( MH_CACHE_KEY ) ); if ( !Array.isArray(cache) || cache.length === 0 ) { return false; } cache.forEach(post => { if (!post?.url) { return; } if (MH_SEEN.has(post.url)) { return; } MH_DB.push(post); MH_SEEN.add(post.url); }); return MH_DB.length > 0; } catch (error) { console.log( '캐시 로드 실패:', error ); return false; } } /* ========================================================= 블로그 데이터 수집 ========================================================= */ function mhJsonp(url) { return new Promise( function (resolve, reject) { const callback = 'mh_jsonp_' + Date.now() + '_' + Math.floor( Math.random() * 99999 ); const script = document.createElement( 'script' ); let finished = false; const cleanup = function () { if (finished) { return; } finished = true; delete window[callback]; script.remove(); }; const timer = setTimeout( function () { cleanup(); reject( new Error( 'JSONP timeout' ) ); }, 15000 ); window[callback] = function (data) { clearTimeout(timer); cleanup(); resolve(data); }; script.onerror = function () { clearTimeout(timer); cleanup(); reject( new Error( 'JSONP request failed' ) ); }; script.src = url + ( url.includes('?') ? '&' : '?' ) + 'callback=' + callback; document.body.appendChild( script ); } ); } /* ========================================================= Blogger 게시글 본문에서 모든 이미지 추출 ========================================================= */ function mhExtractImages(post) { const html = String( post?.content?.$t || post?.summary?.$t || '' ); if (!html) { return []; } const images = []; try { const doc = new DOMParser().parseFromString( html, 'text/html' ); const imgElements = doc.querySelectorAll('img'); imgElements.forEach(img => { let src = img.getAttribute('src') || img.getAttribute('data-src') || ''; /* * src가 없으면 srcset 사용 */ if (!src) { const srcset = img.getAttribute('srcset') || img.getAttribute('data-srcset') || ''; if (srcset) { const first = srcset .split(',') .map(v => v.trim()) .filter(Boolean)[0]; if (first) { src = first .split(/\s+/)[0]; } } } if (!src) { return; } /* * 상대 주소 처리 */ try { src = new URL( src, post?.link?.find?.( link => link.rel === 'alternate' )?.href || location.href ).href; } catch (error) {} /* * Blogger 이미지의 작은 썸네일 크기를 * 가능한 경우 큰 이미지로 변경 * * 예: * s72-c → s1600 * w400 → s1600 */ src = src .replace( /\/s\d+(?:-c)?\//, '/s1600/' ) .replace( /\/w\d+(?:-h\d+)?\//, '/s1600/' ); if ( !images.includes(src) ) { images.push(src); } }); } catch (error) { console.log( '이미지 추출 실패:', error ); } /* * content에서 img를 못 찾았을 경우 * Blogger thumbnail을 마지막으로 사용 */ if ( images.length === 0 && post?.media$thumbnail?.url ) { images.push( post.media$thumbnail.url .replace( 's72-c', 's1600' ) ); } return images; } async function mhFetchBlog( base, source, start = 1, targetDB = MH_DB, targetSeen = MH_SEEN ) { try { const url = base + '/feeds/posts/default' + '?alt=json-in-script' + '&max-results=150' + '&start-index=' + start; const data = await mhJsonp(url); const entries = data?.feed?.entry; if (!Array.isArray(entries)) { return; } entries.forEach(post => { const title = String( post?.title?.$t || '' ).trim(); if ( !title || title.length link.rel === 'alternate' ); if (!linkObj?.href) { return; } const url = linkObj.href; if ( targetSeen.has(url) ) { return; } targetSeen.add(url); /* * 대표 이미지 */ const thumbnail = post.media$thumbnail ? post.media$thumbnail.url .replace( 's72-c', 's800' ) : ''; /* * 게시글 본문의 모든 이미지 */ const images = mhExtractImages(post); const date = post.published ? new Date( post.published.$t ).getTime() : 0; const labels = Array.isArray( post.category ) ? post.category.map( category => category.term ) : []; targetDB.push({ /* * 기존 대표 이미지 */ thumbnail, /* * ★ 게시글의 모든 이미지 */ images, title, url, source, date, labels, search: title.toLowerCase() }); }); const total = parseInt( data ?.feed ?.openSearch$totalResults ?.$t, 10 ) || 0; if ( start + 150 (b.date || 0) - (a.date || 0) ); try { localStorage.setItem( MH_CACHE_KEY, JSON.stringify(tempDB) ); } catch (error) { console.log( '백그라운드 캐시 저장 실패:', error ); } /* * 실제 DB 교체 */ MH_DB.length = 0; MH_SEEN.clear(); tempDB.forEach(post => { MH_DB.push(post); if (post.url) { MH_SEEN.add(post.url); } }); console.log( '실시간 DB 갱신 완료:', MH_DB.length ); } /* ========================================================= 검색 결과 유사도 ========================================================= */ function mhSimilar( title, query ) { const a = String(title ?? '') .replace(/\s+/g, '') .toLowerCase(); const b = String(query ?? '') .replace(/\s+/g, '') .toLowerCase(); if (!a || !b) { return false; } return ( a.includes(b) || b.includes(a) || ( b.length >= 2 && a.includes( b.substring(0, 2) ) ) ); } function mhScore( title, query, source ) { const clean = mhCleanTitle( title, source ) .toLowerCase() .trim(); const q = String(query ?? '') .toLowerCase() .trim(); let score = 0; if (clean === q) { score += 10000; } if (clean.startsWith(q)) { score += 5000; } if (clean.includes(q)) { score += 3000; } const a = clean.replace( /\s+/g, '' ); const b = q.replace( /\s+/g, '' ); if (a === b) { score += 2500; } if (a.includes(b)) { score += 1500; } score += Math.max( 0, 100 - clean.length ); return score; } /* ========================================================= 검색 결과에서 대표 포스트 찾기 ========================================================= */ function mhFindPostByKeyword( keyword ) { const target = mhNormalizeTitle( keyword ); if (!target) { return null; } /* * ======================================================= * 1. 제목 정규화 완전 일치 * ======================================================= */ const exact = MH_DB.find( post => { if (!isAllowedPost(post)) { return false; } const title = mhNormalizeTitle( mhCleanTitle( post.title, post.source ) ); return title === target; } ); if (exact) { return exact; } /* * ======================================================= * 2. 실제로 검색어가 포함된 게시글만 후보로 사용 * * ★ 중요 * * 기존 코드는 후보가 없어도 * 무조건 첫 번째 DB 게시글을 반환했음. * * 이제는 제목에 실제 검색어가 들어간 경우만 * 대표 게시글로 인정. * ======================================================= */ const candidates = MH_DB .filter(isAllowedPost) .filter( post => { const title = mhNormalizeTitle( mhCleanTitle( post.title, post.source ) ); if (!title) { return false; } /* * 제목에 검색어가 포함 */ if ( title.includes(target) ) { return true; } /* * 검색어에 제목이 포함 */ if ( target.includes(title) ) { return true; } return false; } ); /* * ★ 후보 자체가 없으면 null * * 이것이 핵심. * * DB에 없는 검색어라면 * 절대로 다른 게시글을 반환하지 않음. */ if ( candidates.length === 0 ) { return null; } /* * ======================================================= * 3. 실제 일치 후보 중 가장 높은 점수 선택 * ======================================================= */ candidates.sort( (a, b) => mhScore( b.title, keyword, b.source ) - mhScore( a.title, keyword, a.source ) ); return candidates[0] || null; } /* ========================================================= 제목 / 이미지 / 주소 / 브라우저 제목 변경 ========================================================= */ function mhChangeHeaderImage( image ) { if (!image) { return; } const header = document.querySelector( '.header-img' ); if (header) { header.src = image; } } window.mhChangeHeaderImage = mhChangeHeaderImage; /* * post-title 변경 * * Blogger가 동적으로 제목을 다시 * 생성하는 경우 MutationObserver 사용 */ function mhSetPostTitle( title ) { if (!title) { return; } const find = () => document.querySelector( 'h3.post-title.entry-title' ); const apply = () => { const element = find(); if (!element) { return false; } element.textContent = title; return true; }; if (apply()) { return; } const observer = new MutationObserver( function () { if (apply()) { observer.disconnect(); } } ); observer.observe( document.body, { childList: true, subtree: true } ); /* * 무한 Observer 방지 */ setTimeout( () => observer.disconnect(), 10000 ); } /* * 현재 주소 변경 * * /p/go.html 등의 기존 pathname은 * 그대로 유지하고 q만 교체. */ function mhUpdateUrl( keyword ) { const value = String(keyword ?? '') .trim(); if (!value) { return; } const url = new URL( window.location.href ); url.searchParams.set( 'q', value ); /* * 기존 title 파라미터가 있으면 제거 */ url.searchParams.delete( 'title' ); history.replaceState( { q: value }, '', url.pathname + url.search + url.hash ); } /* * 핵심 함수 * * 검색어를 선택했을 때 * * ① 이미지 * ② 제목 * ③ 브라우저 제목 * ④ 주소 * ⑤ 검색창 * ⑥ 검색 결과 * * 를 한 번에 처리. */ /* ========================================================= 검색 상태 변경 ========================================================= */ function mhUpdateSearchState( keyword, post = null, options = {} ) { const value = String(keyword ?? '') .trim(); if (!value) { return; } /* * 대표 포스트 자동 찾기 */ if (!post) { post = mhFindPostByKeyword( value ); } /* * ★ 이미지 변경 조건 * * 반드시 DB에서 찾은 실제 게시글이고 * 실제 썸네일이 있을 때만 변경. * * DB에 없는 검색어: * → 이미지 변경 안 함 * * DB에 있지만 이미지가 없는 글: * → 이미지 변경 안 함 */ if ( post && post.thumbnail ) { mhChangeHeaderImage( post.thumbnail ); } /* * 검색창 */ const input = byId('mh-float-input'); if (input) { input.value = value; } /* * 현재 페이지 주소 */ if ( options.updateUrl !== false ) { mhUpdateUrl( value ); } /* * 본문 제목 */ if ( options.updatePostTitle !== false ) { mhSetPostTitle( value ); } /* * 브라우저 탭 제목 */ if ( options.updateDocumentTitle !== false ) { document.title = value + ' - 검색결과'; } } /* ========================================================= 검색어 열기 ========================================================= */ window.mhOpenSearchKeyword = function ( keyword, image, post ) { const value = String(keyword ?? '') .trim(); if (!value) { return; } /* * ★ 기존의 image 직접 적용 제거 * * 이전 코드: * * if (image) { * mhChangeHeaderImage(image); * } * * 이 방식 때문에 DB에 없는 검색어도 * 전달받은 특정 이미지로 변경될 수 있었음. * * 이제 이미지는 mhUpdateSearchState() * 내부에서 DB 게시글 + thumbnail이 * 존재할 경우에만 변경됨. */ mhUpdateSearchState( value, post, { updateUrl: true, updatePostTitle: true, updateDocumentTitle: true } ); /* * 검색 패널 열기 */ const wrap = byId( 'mh-floating-search' ); wrap?.classList.add( 'open' ); byId('mh-overlay') ?.classList.add( 'show' ); mhHideLiveFeed(); const auto = byId( 'mh-autocomplete' ); if (auto) { auto.style.display = 'none'; } /* * 검색 실행 */ mhSearch(); const panel = byId( 'mh-floating-results' ); if (panel) { panel.style.display = 'block'; } byId( 'mh-float-input' )?.focus(); }; /* ========================================================= 검색 페이지 열기 ========================================================= */ function mhOpenPage( type, site, keyword, labels = [], en = '' ) { const base = BASE_MAP[site]; if (!base) { return; } const q = encodeURIComponent( keyword ); let category = Array.isArray(labels) && labels.length ? labels.join('|') : ''; if (en) { category += category ? `|en:${en}` : `en:${en}`; } const finalUrl = `${base}?t=${type}` + `&q=${q}` + `&labels=${encodeURIComponent( category )}` + SCROLL_ID; const width = 800; const height = screen.availHeight; const left = (screen.width - width) / 2; window.open( finalUrl, '_blank', [ `width=${width}`, `height=${height}`, `left=${left}`, 'top=0', 'resizable=yes', 'scrollbars=yes' ].join(',') ); } /* ========================================================= 검색 버튼 처리 ========================================================= */ async function mhOpenSearch( type, keyword, post = null ) { const value = String(keyword ?? '') .trim(); if (!value) { return; } /* * post가 없는 상단 검색 버튼이면 * DB에서 대표 포스트를 찾음. */ if (!post) { post = mhFindPostByKeyword( value ); } /* * ★ 핵심 * * 애니/VOD/웹툰/만화/소설 * 버튼을 누를 때마다 * * 이미지 * 제목 * URL * document.title * * 모두 변경. */ mhUpdateSearchState( value, post ); /* * 검색 타입별 기존 이동 기능 유지 */ switch (type) { case '애니': mhOpenPage( 'l', 'main', value ); return; case 'VOD': case 'vod': mhOpenPage( 'n', 'main', value ); return; case '웹툰': mhOpenPage( 't', 'main', value ); return; case '만화': mhOpenPage( 'm', 'main', value, post?.labels || [], mhGetEnLabel( post?.labels ) ); return; case '소설': mhOpenPage( 'h', 'main', value ); return; default: window.open( 'https://www.google.com/search?q=' + encodeURIComponent(value), '_blank' ); } } window.mhOpenSearch = mhOpenSearch; /* ========================================================= 버튼 색상 ========================================================= */ function mhBtnColor( name ) { return ( MH_BUTTON_COLORS[name] || '#111' ); } /* ========================================================= 포스트 버튼 생성 ========================================================= */ function mhButtons( post ) { const cleanTitle = mhCleanTitle( post.title, post.source ); const postData = encodeURIComponent( JSON.stringify(post) ); const labels = mhGetLabels(post); const added = new Set(); let html = ' '; labels.forEach( function (label) { const lower = String(label) .toLowerCase() .trim(); Object.keys( MH_LABEL_MAP ).forEach( function (key) { if ( lower === key && !added.has( MH_LABEL_MAP[key] ) ) { const buttonName = MH_LABEL_MAP[key]; added.add( buttonName ); html += ` ${buttonName} `; } } ); } ); html += ' '; return html; } /* ========================================================= 검색 실행 ========================================================= */ function mhSearch() { const input = byId( 'mh-float-input' ); const resultEl = byId( 'mh-floating-results' ); if (!input || !resultEl) { return; } const q = input.value .trim() .toLowerCase(); if (!q) { resultEl.innerHTML = ''; resultEl.style.display = 'none'; return; } /* * 1차 검색 */ let results = MH_DB .filter( isAllowedPost ) .filter( function (post) { return ( post.search.includes(q) || mhSimilar( post.search, q ) ); } ); /* * 점수순 */ results.sort( function (a, b) { return ( mhScore( b.title, q, b.source ) - mhScore( a.title, q, a.source ) ); } ); let html = ''; /* * 상단 이동 버튼 */ html += ` "${q}" 검색 결과 (이동 가능) ${ MH_BUTTONS .map( function (button) { return ` ${ button === 'vod' ? 'VOD' : button } `; } ) .join('') } `; /* * 검색 결과 */ results .slice(0, 80) .forEach( function (post) { html += ` ${post.source} ${post.title} ${mhButtons(post)} `; } ); resultEl.innerHTML = html; resultEl.style.display = 'block'; } window.mhSearch = mhSearch; /* ========================================================= 외부 URL 파라미터 처리 ========================================================= */ function mhApplyExternalParams() { const params = new URLSearchParams( location.search ); const q = params.get('q'); const title = params.get('title'); if (!q && !title) { return; } const value = q || title; /* * 대표 이미지 복원 */ const found = mhFindPostByKeyword( value ); if (found?.thumbnail) { mhChangeHeaderImage( found.thumbnail ); } /* * 상태 복원 */ mhUpdateSearchState( value, found, { updateUrl: false, updatePostTitle: true, updateDocumentTitle: true } ); /* * 패널 열기 */ const wrap = byId( 'mh-floating-search' ); wrap?.classList.add( 'open' ); /* * 검색 */ setTimeout( function () { mhSearch(); const panel = byId( 'mh-floating-results' ); if (panel) { panel.style.display = 'block'; } }, 200 ); } /* ========================================================= 초기화 ========================================================= */ async function mhInit() { console.log( '미디어 검색기 시작' ); const hasCache = mhLoadCache(); const loading = byId( 'mh-loading' ); /* * 캐시가 있으면 즉시 표시 */ if (hasCache) { mhReadyEffect(); if (loading) { loading.innerHTML = '캐시 로드 : ' + MH_DB.length + '건'; } console.log( '캐시 사용:', MH_DB.length ); /* * URL 검색어 즉시 복원 */ mhApplyExternalParams(); /* * 백그라운드 갱신 */ mhRefreshCache(); return; } /* * 최초 수집 */ console.log( '최초 수집 시작' ); await mhFetchBlog( 'https://hanissss.blogspot.com', 'hanissss' ); await mhFetchBlog( 'https://xehostel.blogspot.com', 'xehostel' ); /* * 최신순 정렬 */ MH_DB.sort( (a, b) => (b.date || 0) - (a.date || 0) ); mhSaveCache(); mhReadyEffect(); /* * 외부 검색어 복원 */ mhApplyExternalParams(); if (loading) { loading.innerHTML = '수집 완료 : ' + MH_DB.length + '건'; } console.log( '수집 완료:', MH_DB.length ); } /* ========================================================= 검색창 열기 ========================================================= */ const floatWrap = byId( 'mh-floating-search' ); const floatBtn = byId( 'mh-float-btn' ); const floatInput = byId( 'mh-float-input' ); window.mhOpenSearchPanel = function () { if (!floatWrap) { return; } if ( !floatWrap.classList.contains( 'open' ) ) { floatWrap.classList.add( 'open' ); byId('mh-overlay') ?.classList.add( 'show' ); mhShowLiveFeed(); setTimeout( () => floatInput?.focus(), 150 ); return; } mhSearch(); }; if (floatBtn) { floatBtn.addEventListener( 'click', function () { if (!floatWrap) { return; } if ( !floatWrap.classList.contains( 'open' ) ) { floatWrap.classList.add( 'open' ); byId('mh-overlay') ?.classList.add( 'show' ); mhShowLiveFeed(); setTimeout( () => floatInput?.focus(), 150 ); return; } mhSearch(); } ); } /* ========================================================= 검색창 입력 ========================================================= */ let mhAutoTimer = null; if (floatInput) { floatInput.addEventListener( 'input', function () { mhHideLiveFeed(); clearTimeout( mhAutoTimer ); mhAutoTimer = setTimeout( () => mhAutoComplete( this.value ), 300 ); mhSearch(); } ); floatInput.addEventListener( 'keydown', function (event) { if ( event.key === 'Enter' ) { event.preventDefault(); mhSearch(); } } ); } /* ========================================================= 이벤트 위임 ========================================================= */ document.addEventListener( 'click', function (event) { /* * 미디어 버튼 */ const mediaButton = event.target.closest( '.mh-btn' ); if (mediaButton) { event.preventDefault(); event.stopPropagation(); try { let post = null; if ( mediaButton.dataset.post ) { post = JSON.parse( decodeURIComponent( mediaButton.dataset.post ) ); } const type = mediaButton.dataset.type; const keyword = mediaButton.dataset.keyword || byId( 'mh-float-input' )?.value || ''; mhOpenSearch( type, keyword, post ); } catch (error) { console.log( '버튼 데이터 오류:', error ); } return; } /* * 검색 결과의 글 링크 */ const resultLink = event.target.closest( '.mh-link' ); if (resultLink) { event.preventDefault(); event.stopPropagation(); const url = resultLink.dataset.postUrl; if (url) { const keyword = byId( 'mh-float-input' )?.value || ''; window.open( url + '?q=' + encodeURIComponent( keyword ), '_blank' ); } return; } /* * 최신 피드 */ const feedItem = event.target.closest( '.mh-feed-item' ); if (feedItem) { event.preventDefault(); event.stopPropagation(); mhSelectFeed( event, feedItem.dataset.title, feedItem.dataset.source ); return; } } ); /* ========================================================= 자동완성 외부 클릭 닫기 ========================================================= */ document.addEventListener( 'click', function (event) { const input = byId( 'mh-float-input' ); const autoBox = byId( 'mh-autocomplete' ); if ( !input || !autoBox ) { return; } if ( !input.contains( event.target ) && !autoBox.contains( event.target ) ) { autoBox.style.display = 'none'; } } ); /* ========================================================= 검색 패널 외부 클릭 ========================================================= */ document.addEventListener( 'click', function (event) { if (!floatWrap) { return; } const panel = byId( 'mh-floating-results' ); const feed = byId( 'mh-live-feed' ); const autoBox = byId( 'mh-autocomplete' ); /* * 버튼 클릭은 닫지 않음 */ if ( event.target.closest( '.mh-btn' ) ) { return; } if ( !floatWrap.contains( event.target ) && !(panel && panel.contains( event.target )) && !(feed && feed.contains( event.target )) && !(autoBox && autoBox.contains( event.target )) ) { panel && (panel.style.display = 'none'); floatWrap.classList.remove( 'open' ); feed?.classList.remove( 'show' ); if (autoBox) { autoBox.style.display = 'none'; } } } ); /* ========================================================= 오버레이 ========================================================= */ const overlay = byId( 'mh-overlay' ); if (overlay) { overlay.addEventListener( 'click', function (event) { event.preventDefault(); event.stopPropagation(); const panel = byId( 'mh-floating-results' ); if (panel) { panel.style.display = 'none'; } floatWrap?.classList.remove( 'open' ); byId( 'mh-live-feed' )?.classList.remove( 'show' ); byId( 'mh-autocomplete' )?.style && ( byId( 'mh-autocomplete' ).style.display = 'none' ); overlay.classList.remove( 'show' ); } ); } /* ========================================================= 뒤로가기 / 앞으로가기 ========================================================= */ window.addEventListener( 'popstate', function () { const params = new URLSearchParams( location.search ); const q = params.get('q'); if (!q) { return; } const post = mhFindPostByKeyword(q); mhUpdateSearchState( q, post, { updateUrl: false, updatePostTitle: true, updateDocumentTitle: true } ); mhSearch(); } ); /* ========================================================= 시작 ========================================================= */ mhInit(); })();

이 곳에 소개 된 것들은 모두 여기에서 볼 수 있습니다. click!

원문 보기