// note-feed.jsx — note(ブログ)の最新記事を自動表示 // // 仕組み(取得の優先順位): // 1) note-rss.php … 当社サーバー側の取得窓口(最優先・最安定)。 // note の RSS をサーバーが代理取得し、タイトル/リンク/日付/ // 抜粋/アイキャッチ画像 を JSON で返す。外部サービス非依存。 // 2) 外部CORSプロキシ … サーバー窓口が無い環境(プレビュー等)向けの保険。 // 3) localStorage キャッシュ … 一度表示できた内容を24時間保持。 // note 公式には「最新記事の自動表示ウィジェット」が無いため、RSSを取得して // 当サイト独自デザインのカードで描画しています。 const NOTE_USERNAME = "skyidea"; const NOTE_PROFILE_URL = "https://note.com/" + NOTE_USERNAME; const NOTE_RSS_URL = "https://note.com/" + NOTE_USERNAME + "/rss"; const NOTE_PHP_ENDPOINT = "note-rss.php"; // 同一ドメインのサーバー窓口 const NOTE_ARTICLE_COUNT = 4; // --- ユーティリティ --------------------------------------------------------- function stripHtmlAndTruncate(html, max) { if (!html) return ""; const tmp = document.createElement("div"); tmp.innerHTML = html; let text = (tmp.textContent || tmp.innerText || "").replace(/\s+/g, " ").trim(); if (text.length > max) text = text.slice(0, max).trim() + "…"; return text; } function formatDate(str) { if (!str) return ""; const d = new Date(String(str).replace(" ", "T")); if (isNaN(d.getTime())) return ""; const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}.${m}.${day}`; } // --- キャッシュ(取得済みデータを localStorage に保持) ---------------------- // 一度でも取得に成功すれば、プロキシ不調時でも前回内容(画像含む)を即表示できる。 const NOTE_CACHE_KEY = "skyidea_note_cache_v3"; const NOTE_CACHE_TTL = 1000 * 60 * 60 * 24; // 24時間 // 旧バージョンのキャッシュは破棄 try { localStorage.removeItem("skyidea_note_cache_v1"); localStorage.removeItem("skyidea_note_cache_v2"); } catch (e) {} function readNoteCache() { try { const raw = localStorage.getItem(NOTE_CACHE_KEY); if (!raw) return null; const obj = JSON.parse(raw); if (!obj || !Array.isArray(obj.articles) || obj.articles.length === 0) return null; if (Date.now() - (obj.ts || 0) > NOTE_CACHE_TTL) return null; return obj.articles; } catch (e) { return null; } } function writeNoteCache(articles) { try { if (!articles || !articles.length) return; localStorage.setItem(NOTE_CACHE_KEY, JSON.stringify({ articles, ts: Date.now() })); } catch (e) {} } // --- 取得: 当社サーバー窓口(note-rss.php・最優先/画像つき) --------------- async function fetchWithTimeout(url, ms) { const ctrl = new AbortController(); const id = setTimeout(() => ctrl.abort(), ms); try { return await fetch(url, { signal: ctrl.signal }); } finally { clearTimeout(id); } } async function fetchFromPhp() { const res = await fetchWithTimeout( NOTE_PHP_ENDPOINT + "?t=" + Math.floor(Date.now() / 60000), 9000 ); if (!res.ok) throw new Error("note-rss.php HTTP " + res.status); const json = await res.json(); if (json.status !== "ok" || !Array.isArray(json.items) || json.items.length === 0) { throw new Error("note-rss.php status: " + (json.status || "?")); } return json.items.slice(0, NOTE_ARTICLE_COUNT).map((it) => ({ title: it.title || "(無題)", link: it.link, date: it.date || formatDate(it.pubDate), excerpt: it.excerpt || "", image: it.image || "" })); } // --- 取得: テキスト(rss2json・高速) --------------------------------------- async function fetchTextItems() { const api = "https://api.rss2json.com/v1/api.json?rss_url=" + encodeURIComponent(NOTE_RSS_URL); const res = await fetch(api); if (!res.ok) throw new Error("rss2json HTTP " + res.status); const json = await res.json(); if (json.status !== "ok" || !Array.isArray(json.items)) { throw new Error("rss2json status: " + (json.status || "?")); } return json.items.slice(0, NOTE_ARTICLE_COUNT).map((it) => ({ title: it.title || "(無題)", link: it.link, date: formatDate(it.pubDate), excerpt: stripHtmlAndTruncate(it.description || it.content, 100), image: "" })); } // --- 取得: 生XML(画像つき / フォールバックにも使用) ------------------------ // // note-rss.php が無い環境(プレビュー等)向けの保険。複数のCORSプロキシを // 順に試し、media:thumbnail(アイキャッチ画像)を含む生RSSを取得する。 // プロキシ候補(上から順に試行)。応答が安定しているものを優先。 const XML_PROXIES = [ (u) => "https://test.cors.workers.dev/?" + u, (u) => "https://api.allorigins.win/raw?url=" + encodeURIComponent(u), ]; function parseRssXml(xml) { const doc = new DOMParser().parseFromString(xml, "text/xml"); const nodes = Array.from(doc.querySelectorAll("item")).slice(0, NOTE_ARTICLE_COUNT); if (nodes.length === 0) throw new Error("no items in RSS"); return nodes.map((node) => { const get = (tag) => { const el = node.getElementsByTagName(tag)[0]; return el ? el.getAttribute("url") || el.textContent || "" : ""; }; return { title: get("title") || "(無題)", link: get("link"), date: formatDate(get("pubDate")), excerpt: stripHtmlAndTruncate(get("description") || get("content:encoded"), 100), image: get("media:thumbnail") || get("media:content") || "" }; }); } async function fetchXmlItems() { let lastErr; for (const makeUrl of XML_PROXIES) { try { const res = await fetchWithTimeout(makeUrl(NOTE_RSS_URL), 9000); if (!res.ok) throw new Error("HTTP " + res.status); const xml = await res.text(); return parseRssXml(xml); } catch (e) { lastErr = e; } } throw lastErr || new Error("all xml proxies failed"); } // --- noteアイコン ----------------------------------------------------------- function NoteMark() { return ( ); } // --- カード(サムネイル全面・全カード同サイズ) ----------------------------- function NoteCard({ article }) { // note のアイキャッチ画像にはタイトルが含まれるため、画像がある場合は // サムネイルのみを表示(シャドウ・タイトル等は重複になるので出さない)。 // 画像が取得できなかった時だけ、情報が伝わるようタイトルを表示する。 return ( {article.image ? ( {article.title} ) : (
{article.date && {article.date}}

