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