{article.title}

)}
); } function NoteCardSkeleton() { return ( ); } // --- セクション本体 --------------------------------------------------------- function NoteFeed() { const [state, setState] = React.useState("loading"); // loading | ready | error const [articles, setArticles] = React.useState([]); React.useEffect(() => { let alive = true; // 0) キャッシュがあれば即時表示(取得失敗時でも内容を維持) const cached = readNoteCache(); if (cached) { setArticles(cached); setState("ready"); } // 既存カードの画像をリンク単位で保持してマージ const keepImages = (items, prev) => { const imgByLink = {}; (prev || []).forEach((p) => { if (p.image) imgByLink[p.link] = p.image; }); return items.map((a) => !a.image && imgByLink[a.link] ? { ...a, image: imgByLink[a.link] } : a ); }; // 1) 最優先: 当社サーバー窓口(note-rss.php)。画像つきで1回で揃う。 fetchFromPhp() .then((items) => { if (!alive) return; setArticles(items); writeNoteCache(items); setState("ready"); }) .catch(() => { // 2) 窓口が無い/失敗 → 外部プロキシでフォールバック if (!alive) return; const textP = fetchTextItems(); const xmlP = fetchXmlItems(); // 2a) まず速い方(rss2json・テキスト)で描画 textP .then((items) => { if (!alive) return; setArticles((prev) => { const merged = keepImages(items, prev); writeNoteCache(merged); return merged; }); setState("ready"); }) .catch(() => {}); // 2b) XML が取れたら実画像を反映 xmlP .then((xmlItems) => { if (!alive) return; setArticles((prev) => { const base = prev && prev.length ? prev : xmlItems; const byLink = {}; xmlItems.forEach((x) => { if (x.link) byLink[x.link] = x; }); const merged = base.map((a) => { const x = byLink[a.link]; return x && x.image ? { ...a, image: x.image } : a; }); writeNoteCache(merged); return merged; }); setState("ready"); }) .catch(() => {}); // 2c) すべて失敗し、キャッシュも無ければエラー表示 Promise.allSettled([textP, xmlP]).then((rs) => { if (!alive) return; const allFailed = rs.every((r) => r.status === "rejected"); if (allFailed && !cached) { console.warn("[note-feed] all feeds failed and no cache"); setState("error"); } }); }); return () => { alive = false; }; }, []); return (
JOURNAL NOTE / COLUMN

note 最新記事

DX / AX、情報セキュリティ、BCP に関する知見やニュースへの所感を、 ブログサービス「note」で随時発信しています。最新の記事を自動で表示しています。

{state === "loading" && (
{Array.from({ length: NOTE_ARTICLE_COUNT }).map((_, i) => ( ))}
)} {state === "ready" && (
{articles.map((a, i) => ( ))}
)} {state === "error" && (

最新記事の自動読み込みに失敗しました。 お手数ですが、note のページから直接ご覧ください。

note で記事を見る
)}
note のすべての記事を見る
); } Object.assign(window, { NoteFeed });