セルフホスト可能で全文取得に対応した軽快なRSSリーダー「selfrss」

SNS全盛の時代ですが、情報を効率よく集めようとするとRSSリーダのほうが便利な場面もあります。
ここ最近は「FreshRSS」を利用しており動作は特に問題はなかったのですが、デザインを変えたいなと思い、それなら自作してしまえば、と作ってみました。「selfrss」です。
表示方式やショートカットキーを自分好みにしているので、インストール後にカスタマイズの必要がないのがメリット。3ペイン表示で、FreshRSSからエクスポートしたZIPファイル内のXMLファイルのインポートにも対応しています。

フィード追加時の検出ロジックにはまだまだ改良の余地はありそうですが、普段巡回しているところを見るだけならまったく不満がない感じにはなっています。fキーでどんどん閲覧していけます。

LXDコンテナにインストール

#!/bin/bash
# selfrss インストーラ v9 - セルフホスト型 RSS リーダー
# デフォルト: /opt/selfrss, ポート 3347

set -e
INSTALL_DIR="${1:-/opt/selfrss}"
PORT="${2:-3347}"
SERVICE_NAME="selfrss"

echo "=== selfrss インストーラ v9 ==="
echo "インストール先: $INSTALL_DIR"
echo "ポート: $PORT"
echo ""

if [ "$EUID" -ne 0 ]; then echo "エラー: sudo で実行してください"; exit 1; fi

if ! command -v node &>/dev/null; then
  echo "--- Node.js をインストール中 ---"
  if command -v apt-get &>/dev/null; then
    apt-get update -qq && apt-get install -y -qq curl
    curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
    apt-get install -y -qq nodejs
  elif command -v dnf &>/dev/null; then dnf install -y nodejs
  elif command -v yum &>/dev/null; then
    curl -fsSL https://rpm.nodesource.com/setup_22.x | bash -
    yum install -y nodejs
  elif command -v pacman &>/dev/null; then pacman -Sy --noconfirm nodejs npm
  else echo "エラー: Node.js 22+ を手動でインストールしてください"; exit 1; fi
fi
NODE_VER=$(node -v | sed 's/v//' | cut -d. -f1)
if [ "$NODE_VER" -lt 20 ]; then echo "エラー: Node.js 20+ が必要です (v$NODE_VER)"; exit 1; fi
echo "Node.js $(node -v) OK"

echo "--- ビルドツールを確認中 (better-sqlite3 のネイティブビルド用) ---"
if ! command -v make &>/dev/null || ! command -v g++ &>/dev/null || ! command -v python3 &>/dev/null; then
  if command -v apt-get &>/dev/null; then
    apt-get update -qq
    apt-get install -y -qq build-essential python3
  elif command -v dnf &>/dev/null; then dnf install -y make gcc-c++ python3
  elif command -v yum &>/dev/null; then yum groupinstall -y "Development Tools"; yum install -y python3
  elif command -v pacman &>/dev/null; then pacman -Sy --noconfirm base-devel python3
  else echo "警告: make/g++/python3 が見つかりません。better-sqlite3 のビルドに失敗する可能性があります"; fi
fi
echo "ビルドツール OK"

if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
  echo "--- 既存サービスを停止中 ---"
  systemctl stop "$SERVICE_NAME"
fi

echo "--- ディレクトリを作成中 ---"
mkdir -p "$INSTALL_DIR/server" "$INSTALL_DIR/public/css" "$INSTALL_DIR/public/js" "$INSTALL_DIR/data"

echo "--- ソースファイルを書き込み中 ---"

cat > "$INSTALL_DIR/package.json" << 'PKGJSON'
{"name":"selfrss","version":"9.0.0","description":"Self-hosted RSS Reader","type":"module","scripts":{"start":"node server/index.js","dev":"node --watch server/index.js"},"dependencies":{"fastify":"^5.2.0","@fastify/static":"^8.0.0","@fastify/cors":"^10.0.0","better-sqlite3":"^11.7.0","rss-parser":"^3.13.0","node-cron":"^3.0.3","turndown":"^7.2.0","@mozilla/readability":"^0.6.0","jsdom":"^25.0.0"}}
PKGJSON

cat > "$INSTALL_DIR/server/index.js" << 'SRVINDEX'
import Fastify from 'fastify';
import fastifyStatic from '@fastify/static';
import fastifyCors from '@fastify/cors';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import cron from 'node-cron';
import routes from './routes.js';
import { fetchAllFeeds } from './feed-fetcher.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3347;
const HOST = process.env.HOST || '0.0.0.0';
const app = Fastify({ logger: true });

await app.register(fastifyCors);
await app.register(fastifyStatic, { root: join(__dirname, '..', 'public') });
await app.register(routes, { prefix: '/api' });

app.addHook('onSend', async (req, reply) => {
  if (req.url.startsWith('/api/')) {
    reply.header('Cache-Control', 'no-store, no-cache, must-revalidate');
    reply.header('Pragma', 'no-cache');
    reply.header('Expires', '0');
  }
});

app.setNotFoundHandler((req, reply) => {
  if (req.url.startsWith('/api/')) return reply.code(404).send({ error: 'Not found' });
  return reply.sendFile('index.html');
});

cron.schedule('*/30 * * * *', async () => {
  app.log.info('Cron: 全フィードを更新中');
  try { await fetchAllFeeds(); } catch (e) { app.log.error(e); }
});

await app.listen({ port: PORT, host: HOST });
console.log('selfrss running at http://' + HOST + ':' + PORT);
SRVINDEX

cat > "$INSTALL_DIR/server/db.js" << 'SRVDB'
import Database from 'better-sqlite3';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { mkdirSync } from 'fs';

const __dirname = dirname(fileURLToPath(import.meta.url));
const dataDir = join(__dirname, '..', 'data');
mkdirSync(dataDir, { recursive: true });

const db = new Database(join(dataDir, 'selfrss.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');

db.exec(`
  CREATE TABLE IF NOT EXISTS feeds (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, url TEXT UNIQUE NOT NULL, site_url TEXT, folder_id INTEGER, last_fetched_at TEXT, fetch_error TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL);
  CREATE TABLE IF NOT EXISTS folders (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT DEFAULT (datetime('now')));
  CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY AUTOINCREMENT, feed_id INTEGER NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, author TEXT, content TEXT, summary TEXT, published_at TEXT, is_read INTEGER DEFAULT 0, is_starred INTEGER DEFAULT 0, fetched_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE, UNIQUE(feed_id, url));
  CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
  CREATE INDEX IF NOT EXISTS idx_articles_published_at ON articles(published_at DESC);
  CREATE INDEX IF NOT EXISTS idx_articles_is_read ON articles(is_read);
`);

export default db;
SRVDB

cat > "$INSTALL_DIR/server/feed-fetcher.js" << 'SRVFETCH'
import RssParser from 'rss-parser';
import { JSDOM } from 'jsdom';
import { Readability } from '@mozilla/readability';
import TurndownService from 'turndown';
import db from './db.js';

const parser = new RssParser({ timeout: 15000, headers: { 'User-Agent': 'selfrss/1.0' } });
const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
let refreshing = false;

function extractContent(html, baseUrl) {
  try {
    const dom = new JSDOM(html, { url: baseUrl || 'https://example.com' });
    const reader = new Readability(dom.window.document);
    const article = reader.parse();
    if (article && article.content) return turndown.turndown(article.content);
  } catch {}
  return null;
}

function stripTags(html) {
  if (!html) return '';
  return html.replace(/<[^>]*>/g, '').replace(/&[^;]+;/g, ' ').trim();
}

function decodeWithDetection(bytes) {
  const encodings = ['euc-jp', 'shift_jis', 'utf-8'];
  for (const enc of encodings) {
    try {
      const decoded = new TextDecoder(enc, { fatal: true }).decode(bytes);
      if (!decoded.includes('\ufffd')) return decoded;
    } catch {}
  }
  return new TextDecoder('utf-8').decode(bytes);
}

async function fetchFullText(url) {
  try {
    const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; selfrss/1.0)' }, signal: AbortSignal.timeout(10000), redirect: 'follow' });
    if (!res.ok) return null;
    const contentType = res.headers.get('content-type') || '';
    if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) return null;
    let encoding = 'utf-8';
    const ctMatch = contentType.match(/charset=([^\s;]+)/i);
    if (ctMatch) encoding = ctMatch[1].toLowerCase();
    const buffer = await res.arrayBuffer();
    let html;
    if (encoding === 'euc-jp' || encoding === 'shift_jis' || encoding === 'sjis' || encoding === 'iso-2022-jp') {
      try { html = new TextDecoder(encoding).decode(buffer); } catch { html = decodeWithDetection(new Uint8Array(buffer)); }
    } else {
      html = new TextDecoder('utf-8').decode(buffer);
      if (html.includes('\ufffd')) html = decodeWithDetection(new Uint8Array(buffer));
    }
    return extractContent(html, url);
  } catch { return null; }
}

async function processItems(items, feedId, insertArticle) {
  let added = 0;
  const CONCURRENCY = 3;
  for (let i = 0; i < items.length; i += CONCURRENCY) {
    const batch = items.slice(i, i + CONCURRENCY);
    const results = await Promise.all(batch.map(async (item) => {
      const url = item.link || item.guid;
      if (!url) return null;
      let content = null;
      if (item.content) content = extractContent(item.content);
      if (!content && item['content:encoded']) content = extractContent(item['content:encoded']);
      if (!content || (content && content.length < 1000)) {
        const fullText = await fetchFullText(url);
        if (fullText && fullText.length > (content ? content.length : 0)) content = fullText;
      }
      const summary = item.contentSnippet ? item.contentSnippet.substring(0, 500) : (item.content ? stripTags(item.content).substring(0, 500) : '');
      const pubDate = item.isoDate || item.pubDate || new Date().toISOString();
      return insertArticle.run(feedId, item.title || 'Untitled', url, item.creator || item.author || null, content, summary, pubDate);
    }));
    for (const r of results) { if (r && r.changes > 0) added++; }
  }
  return added;
}

export async function fetchFeed(feedId) {
  const feed = db.prepare('SELECT * FROM feeds WHERE id = ?').get(feedId);
  if (!feed) return { added: 0, error: 'Feed not found' };
  try {
    const res = await fetch(feed.url, { headers: { 'User-Agent': 'selfrss/1.0' }, signal: AbortSignal.timeout(15000) });
    if (!res.ok) throw new Error('HTTP ' + res.status);
    const contentType = res.headers.get('content-type') || '';
    let encoding = 'utf-8';
    const ctMatch = contentType.match(/charset=([^\s;]+)/i);
    if (ctMatch) encoding = ctMatch[1].toLowerCase();
    const buffer = await res.arrayBuffer();
    let xml;
    if (encoding === 'euc-jp' || encoding === 'shift_jis' || encoding === 'sjis' || encoding === 'iso-2022-jp') {
      try { xml = new TextDecoder(encoding).decode(buffer); } catch { xml = new TextDecoder('utf-8').decode(buffer); }
    } else {
      xml = new TextDecoder('utf-8').decode(buffer);
      if (xml.includes('\ufffd')) { try { xml = new TextDecoder('euc-jp').decode(buffer); } catch {} }
    }
    const parsed = await parser.parseString(xml);
    db.prepare("UPDATE feeds SET title = ?, site_url = ?, last_fetched_at = datetime('now'), fetch_error = NULL WHERE id = ?").run(parsed.title || feed.title, parsed.link || feed.site_url, feedId);
    const insertArticle = db.prepare('INSERT OR IGNORE INTO articles (feed_id, title, url, author, content, summary, published_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
    const items = (parsed.items || []).slice(0, 30);
    const added = await processItems(items, feedId, insertArticle);
    return { added, total: items.length };
  } catch (err) {
    db.prepare("UPDATE feeds SET fetch_error = ?, last_fetched_at = datetime('now') WHERE id = ?").run(err.message, feedId);
    return { added: 0, error: err.message };
  }
}

export async function fetchAllFeeds() {
  if (refreshing) return { error: 'Refresh already in progress' };
  refreshing = true;
  try {
    const feeds = db.prepare('SELECT id FROM feeds').all();
    const results = [];
    const CONCURRENCY = 3;
    for (let i = 0; i < feeds.length; i += CONCURRENCY) {
      const batch = feeds.slice(i, i + CONCURRENCY);
      const batchResults = await Promise.all(batch.map(async (feed) => {
        const result = await fetchFeed(feed.id);
        return { feedId: feed.id, ...result };
      }));
      results.push(...batchResults);
    }
    return results;
  } finally { refreshing = false; }
}
SRVFETCH

cat > "$INSTALL_DIR/server/routes.js" << 'SRVROUTES'
import db from './db.js';
import { fetchFeed, fetchAllFeeds } from './feed-fetcher.js';
import RssParser from 'rss-parser';
import { JSDOM } from 'jsdom';

const parser = new RssParser({ timeout: 15000, headers: { 'User-Agent': 'selfrss/1.0' } });

async function discoverFeeds(siteUrl) {
  try {
    let base = siteUrl.replace(/\/$/, '');
    if (!base.match(/^https?:\/\//)) base = 'https://' + base;
    const res = await fetch(base, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; selfrss/1.0)' }, signal: AbortSignal.timeout(10000), redirect: 'follow' });
    if (!res.ok) return [];
    const contentType = res.headers.get('content-type') || '';
    if (!contentType.includes('text/html')) return [];
    const buffer = await res.arrayBuffer();
    let html;
    const ctMatch = contentType.match(/charset=([^\s;]+)/i);
    const encoding = ctMatch ? ctMatch[1].toLowerCase() : 'utf-8';
    if (encoding === 'euc-jp' || encoding === 'shift_jis' || encoding === 'sjis') {
      try { html = new TextDecoder(encoding).decode(buffer); } catch { html = new TextDecoder('utf-8').decode(buffer); }
    } else { html = new TextDecoder('utf-8').decode(buffer); }
    const dom = new JSDOM(html, { url: base });
    const doc = dom.window.document;
    const feeds = [];
    const links = doc.querySelectorAll('link[type*="rss"], link[type*="atom+xml"], link[type*="feed"]');
    for (const link of links) {
      const href = link.getAttribute('href');
      if (!href) continue;
      let feedUrl = href;
      if (feedUrl.startsWith('/')) { const u = new URL(base); feedUrl = u.origin + feedUrl; }
      else if (!feedUrl.startsWith('http')) feedUrl = base + '/' + feedUrl;
      const title = link.getAttribute('title') || '';
      if (!feeds.find(f => f.url === feedUrl)) feeds.push({ url: feedUrl, title: title });
    }
    return feeds;
  } catch { return []; }
}

function escapeXml(str) {
  if (!str) return '';
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}

// 簡易OPMLパーサ。<outline> の属性順序やtitle/text混在に依存しない実装。
// 属性は順不同に個別の正規表現で抽出し、xmlUrl の有無でフィード行かフォルダ行かを判定する。
function getAttr(tag, name) {
  const m = tag.match(new RegExp(name + '\\s*=\\s*"([^"]*)"', 'i'));
  return m ? m[1].replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'") : null;
}

function parseOpml(xml) {
  const feeds = [];
  // 自己終了 <outline .../> と開始タグ <outline ...> の両方にマッチさせる
  const outlineRe = /<outline\b([^>]*?)\/?>/gi;
  const folderStack = []; // フォルダ名のスタック(ネスト対応、念のため)
  let depthTracker = 0;
  // ネストを厳密に追うのは複雑なので、フラットな1階層フォルダ構造を前提に簡易実装する
  let currentFolder = null;
  let match;
  // outline開始/終了を行ごとに見て、開いた要素が自己終了か非自己終了かを判定する
  const lines = xml.split(/(?=<outline\b)|(?=<\/outline>)/i);
  for (const chunk of lines) {
    const closeMatch = chunk.match(/^<\/outline>/i);
    if (closeMatch) {
      if (currentFolder !== null) currentFolder = null;
      continue;
    }
    const openMatch = chunk.match(/^<outline\b([^>]*?)(\/?)>/i);
    if (!openMatch) continue;
    const attrsStr = openMatch[1];
    const selfClosing = openMatch[2] === '/';
    const xmlUrl = getAttr(attrsStr, 'xmlUrl');
    const text = getAttr(attrsStr, 'text') || getAttr(attrsStr, 'title') || '';
    if (xmlUrl) {
      feeds.push({
        title: getAttr(attrsStr, 'title') || text,
        url: xmlUrl,
        siteUrl: getAttr(attrsStr, 'htmlUrl'),
        description: getAttr(attrsStr, 'description'),
        folder: currentFolder
      });
      // フィード行は通常自己終了タグなのでフォルダは変えない
    } else {
      // フォルダ(カテゴリ)行
      if (!selfClosing) currentFolder = text;
    }
  }
  return feeds;
}

export default async function routes(app) {
  app.post('/opml/import', async (req, reply) => {
    const { opml } = req.body;
    if (!opml) return reply.code(400).send({ error: 'OPML content required' });
    try {
      const feeds = parseOpml(opml);
      if (feeds.length === 0) return reply.code(400).send({ error: 'No feeds found in OPML' });
      let imported = 0, skipped = 0;
      const folderMap = {};
      for (const feed of feeds) {
        if (feed.folder) {
          if (!folderMap[feed.folder]) {
            const existing = db.prepare('SELECT id FROM folders WHERE name = ?').get(feed.folder);
            if (existing) folderMap[feed.folder] = existing.id;
            else { const r = db.prepare('INSERT INTO folders (name) VALUES (?)').run(feed.folder); folderMap[feed.folder] = r.lastInsertRowid; }
          }
        }
        if (db.prepare('SELECT id FROM feeds WHERE url = ?').get(feed.url)) { skipped++; continue; }
        db.prepare('INSERT INTO feeds (title, url, site_url, folder_id) VALUES (?, ?, ?, ?)').run(feed.title, feed.url, feed.siteUrl, feed.folder ? folderMap[feed.folder] : null);
        imported++;
      }
      fetchAllFeeds().catch(() => {});
      return { imported, skipped, total: feeds.length };
    } catch (err) { return reply.code(500).send({ error: err.message }); }
  });

  app.post('/feeds/discover', async (req, reply) => {
    const { url } = req.body;
    if (!url) return reply.code(400).send({ error: 'URL required' });
    return { feeds: await discoverFeeds(url) };
  });

  app.get('/feeds', async () => {
    return db.prepare('SELECT f.*, COUNT(a.id) as article_count, SUM(CASE WHEN a.is_read = 0 THEN 1 ELSE 0 END) as unread_count, fl.name as folder_name FROM feeds f LEFT JOIN articles a ON a.feed_id = f.id LEFT JOIN folders fl ON fl.id = f.folder_id GROUP BY f.id ORDER BY fl.name, f.title').all();
  });

  app.post('/feeds', async (req, reply) => {
    const { url, folder_id } = req.body;
    if (!url) return reply.code(400).send({ error: 'URL required' });
    if (db.prepare('SELECT id FROM feeds WHERE url = ?').get(url)) return reply.code(409).send({ error: 'Feed already exists' });
    const result = db.prepare('INSERT INTO feeds (title, url, site_url, folder_id) VALUES (?, ?, ?, ?)').run(url, url, null, folder_id || null);
    fetchFeed(result.lastInsertRowid).catch(() => {});
    return { id: result.lastInsertRowid };
  });

  app.delete('/feeds/:id', async (req) => { db.prepare('DELETE FROM feeds WHERE id = ?').run(req.params.id); return { ok: true }; });

  app.put('/feeds/:id', async (req) => {
    const { title, folder_id } = req.body;
    if (title) db.prepare('UPDATE feeds SET title = ? WHERE id = ?').run(title, req.params.id);
    if (folder_id !== undefined) db.prepare('UPDATE feeds SET folder_id = ? WHERE id = ?').run(folder_id, req.params.id);
    return { ok: true };
  });

  app.post('/feeds/:id/refresh', async (req) => await fetchFeed(parseInt(req.params.id)));
  app.post('/feeds/refresh-all', async () => await fetchAllFeeds());

  app.get('/folders', async () => db.prepare('SELECT * FROM folders ORDER BY name').all());
  app.post('/folders', async (req, reply) => {
    const { name } = req.body;
    if (!name) return reply.code(400).send({ error: 'Name required' });
    try { return { id: db.prepare('INSERT INTO folders (name) VALUES (?)').run(name).lastInsertRowid }; }
    catch { return reply.code(409).send({ error: 'Folder already exists' }); }
  });
  app.delete('/folders/:id', async (req) => { db.prepare('DELETE FROM folders WHERE id = ?').run(req.params.id); return { ok: true }; });

  app.get('/articles', async (req) => {
    const { feed_id, folder_id, starred, unread, search, limit = 100, offset = 0 } = req.query;
    let where = [], params = [];
    if (feed_id) { where.push('a.feed_id = ?'); params.push(feed_id); }
    if (folder_id) { where.push('f.folder_id = ?'); params.push(folder_id); }
    if (starred === '1') where.push('a.is_starred = 1');
    if (unread === '1') where.push('a.is_read = 0');
    if (search) { where.push('(a.title LIKE ? OR a.summary LIKE ?)'); params.push('%' + search + '%', '%' + search + '%'); }
    const wc = where.length ? 'WHERE ' + where.join(' AND ') : '';
    const articles = db.prepare('SELECT a.*, f.title as feed_title, f.site_url as feed_site_url FROM articles a JOIN feeds f ON f.id = a.feed_id ' + wc + ' ORDER BY a.published_at DESC LIMIT ? OFFSET ?').all(...params, parseInt(limit), parseInt(offset));
    const total = db.prepare('SELECT COUNT(*) as total FROM articles a JOIN feeds f ON f.id = a.feed_id ' + wc).get(...params).total;
    return { articles, total };
  });

  app.get('/articles/:id', async (req) => {
    const a = db.prepare('SELECT a.*, f.title as feed_title, f.site_url as feed_site_url FROM articles a JOIN feeds f ON f.id = a.feed_id WHERE a.id = ?').get(req.params.id);
    return a || { error: 'Not found' };
  });

  const noBody = { schema: { consumes: [] } };
  app.put('/articles/:id/read', noBody, async (req) => { db.prepare('UPDATE articles SET is_read = 1 WHERE id = ?').run(req.params.id); return { ok: true }; });
  app.put('/articles/:id/unread', noBody, async (req) => { db.prepare('UPDATE articles SET is_read = 0 WHERE id = ?').run(req.params.id); return { ok: true }; });
  app.put('/articles/:id/star', noBody, async (req) => { db.prepare('UPDATE articles SET is_starred = 1 WHERE id = ?').run(req.params.id); return { ok: true }; });
  app.put('/articles/:id/unstar', noBody, async (req) => { db.prepare('UPDATE articles SET is_starred = 0 WHERE id = ?').run(req.params.id); return { ok: true }; });
  app.put('/feeds/:id/mark-all-read', noBody, async (req) => { db.prepare('UPDATE articles SET is_read = 1 WHERE feed_id = ?').run(req.params.id); return { ok: true }; });
  app.put('/mark-all-read', noBody, async () => { db.prepare('UPDATE articles SET is_read = 1').run(); return { ok: true }; });

  app.get('/stats', async () => {
    return {
      total: db.prepare('SELECT COUNT(*) as c FROM articles').get().c,
      unread: db.prepare('SELECT COUNT(*) as c FROM articles WHERE is_read = 0').get().c,
      feeds: db.prepare('SELECT COUNT(*) as c FROM feeds').get().c,
      starred: db.prepare('SELECT COUNT(*) as c FROM articles WHERE is_starred = 1').get().c
    };
  });

  app.get('/opml/export', async (req, reply) => {
    const feeds = db.prepare('SELECT f.title, f.url, f.site_url, fl.name as folder_name FROM feeds f LEFT JOIN folders fl ON fl.id = f.folder_id ORDER BY fl.name, f.title').all();
    let opml = '<?xml version="1.0" encoding="UTF-8"?>\n<opml version="2.0">\n  <head>\n    <title>selfrss</title>\n    <dateCreated>' + new Date().toUTCString() + '</dateCreated>\n  </head>\n  <body>\n';
    const grouped = {};
    for (const f of feeds) { const k = f.folder_name || 'Uncategorized'; if (!grouped[k]) grouped[k] = []; grouped[k].push(f); }
    for (const [folder, items] of Object.entries(grouped)) {
      opml += '    <outline text="' + escapeXml(folder) + '">\n';
      for (const f of items) opml += '      <outline text="' + escapeXml(f.title) + '" type="rss" xmlUrl="' + escapeXml(f.url) + '"' + (f.site_url ? ' htmlUrl="' + escapeXml(f.site_url) + '"' : '') + '/>\n';
      opml += '    </outline>\n';
    }
    opml += '  </body>\n</opml>';
    reply.header('Content-Type', 'application/xml; charset=utf-8');
    reply.header('Content-Disposition', 'attachment; filename="selfrss-export.opml"');
    return reply.send(opml);
  });
}
SRVROUTES

cat > "$INSTALL_DIR/public/favicon.svg" << 'FAVICON'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
  <rect width="32" height="32" rx="6" fill="#2563eb"/>
  <circle cx="16" cy="20" r="3" fill="white"/>
  <path d="M16 17 Q8 10 8 6" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>
  <path d="M16 17 Q24 10 24 6" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>
  <path d="M16 14 Q11 9 11 6" stroke="white" stroke-width="2" fill="none" stroke-linecap="round"/>
  <path d="M16 14 Q21 9 21 6" stroke="white" stroke-width="2" fill="none" stroke-linecap="round"/>
</svg>
FAVICON

cat > "$INSTALL_DIR/public/index.html" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>selfrss</title>
  <link rel="icon" type="image/svg+xml" href="/favicon.svg">
  <link rel="stylesheet" href="/css/style.css">
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
  <div id="app">
    <aside id="sidebar" class="sidebar">
      <div class="sidebar-header">
        <h1 class="logo">selfrss</h1>
        <div class="sidebar-actions">
          <button id="btn-add-feed" class="icon-btn" title="フィード追加"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
          <button id="btn-import-opml" class="icon-btn" title="OPMLインポート"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></button>
          <button id="btn-export-opml" class="icon-btn" title="OPMLエクスポート"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
          <button id="btn-refresh-all" class="icon-btn" title="全フィード更新"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
          <button id="btn-theme" class="icon-btn" title="テーマ切替"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></button>
        </div>
      </div>
      <div class="sidebar-stats" id="stats"></div>
      <nav class="sidebar-nav">
        <a href="#" class="nav-item active" data-view="all"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg><span>全記事</span><span class="badge" id="badge-all"></span></a>
        <a href="#" class="nav-item" data-view="unread"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg><span>未読</span><span class="badge" id="badge-unread"></span></a>
        <a href="#" class="nav-item" data-view="starred"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><span>お気に入り</span><span class="badge" id="badge-starred"></span></a>
      </nav>
      <div class="feed-section">
        <div class="feed-section-header"><span>Feeds</span><button id="btn-add-folder" class="icon-btn-sm" title="カテゴリー追加"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/></svg></button></div>
        <div id="feed-list" class="feed-list"></div>
      </div>
    </aside>
    <main id="article-pane" class="article-pane">
      <div class="article-pane-header">
        <button id="btn-toggle-sidebar" class="icon-btn mobile-only"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
        <button id="btn-toggle-sidebar-desktop" class="icon-btn active" title="サイドバー切替"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg></button>
        <button id="btn-view-mode" class="icon-btn" title="表示モード切替"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg></button>
        <h2 id="article-pane-title">全記事</h2>
        <div class="article-pane-actions">
          <button id="btn-delete-feed" class="icon-btn" title="削除" style="display:none"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
          <input type="search" id="search-input" placeholder="検索..." class="search-input">
          <button id="btn-mark-all-read" class="icon-btn" title="全て既読にする"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg></button>
        </div>
      </div>
      <div id="article-list" class="article-list"></div>
    </main>
    <aside id="content-pane" class="content-pane">
      <div id="content-placeholder" class="content-placeholder">
        <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.3"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
        <p>記事を選択してください</p>
      </div>
      <div id="content-view" class="content-view" style="display:none">
        <div class="content-toolbar">
          <button id="btn-back" class="icon-btn mobile-only"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg></button>
          <div class="content-toolbar-actions">
            <button id="btn-star" class="icon-btn" title="お気に入り"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
            <button id="btn-open-original" class="icon-btn" title="元を開く"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
          </div>
        </div>
        <div class="content-header"><h1 id="content-title"></h1><div class="content-meta"><span id="content-feed"></span><span id="content-author"></span><span id="content-date"></span></div></div>
        <div id="content-body" class="prose"></div>
      </div>
    </aside>
  </div>
  <div id="modal-overlay" class="modal-overlay" style="display:none"><div class="modal"><div class="modal-header"><h3 id="modal-title">フィード追加</h3><button id="btn-modal-close" class="icon-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="modal-body"><input type="url" id="modal-input" placeholder="https://example.com/feed.xml" class="modal-input"><select id="modal-folder-select" class="modal-input" style="margin-top:8px;padding:7px 10px"><option value="">カテゴリーを選択(任意)</option></select><div id="modal-discover" style="display:none;margin-top:12px;padding:10px;background:var(--bg-subtle);border-radius:6px;font-size:13px"></div><div class="modal-error" id="modal-error"></div></div><div class="modal-footer"><button id="btn-modal-cancel" class="btn btn-secondary">キャンセル</button><button id="btn-modal-confirm" class="btn btn-primary">追加</button></div></div></div>
  <div id="opml-modal-overlay" class="modal-overlay" style="display:none"><div class="modal"><div class="modal-header"><h3>OPMLインポート</h3><button id="btn-opml-modal-close" class="icon-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="modal-body"><p style="font-size:13px;color:var(--text-muted);margin-bottom:12px">OPMLファイルを選択してください</p><input type="file" id="opml-file-input" accept=".opml,.xml,.opml.xml" class="modal-input" style="padding:6px"><div class="modal-error" id="opml-modal-error"></div><div id="opml-result" style="display:none;margin-top:12px;padding:10px;background:var(--bg-subtle);border-radius:6px;font-size:13px"></div></div><div class="modal-footer"><button id="btn-opml-modal-cancel" class="btn btn-secondary">キャンセル</button><button id="btn-opml-modal-confirm" class="btn btn-primary">インポート</button></div></div></div>
  <script src="/js/app.js"></script>
</body>
</html>
HTMLEOF

cat > "$INSTALL_DIR/public/css/style.css" << 'CSSEOF'
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#fff;--bg-card:#fff;--bg-sidebar:#f0f0f2;--bg-header:#fff;--bg-input:#fff;--bg-subtle:#f7f7f7;--text:#111;--text-muted:#6b7280;--accent:#2563eb;--accent-text:#fff;--border:#e5e7eb;--hover:rgba(0,0,0,.04);--hover-sidebar:rgba(0,0,0,.08);--header-height:48px;--sidebar-width:260px;--radius:.5rem;--font:'Noto Sans JP',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;--font-article:'Noto Sans JP',Georgia,serif}
.dark{--bg:#111;--bg-card:#111;--bg-sidebar:#0a0a0a;--bg-header:#111;--bg-input:#1a1a1a;--bg-subtle:#1a1a1a;--text:#e8e8e8;--text-muted:#6b7280;--accent:#60a5fa;--accent-text:#fff;--border:#2a2a2a;--hover:rgba(255,255,255,.05);--hover-sidebar:rgba(255,255,255,.1)}
body{font-family:var(--font);background:var(--bg);color:var(--text);height:100vh;overflow:hidden}
#app{display:flex;height:100vh}
.sidebar{width:var(--sidebar-width);min-width:var(--sidebar-width);background:var(--bg-sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;transition:transform .2s}
.sidebar-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border)}
.logo{font-size:18px;font-weight:700;color:var(--accent)}
.sidebar-actions{display:flex;gap:4px}
.sidebar-stats{padding:8px 16px;font-size:12px;color:var(--text-muted);border-bottom:1px solid var(--border)}
.sidebar-nav{padding:8px 0;border-bottom:1px solid var(--border)}
.nav-item{display:flex;align-items:center;gap:10px;padding:8px 16px;color:var(--text);text-decoration:none;font-size:13px;cursor:pointer;transition:background .15s}
.nav-item:hover{background:var(--hover-sidebar)}
.nav-item.active{background:var(--hover-sidebar);font-weight:500;color:var(--accent)}
.nav-item svg{flex-shrink:0}
.nav-item span:first-of-type{flex:1}
.badge{background:var(--accent);color:var(--accent-text);font-size:11px;font-weight:600;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
.badge:empty{display:none}
.feed-section{flex:1;overflow-y:auto;padding:8px 0}
.feed-section-header{display:flex;align-items:center;justify-content:space-between;padding:4px 16px 8px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted)}
.feed-list{display:flex;flex-direction:column}
.feed-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;cursor:pointer;transition:background .15s;position:relative}
.feed-item:hover{background:var(--hover-sidebar)}
.feed-item.active{background:var(--hover-sidebar);font-weight:500;color:var(--accent)}
.feed-item .feed-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.folder-item{padding:4px 16px;font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px;display:flex;align-items:center;gap:6px;cursor:pointer}
.folder-item span:first-of-type{flex:1}
.folder-item:hover{background:var(--hover-sidebar)}
.folder-item.active{background:var(--hover-sidebar);color:var(--accent)}
.badge-muted{background:#6b7280;color:#fff}
.dark .badge-muted{background:#4b5563}
.icon-btn.active{color:var(--accent);background:var(--hover-sidebar)}
.compact .article-card-title{-webkit-line-clamp:1}
.compact .article-card-meta{display:none}
.compact .article-card-summary{display:none}
.article-pane{flex:1;min-width:320px;max-width:420px;border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.article-pane-header{display:flex;align-items:center;gap:8px;padding:10px 16px;border-bottom:1px solid var(--border);background:var(--bg-header);min-height:var(--header-height)}
.article-pane-header h2{font-size:15px;font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.article-pane-actions{display:flex;gap:4px;align-items:center}
.search-input{background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius);padding:5px 10px;font-size:13px;color:var(--text);width:140px;outline:none;transition:border-color .15s,width .2s}
.search-input:focus{border-color:var(--accent);width:200px}
.article-list{flex:1;overflow-y:auto}
.article-card{padding:12px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .15s;position:relative}
.article-card:hover{background:var(--hover)}
.article-card.active{background:var(--hover);border-left:3px solid var(--accent);padding-left:13px}
.article-card.unread .article-card-title{font-weight:600}
.article-card.unread::before{content:'';position:absolute;left:6px;top:50%;transform:translateY(-50%);width:6px;height:6px;border-radius:50%;background:var(--accent)}
.article-card-title{font-size:13px;font-weight:500;line-height:1.4;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.article-card-meta{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--text-muted)}
.article-card-feed{color:var(--accent);font-weight:500}
.article-card-star{color:#f59e0b}
.article-card-summary{font-size:12px;color:var(--text-muted);line-height:1.4;margin-top:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.content-pane{flex:1;overflow-y:auto;display:flex;flex-direction:column}
.content-placeholder{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:var(--text-muted);font-size:14px}
.content-view{flex:1;display:flex;flex-direction:column}
.content-toolbar{display:flex;align-items:center;justify-content:space-between;padding:8px 24px;border-bottom:1px solid var(--border);background:var(--bg-header);position:sticky;top:0;z-index:10}
.content-toolbar-actions{display:flex;gap:4px}
.content-header{padding:24px 24px 16px;max-width:720px;margin:0 auto;width:100%}
.content-header h1{font-size:24px;font-weight:700;line-height:1.3;margin-bottom:12px}
.content-meta{display:flex;flex-wrap:wrap;gap:12px;font-size:13px;color:var(--text-muted);padding-bottom:16px;border-bottom:1px solid var(--border)}
.content-meta span::before{content:'';display:inline-block;width:4px;height:4px;border-radius:50%;background:var(--text-muted);margin-right:12px;vertical-align:middle}
.content-meta span:first-child::before{display:none}
.prose{padding:24px;max-width:720px;margin:0 auto;width:100%;font-family:var(--font-article);line-height:1.8;font-size:15px}
.prose h2{font-size:20px;font-weight:600;color:var(--accent);margin:1.5em 0 .5em}
.prose h3{font-size:17px;font-weight:600;margin:1.25em 0 .4em}
.prose p{margin-bottom:1em}
.prose a{color:var(--accent);text-decoration:underline;text-underline-offset:2px}
.prose img{max-width:100%;height:auto;border-radius:4px;margin:1em 0}
.prose pre{background:var(--bg-subtle);border-radius:6px;padding:16px;overflow-x:auto;margin:1em 0;font-size:13px}
.prose code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.875em;background:var(--bg-subtle);padding:2px 4px;border-radius:3px}
.prose pre code{background:none;padding:0}
.prose blockquote{border-left:3px solid var(--border);padding-left:1em;color:var(--text-muted);margin:1em 0}
.prose ul,.prose ol{padding-left:1.5em;margin:.75em 0}
.prose li{margin-bottom:.3em}
.prose table{border-collapse:collapse;margin:1em 0;width:100%}
.prose th,.prose td{padding:8px 12px;border:1px solid var(--border);text-align:left}
.prose th{font-weight:600;background:var(--bg-subtle)}
.prose hr{border:none;border-top:1px solid var(--border);margin:1.5em 0}
.icon-btn{background:none;border:none;color:var(--text-muted);cursor:pointer;padding:6px;border-radius:var(--radius);display:flex;align-items:center;justify-content:center;transition:background .15s,color .15s}
.icon-btn:hover{background:var(--hover);color:var(--text)}
.icon-btn-sm{background:none;border:none;color:var(--text-muted);cursor:pointer;padding:2px;border-radius:var(--radius);display:flex;align-items:center;justify-content:center}
.icon-btn-sm:hover{color:var(--text)}
.btn{padding:8px 16px;border-radius:var(--radius);font-size:13px;font-weight:500;cursor:pointer;border:1px solid var(--border);transition:background .15s}
.btn-primary{background:var(--accent);color:var(--accent-text);border-color:var(--accent)}
.btn-primary:hover{opacity:.9}
.btn-secondary{background:var(--bg);color:var(--text)}
.btn-secondary:hover{background:var(--hover)}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100}
.modal{background:var(--bg-card);border-radius:var(--radius);border:1px solid var(--border);width:400px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.3)}
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border)}
.modal-header h3{font-size:15px;font-weight:600}
.modal-body{padding:16px 20px}
.modal-input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);font-size:14px;color:var(--text);background:var(--bg-input);outline:none}
.modal-input:focus{border-color:var(--accent)}
.modal-error{color:#dc2626;font-size:12px;margin-top:8px;min-height:16px}
.modal-footer{display:flex;justify-content:flex-end;gap:8px;padding:12px 20px;border-top:1px solid var(--border)}
::-webkit-scrollbar{width:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}
.mobile-only{display:none}
@media(max-width:768px){.sidebar{position:fixed;z-index:50;height:100vh;transform:translateX(-100%)}.sidebar.open{transform:translateX(0)}.mobile-only{display:flex}.article-pane{max-width:none}#content-placeholder,#content-view{display:none}.content-pane.show #content-placeholder{display:none!important}.content-pane.show #content-view{display:flex!important}.content-pane.show{position:fixed;inset:0;z-index:40;background:var(--bg)}}
.loading{text-align:center;padding:40px;color:var(--text-muted);font-size:13px}
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 20px;color:var(--text-muted);gap:12px}
.empty-state svg{opacity:.3}
.empty-state p{font-size:14px}
CSSEOF

cat > "$INSTALL_DIR/public/js/app.js" << 'APPJS'
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);

let currentView = 'all';
let currentFeedId = null;
let currentFolderId = null;
let currentArticleId = null;
let searchTimeout = null;
let compactMode = localStorage.getItem('selfrss-compact') === '1';
let sidebarVisible = localStorage.getItem('selfrss-sidebar') !== '0';

async function api(path, opts = {}) {
  const headers = opts.body ? { 'Content-Type': 'application/json' } : {};
  const res = await fetch('/api' + path, { ...opts, headers: { ...headers, ...opts.headers }, body: opts.body ? JSON.stringify(opts.body) : undefined });
  return res.json();
}

function initTheme() {
  const saved = localStorage.getItem('selfrss-theme');
  if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) document.documentElement.classList.add('dark');
}
function toggleTheme() {
  document.documentElement.classList.toggle('dark');
  localStorage.setItem('selfrss-theme', document.documentElement.classList.contains('dark') ? 'dark' : 'light');
}

function renderStats(stats) {
  $('#stats').textContent = stats.total + ' articles · ' + stats.feeds + ' feeds';
  const ab = $('#badge-all');
  if (ab) { ab.textContent = stats.total || ''; ab.className = stats.total ? 'badge badge-muted' : 'badge'; }
  $('#badge-unread').textContent = stats.unread || '';
  const sb = $('#badge-starred');
  if (sb) { sb.textContent = stats.starred || ''; sb.className = stats.starred ? 'badge badge-muted' : 'badge'; }
}

function renderFeedList(feeds, folders) {
  const container = $('#feed-list');
  container.innerHTML = '';
  const grouped = {};
  for (const f of feeds) { const key = f.folder_name || '__root__'; if (!grouped[key]) grouped[key] = []; grouped[key].push(f); }
  for (const [folder, items] of Object.entries(grouped)) {
    if (folder !== '__root__') {
      const totalUnread = items.reduce((sum, f) => sum + (f.unread_count || 0), 0);
      const folderId = items[0] ? items[0].folder_id : null;
      const el = document.createElement('div');
      el.className = 'folder-item' + (currentView === 'folder' && currentFolderId === folderId ? ' active' : '');
      el.dataset.folderId = folderId;
      el.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg><span>' + folder + '</span>' + (totalUnread > 0 ? '<span class="badge badge-muted">' + totalUnread + '</span>' : '');
      el.addEventListener('click', (function(fid,fn){return function(){selectFolder(fid,fn)}})(folderId,folder));
      container.appendChild(el);
    }
    for (const feed of items) {
      const el = document.createElement('div');
      el.className = 'feed-item' + (currentView === 'feed' && currentFeedId === feed.id ? ' active' : '');
      el.dataset.feedId = feed.id;
      el.innerHTML = '<span class="feed-title">' + esc(feed.title) + '</span>' + (feed.unread_count ? '<span class="badge">' + feed.unread_count + '</span>' : '');
      el.addEventListener('click', (function(id,title){return function(){selectFeed(id,title)}})(feed.id,feed.title));
      container.appendChild(el);
    }
  }
  if (folders && folders.length > 0) {
    for (const folder of folders) {
      if (!grouped[folder.name]) {
        const el = document.createElement('div');
        el.className = 'folder-item' + (currentView === 'folder' && currentFolderId === folder.id ? ' active' : '');
        el.dataset.folderId = folder.id;
        el.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg><span>' + esc(folder.name) + '</span>';
        el.addEventListener('click', (function(id,name){return function(){selectFolder(id,name)}})(folder.id,folder.name));
        container.appendChild(el);
      }
    }
  }
  if (feeds.length === 0 && (!folders || folders.length === 0)) {
    container.innerHTML = '<div class="empty-state"><p>フィードがありません。追加しましょう!</p></div>';
  }
}

function renderArticles(articles) {
  const container = $('#article-list');
  container.innerHTML = '';
  if (articles.length === 0) { container.innerHTML = '<div class="empty-state"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><p>記事がありません</p></div>'; return; }
  container.classList.toggle('compact', compactMode);
  for (const a of articles) {
    const el = document.createElement('div');
    el.className = 'article-card' + (a.is_read ? '' : ' unread') + (currentArticleId === a.id ? ' active' : '');
    el.dataset.articleId = a.id;
    if (compactMode) { el.innerHTML = '<div class="article-card-title">' + esc(a.title) + '</div>'; }
    else { el.innerHTML = '<div class="article-card-title">' + esc(a.title) + '</div><div class="article-card-meta"><span class="article-card-feed">' + esc(a.feed_title) + '</span><span>' + timeAgo(a.published_at) + '</span>' + (a.is_starred ? '<span class="article-card-star">&#9733;</span>' : '') + '</div>' + (a.summary ? '<div class="article-card-summary">' + esc(a.summary) + '</div>' : ''); }
    el.addEventListener('click', (function(id){return function(){selectArticle(id)}})(a.id));
    container.appendChild(el);
  }
}

function renderContent(article) {
  if (!article) { $('#content-placeholder').style.display = 'flex'; $('#content-view').style.display = 'none'; return; }
  $('#content-placeholder').style.display = 'none'; $('#content-view').style.display = 'flex'; $('#content-pane').scrollTop = 0;
  $('#content-title').textContent = article.title;
  $('#content-feed').textContent = article.feed_title;
  $('#content-author').textContent = article.author ? 'by ' + article.author : '';
  $('#content-date').textContent = formatDate(article.published_at);
  var body = article.content || article.summary || '<p>コンテンツがありません。</p>';
  $('#content-body').innerHTML = '<div class="prose">' + markdownToHtml(body) + '</div>';
  updateStarButton(article.is_starred);
  $('#btn-open-original').onclick = function(){ window.open(article.url, '_blank'); };
}

function updateStarButton(s) {
  var b = $('#btn-star'); b.dataset.starred = String(s || 0);
  b.innerHTML = s
    ? '<svg width="18" height="18" viewBox="0 0 24 24" fill="#f59e0b" stroke="#f59e0b" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'
    : '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
}

async function loadFeeds() { var f = await api('/feeds'); var fl = await api('/folders'); renderFeedList(f, fl); }
async function loadStats() { renderStats(await api('/stats')); }
async function loadArticles(p) { p = p || {}; var qs = new URLSearchParams(p).toString(); var d = await api('/articles' + (qs ? '?' + qs : '')); renderArticles(d.articles); }

async function selectFeed(id, title) {
  currentView = 'feed'; currentFeedId = id; currentFolderId = null; currentArticleId = null;
  $('#article-pane-title').textContent = title;
  $$('.nav-item').forEach(function(n){n.classList.remove('active')});
  $$('.feed-item').forEach(function(f){f.classList.toggle('active', parseInt(f.dataset.feedId) === id)});
  $$('.folder-item').forEach(function(f){f.classList.remove('active')});
  var d = $('#btn-delete-feed'); if (d) { d.style.display = ''; d.title = 'フィード削除'; }
  renderContent(null); await loadArticles({ feed_id: id });
}

async function selectFolder(id, name) {
  currentView = 'folder'; currentFolderId = id; currentFeedId = null; currentArticleId = null;
  $('#article-pane-title').textContent = name;
  $$('.nav-item').forEach(function(n){n.classList.remove('active')});
  $$('.feed-item').forEach(function(f){f.classList.remove('active')});
  $$('.folder-item').forEach(function(f){f.classList.toggle('active', parseInt(f.dataset.folderId) === id)});
  var d = $('#btn-delete-feed'); if (d) { d.style.display = ''; d.title = 'カテゴリー削除'; }
  renderContent(null); await loadArticles({ folder_id: id });
}

async function selectArticle(id) {
  currentArticleId = id;
  var article = await api('/articles/' + id);
  renderContent(article);
  await api('/articles/' + id + '/read', { method: 'PUT' });
  var card = $('.article-card[data-article-id="' + id + '"]');
  if (card) {
    card.classList.remove('unread');
    var list = $('#article-list');
    var cardRect = card.getBoundingClientRect();
    var listRect = list.getBoundingClientRect();
    if (cardRect.top < listRect.top || cardRect.bottom > listRect.bottom) {
      card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
    }
  }
  await Promise.all([loadStats(), loadFeeds()]);
}

function setView(view) {
  currentView = view; currentFeedId = null; currentFolderId = null; currentArticleId = null;
  renderContent(null);
  $$('.nav-item').forEach(function(n){n.classList.toggle('active', n.dataset.view === view)});
  $$('.feed-item').forEach(function(f){f.classList.remove('active')});
  $$('.folder-item').forEach(function(f){f.classList.remove('active')});
  var d = $('#btn-delete-feed'); if (d) d.style.display = 'none';
  var t = { all: '全記事', unread: '未読', starred: 'お気に入り' };
  $('#article-pane-title').textContent = t[view] || '記事';
  var p = {};
  if (view === 'unread') p.unread = '1';
  if (view === 'starred') p.starred = '1';
  loadArticles(p);
}

function hideModal() { $('#modal-overlay').style.display = 'none'; var d = $('#modal-discover'); if (d) d.style.display = 'none'; }
function esc(s) { if (!s) return ''; return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function timeAgo(d) { if (!d) return ''; var diff = Date.now() - new Date(d).getTime(); var m = Math.floor(diff / 60000); if (m < 1) return 'just now'; if (m < 60) return m + 'm ago'; var h = Math.floor(m / 60); if (h < 24) return h + 'h ago'; var dy = Math.floor(h / 24); if (dy < 30) return dy + 'd ago'; return new Date(d).toLocaleDateString('ja-JP'); }
function formatDate(d) { if (!d) return ''; return new Date(d).toLocaleString('ja-JP', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }
function markdownToHtml(md) { if (!md) return ''; return md.replace(/^### (.+)$/gm, '<h3>$1</h3>').replace(/^## (.+)$/gm, '<h2>$1</h2>').replace(/^# (.+)$/gm, '<h1>$1</h1>').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/`(.+?)`/g, '<code>$1</code>').replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>').replace(/^\> (.+)$/gm, '<blockquote>$1</blockquote>').replace(/^- (.+)$/gm, '<li>$1</li>').replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>').replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" loading="lazy">').replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>').replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>'); }

initTheme();
$$('.nav-item').forEach(function(n){n.addEventListener('click', function(e){e.preventDefault();setView(n.dataset.view)})});
$('#btn-toggle-sidebar') && $('#btn-toggle-sidebar').addEventListener('click', function(){$('#sidebar').classList.toggle('open')});
$('#btn-back') && $('#btn-back').addEventListener('click', function(){$('#content-pane').classList.remove('show')});
$('#btn-theme').addEventListener('click', toggleTheme);
$('#btn-toggle-sidebar-desktop') && $('#btn-toggle-sidebar-desktop').addEventListener('click', function(){sidebarVisible=!sidebarVisible;localStorage.setItem('selfrss-sidebar',sidebarVisible?'1':'0');$('#sidebar').style.display=sidebarVisible?'':'none';$('#btn-toggle-sidebar-desktop').classList.toggle('active',sidebarVisible)});
$('#btn-view-mode') && $('#btn-view-mode').addEventListener('click', function(){compactMode=!compactMode;localStorage.setItem('selfrss-compact',compactMode?'1':'0');$('#btn-view-mode').innerHTML=compactMode?'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>':'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>';setView(currentView)});
if (!sidebarVisible) { $('#sidebar').style.display='none'; var bsd=$('#btn-toggle-sidebar-desktop');if(bsd)bsd.classList.remove('active'); }
if (compactMode) { var vm=$('#btn-view-mode');if(vm)vm.innerHTML='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>'; }

$('#btn-add-feed').addEventListener('click', async function() {
  var folders = await api('/folders');
  var select = $('#modal-folder-select');
  select.innerHTML = '<option value="">カテゴリーを選択(任意)</option>';
  for (var i = 0; i < folders.length; i++) { select.innerHTML += '<option value="'+folders[i].id+'">'+esc(folders[i].title||folders[i].name)+'</option>'; }
  if (currentFolderId) select.value = currentFolderId;
  $('#modal-title').textContent = 'フィード追加';
  $('#modal-input').placeholder = 'https://example.com/feed.xml';
  $('#modal-input').value = '';
  $('#modal-error').textContent = '';
  $('#modal-discover').style.display = 'none';
  $('#modal-overlay').style.display = 'flex';
  $('#modal-input').focus();
  var composing = false;
  var onStart = function(){composing=true};
  var onEnd = function(){composing=false};
  $('#modal-input').addEventListener('compositionstart', onStart);
  $('#modal-input').addEventListener('compositionend', onEnd);
  var handler = async function() {
    if (composing) return;
    var url = $('#modal-input').value.trim();
    if (!url) return;
    var folderId = $('#modal-folder-select').value || null;
    var looksLikeFeed = /\.(xml|rss|atom|rdf)(\?[^]*)?$/i.test(url) || /\/feed(\/)?(\?[^]*)?$/i.test(url) || /\/rss(\/)?(\?[^]*)?$/i.test(url);
    try {
      if (looksLikeFeed) {
        var res = await api('/feeds', { method: 'POST', body: { url: url, folder_id: folderId } });
        if (res.error) throw new Error(res.error);
        hideModal(); await loadFeeds(); await loadStats();
      } else {
        var d = $('#modal-discover');
        d.style.display = 'block';
        d.innerHTML = 'フィードを探しています...';
        var disc = await api('/feeds/discover', { method: 'POST', body: { url: url } });
        if (disc.feeds && disc.feeds.length > 0) {
          d.innerHTML = '<strong>以下のフィードが見つかりました:</strong><br>';
          for (var i = 0; i < disc.feeds.length; i++) {
            var f = disc.feeds[i];
            d.innerHTML += '<div style="margin:6px 0"><a href="#" class="discover-link" data-url="'+esc(f.url)+'" data-title="'+esc(f.title)+'" style="color:var(--accent)">'+esc(f.title||f.url)+'</a><br><span style="font-size:11px;color:var(--text-muted)">'+esc(f.url)+'</span></div>';
          }
          d.querySelectorAll('.discover-link').forEach(function(link){
            link.addEventListener('click', async function(e){
              e.preventDefault();
              var r = await api('/feeds', { method: 'POST', body: { url: link.dataset.url, folder_id: folderId } });
              if (r.error) throw new Error(r.error);
              hideModal(); await loadFeeds(); await loadStats();
            });
          });
          return;
        } else { throw new Error('フィードが見つかりませんでした。URLを確認してください。'); }
      }
    } catch(e) { $('#modal-error').textContent = e.message; }
  };
  $('#btn-modal-confirm').onclick = handler;
  $('#btn-modal-cancel').onclick = function(){hideModal();$('#modal-input').removeEventListener('compositionstart',onStart);$('#modal-input').removeEventListener('compositionend',onEnd)};
  $('#btn-modal-close').onclick = function(){hideModal();$('#modal-input').removeEventListener('compositionstart',onStart);$('#modal-input').removeEventListener('compositionend',onEnd)};
  $('#modal-input').onkeydown = function(e){if(e.key==='Enter'&&!composing)handler()};
});

$('#btn-add-folder').addEventListener('click', function() {
  var fs=$('#modal-folder-select'); if(fs)fs.style.display='none';
  var d=$('#modal-discover'); if(d)d.style.display='none';
  $('#modal-title').textContent='カテゴリー追加';
  $('#modal-input').placeholder='カテゴリー名';
  $('#modal-input').value='';
  $('#modal-error').textContent='';
  $('#modal-overlay').style.display='flex';
  $('#modal-input').focus();
  var composing=false;
  var onStart=function(){composing=true};
  var onEnd=function(){composing=false};
  $('#modal-input').addEventListener('compositionstart',onStart);
  $('#modal-input').addEventListener('compositionend',onEnd);
  var handler=async function(){
    if(composing)return;
    var name=$('#modal-input').value.trim();
    if(!name)return;
    try{var r=await api('/folders',{method:'POST',body:{name:name}});if(r.error)throw new Error(r.error);hideModal();if(fs)fs.style.display='';$('#modal-input').removeEventListener('compositionstart',onStart);$('#modal-input').removeEventListener('compositionend',onEnd);await loadFeeds();await loadStats();}
    catch(e){$('#modal-error').textContent=e.message;}
  };
  var cleanup=function(){if(fs)fs.style.display='';$('#modal-input').removeEventListener('compositionstart',onStart);$('#modal-input').removeEventListener('compositionend',onEnd)};
  $('#btn-modal-confirm').onclick=handler;
  $('#btn-modal-cancel').onclick=function(){hideModal();cleanup()};
  $('#btn-modal-close').onclick=function(){hideModal();cleanup()};
  $('#modal-input').onkeydown=function(e){if(e.key==='Enter'&&!composing)handler()};
});

$('#btn-import-opml').addEventListener('click', function() {
  var o=$('#opml-modal-overlay');o.style.display='flex';$('#opml-modal-error').textContent='';$('#opml-result').style.display='none';$('#opml-file-input').value='';
  var close=function(){o.style.display='none'};
  $('#btn-opml-modal-cancel').onclick=close;$('#btn-opml-modal-close').onclick=close;
  o.addEventListener('click',function(e){if(e.target===o)close()},{once:true});
  $('#btn-opml-modal-confirm').onclick=async function(){
    var file=$('#opml-file-input').files[0];
    if(!file){$('#opml-modal-error').textContent='ファイルを選択してください';return;}
    try{var text=await file.text();var res=await api('/opml/import',{method:'POST',body:{opml:text}});if(res.error)throw new Error(res.error);$('#opml-result').style.display='block';$('#opml-result').innerHTML='<strong>インポート完了</strong><br>インポート: '+res.imported+'件 / スキップ: '+res.skipped+'件 / 合計: '+res.total+'件';await loadFeeds();await loadStats();}
    catch(e){$('#opml-modal-error').textContent=e.message;}
  };
});

$('#btn-export-opml').addEventListener('click', function(){ window.location.href='/api/opml/export'; });

$('#btn-refresh-all').addEventListener('click', async function() {
  var b=$('#btn-refresh-all');b.style.animation='spin 1s linear infinite';
  await api('/feeds/refresh-all',{method:'POST'});b.style.animation='';
  await loadFeeds();await loadStats();
  if(currentFeedId)await loadArticles({feed_id:currentFeedId});
  else if(currentFolderId)await loadArticles({folder_id:currentFolderId});
  else setView(currentView);
});

$('#btn-mark-all-read').addEventListener('click', async function() {
  if(currentFeedId){
    await api('/feeds/'+currentFeedId+'/mark-all-read',{method:'PUT'});
    await loadFeeds();await loadStats();
    var feeds=await api('/feeds');
    var idx=feeds.findIndex(function(f){return f.id===currentFeedId});
    for(var i=idx+1;i<feeds.length;i++){if(feeds[i].unread_count>0){await selectFeed(feeds[i].id,feeds[i].title);return;}}
    for(var i=0;i<idx;i++){if(feeds[i].unread_count>0){await selectFeed(feeds[i].id,feeds[i].title);return;}}
    setView(currentView);
  } else if(currentFolderId){
    var feeds=await api('/feeds');
    for(var i=0;i<feeds.length;i++){if(feeds[i].folder_id===currentFolderId)await api('/feeds/'+feeds[i].id+'/mark-all-read',{method:'PUT'});}
    await loadFeeds();await loadStats();setView(currentView);
  } else { await api('/mark-all-read',{method:'PUT'});await loadFeeds();await loadStats();setView(currentView); }
});

$('#btn-star').addEventListener('click', async function() {
  if(!currentArticleId)return;
  var s=$('#btn-star').dataset.starred==='1';
  await api('/articles/'+currentArticleId+'/'+(s?'unstar':'star'),{method:'PUT'});
  var a=await api('/articles/'+currentArticleId);renderContent(a);
  if(currentView==='starred')setView('starred');
});

$('#btn-delete-feed').addEventListener('click', async function() {
  if(currentView==='folder'&&currentFolderId){
    var folders=await api('/folders');var folder=folders.find(function(f){return f.id===currentFolderId});
    var feeds=await api('/feeds');var folderFeeds=feeds.filter(function(f){return f.folder_id===currentFolderId});
    if(!confirm('「'+(folder?folder.name:'このカテゴリー')+'」を削除しますか?\n中のフィード('+folderFeeds.length+'件)も全て削除されます。'))return;
    for(var i=0;i<folderFeeds.length;i++)await api('/feeds/'+folderFeeds[i].id,{method:'DELETE'});
    setView('all');await loadFeeds();await loadStats();
  } else if(currentFeedId){
    var feeds=await api('/feeds');var feed=feeds.find(function(f){return f.id===currentFeedId});
    if(!feed)return;
    if(!confirm('「'+feed.title+'」を削除しますか?\nこのフィードの全記事も削除されます。'))return;
    await api('/feeds/'+currentFeedId,{method:'DELETE'});
    setView('all');await loadFeeds();await loadStats();
  }
});

$('#search-input').addEventListener('input',function(e){clearTimeout(searchTimeout);var q=e.target.value.trim();searchTimeout=setTimeout(function(){q?loadArticles({search:q}):setView(currentView)},300)});
$('#modal-overlay').addEventListener('click',function(e){if(e.target===e.currentTarget)hideModal()});

document.addEventListener('keydown',function(e){
  if(e.target.tagName==='INPUT')return;
  var cards=Array.prototype.slice.call($$('.article-card'));
  var idx=-1;for(var i=0;i<cards.length;i++){if(cards[i].dataset.articleId==currentArticleId){idx=i;break}}
  if(e.key==='j'||e.key==='ArrowDown'){e.preventDefault();var n=idx<cards.length-1?cards[idx+1]:cards[0];if(n)selectArticle(parseInt(n.dataset.articleId))}
  if(e.key==='k'||e.key==='ArrowUp'){e.preventDefault();var p=idx>0?cards[idx-1]:cards[cards.length-1];if(p)selectArticle(parseInt(p.dataset.articleId))}
  if(e.key==='g'){e.preventDefault();var n=idx<cards.length-1?cards[idx+1]:cards[0];if(n)selectArticle(parseInt(n.dataset.articleId))}
  if(e.key==='f'){
    e.preventDefault();
    for(var i=idx+1;i<cards.length;i++){if(cards[i].classList.contains('unread')){selectArticle(parseInt(cards[i].dataset.articleId));return;}}
    for(var i=0;i<=idx;i++){if(cards[i].classList.contains('unread')){selectArticle(parseInt(cards[i].dataset.articleId));return;}}
    loadFeeds().then(async function(){
      var feeds=await api('/feeds');
      var ci=-1;for(var i=0;i<feeds.length;i++){if(feeds[i].id===currentFeedId){ci=i;break}}
      for(var i=(ci>=0?ci+1:0);i<feeds.length;i++){if(feeds[i].unread_count>0){await selectFeed(feeds[i].id,feeds[i].title);var d=await api('/articles?feed_id='+feeds[i].id+'&unread=1&limit=1');if(d.articles&&d.articles.length>0)selectArticle(d.articles[0].id);return;}}
      for(var i=0;i<=ci;i++){if(feeds[i].unread_count>0){await selectFeed(feeds[i].id,feeds[i].title);var d=await api('/articles?feed_id='+feeds[i].id+'&unread=1&limit=1');if(d.articles&&d.articles.length>0)selectArticle(d.articles[0].id);return;}}
    });
  }
  if(e.key==='d'){e.preventDefault();var p=idx>0?cards[idx-1]:cards[cards.length-1];if(p)selectArticle(parseInt(p.dataset.articleId))}
  if(e.key==='r')$('#btn-refresh-all').click();
  if(e.key==='/'){e.preventDefault();$('#search-input').focus()}
});

var st=document.createElement('style');st.textContent='@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}';document.head.appendChild(st);
loadFeeds();loadStats();setView('all');
APPJS

echo "--- npm 依存関係をインストール中 ---"
cd "$INSTALL_DIR"
npm install --production 2>&1 | tail -5

echo "--- systemd サービスを作成中 ---"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << SVC
[Unit]
Description=selfrss - Self-hosted RSS Reader
After=network.target

[Service]
Type=simple
WorkingDirectory=${INSTALL_DIR}
ExecStart=$(which node) server/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=${PORT}
Environment=HOST=0.0.0.0

[Install]
WantedBy=multi-user.target
SVC

systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl start "$SERVICE_NAME"

TS_IP=$(tailscale ip -4 2>/dev/null || echo "N/A")
TS_HOSTNAME=$(tailscale status --json 2>/dev/null | grep -o '"DNSName":"[^"]*"' | head -1 | cut -d'"' -f4 || echo "")
if [ -z "$TS_HOSTNAME" ]; then TS_HOSTNAME=$(hostname); fi

sleep 2
if systemctl is-active --quiet "$SERVICE_NAME"; then
  echo ""
  echo "=== selfrss v9 のインストールが完了しました ==="
  echo ""
  echo "  Web UI : http://${TS_IP}:${PORT}"
  echo "  Web UI : http://${TS_HOSTNAME%%.*}:${PORT}  (MagicDNS)"
  echo ""
  echo "  インストール先: ${INSTALL_DIR}"
  echo ""
  echo "  コマンド:"
  echo "    systemctl status ${SERVICE_NAME}"
  echo "    systemctl restart ${SERVICE_NAME}"
  echo "    journalctl -u ${SERVICE_NAME} -f"
  echo ""
  echo "  機能:"
  echo "    - 3ペインUI(フィード / 記事一覧 / 記事本文)"
  echo "    - ダーク/ライトテーマ切替"
  echo "    - OPML インポート/エクスポート(属性順序に依存しないパーサ)"
  echo "    - 全文取得(EUC-JP/SJIS/UTF-8 自動判別)"
  echo "    - ショートカットキー: j/k/g/f/d/r/"
else
  echo "エラー: サービスの起動に失敗しました"
  systemctl status "$SERVICE_NAME" --no-pager
  exit 1
fi

基本操作

フィード追加

  • 左上の + ボタンをクリック
  • URLを入力

– フィードURL(https://example.com/feed.xml)→ そのまま追加
– サイトURL(https://example.com/)→ RSS/Atomフィードを自動検出

  • カテゴリーを選択(任意)
  • 「追加」ボタンをクリック

カテゴリー(フォルダ)追加

  • 「Feeds」セクション右の + ボタンをクリック
  • カテゴリー名を入力
  • 「追加」ボタンをクリック

記事を読む

記事一覧から記事をクリックすると、右ペインに本文が表示されます。記事を読むと自動的に既読になります。

お気に入り

記事本文ツールバーの  アイコンをクリックするとお気に入りに登録/解除されます。


サイドバー

ナビゲーション

項目説明
全記事全ての記事を表示(灰色バッジに件数表示)
未読未読の記事のみを表示
お気に入りお気に入りに登録した記事を表示(灰色バッジに件数表示)

フィード一覧

  • カテゴリー名の右に 灰色バッジ で未読数を表示
  • フィード名の右に 青バッジ で未読数を表示
  • カテゴリー・フィードをクリックすると、その中の記事一覧を表示
  • フィード0のカテゴリーも表示されます

ツールバー

サイドバー上部

ボタン機能
フィード追加
アップ矢印OPMLインポート
ダウン矢印OPMLエクスポート
再生ボタン全フィード更新
太陽/月テーマ切替(ダーク/ライト)

記事一覧ヘッダー

ボタン/項目機能
三本線サイドバー表示/非表示切替
グリッド表示モード切替(詳細/コンパクト)
ゴミ箱フィード/カテゴリー削除(選択時のみ表示)
検索ボックス記事タイトル・内容を検索
チェックマーク全て既読にする

表示モード

モード表示内容
詳細(デフォルト)タイトル2行 + フィード名 + 概要
コンパクトタイトル1行のみ


ヘッダーのグリッドボタンで切り替えます。設定はブラウザに保存されます。


OPML インポート/エクスポート

インポート

  • サイドバー上のアップ矢印アイコンをクリック
  • OPMLファイルを選択
  • 「インポート」ボタンをクリック

エクスポート

サイドバー上のダウン矢印アイコンをクリックすると、OPMLファイルがダウンロードされます。


ショートカットキー

キー機能
j / 次の記事へ
k / 前の記事へ
g次の記事へ
f次の未読記事へ(現在のフィードの未読がなくなると次の未読フィードへ自動移動)
d前の記事へ
r全フィード更新
/検索ボックスにフォーカス

フィード/カテゴリーの削除

フィード削除

  • 削除するフィードをサイドバーで選択
  • 記事一覧ヘッダーのゴミ箱アイコンをクリック
  • 確認ダイアログで「OK」をクリック

カテゴリー削除

  • 削除するカテゴリーをサイドバーで選択
  • 記事一覧ヘッダーのゴミ箱アイコンをクリック
  • 確認ダイアログで「OK」をクリック(中のフィードも全て削除されます)

テーマ切替

サイドバー右上の太陽/月アイコンでダークテーマとライトテーマを切り替えます。設定はブラウザに保存されます。


全文取得

RSSフィードにサマリーのみ含まれる記事でも、記事ページから自動的に全文を取得します。対応している文字エンコーディング:

  • UTF-8
  • EUC-JP
  • Shift_JIS
  • ISO-2022-JP

自動更新

30分ごとに全フィードを自動更新します。手動更新はヘッダーの再生ボタンまたは r キーで行えます。


API エンドポイント

メソッドパス説明
GET/api/feedsフィード一覧
POST/api/feedsフィード追加
DELETE/api/feeds/:idフィード削除
POST/api/feeds/discoverサイトからフィード検出
POST/api/feeds/refresh-all全フィード更新
GET/api/foldersカテゴリー一覧
POST/api/foldersカテゴリー追加
GET/api/articles記事一覧(feed_idfolder_idunreadstarredsearch で絞り込み)
PUT/api/articles/:id/read既読にする
PUT/api/articles/:id/starお気に入りに登録
PUT/api/mark-all-read全て既読にする
GET/api/stats統計情報
POST/api/opml/importOPMLインポート
GET/api/opml/exportOPMLエクスポート

アンインストール

サービス削除、データ削除(確認あり)、データのみ退避してアプリ削除、の3パターンに対応しています。

#!/bin/bash
# selfrss アンインストーラ
# 使い方: sudo bash uninstall-selfrss9.sh [インストール先]
# デフォルト: /opt/selfrss
set -e
INSTALL_DIR="${1:-/opt/selfrss}"
SERVICE_NAME="selfrss"

if [ "$EUID" -ne 0 ]; then echo "エラー: sudo で実行してください"; exit 1; fi

echo "=== selfrss アンインストーラ ==="
echo "インストール先: $INSTALL_DIR"
echo ""

if systemctl list-unit-files | grep -q "^${SERVICE_NAME}.service"; then
  echo "--- サービスを停止・無効化中 ---"
  systemctl stop "$SERVICE_NAME" 2>/dev/null || true
  systemctl disable "$SERVICE_NAME" 2>/dev/null || true
  rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
  systemctl daemon-reload
  echo "サービスを削除しました"
else
  echo "サービスは見つかりませんでした(スキップ)"
fi

if [ -d "$INSTALL_DIR" ]; then
  read -p "データベースを含む ${INSTALL_DIR} を削除しますか? [データのバックアップを残す場合は n] (y/N): " confirm
  if [[ "$confirm" =~ ^[Yy]$ ]]; then
    rm -rf "$INSTALL_DIR"
    echo "${INSTALL_DIR} を削除しました"
  else
    read -p "データ(${INSTALL_DIR}/data)だけ残してアプリ本体だけ削除しますか? (y/N): " confirm2
    if [[ "$confirm2" =~ ^[Yy]$ ]]; then
      BACKUP_DIR="${INSTALL_DIR}-data-backup-$(date +%Y%m%d%H%M%S)"
      mv "${INSTALL_DIR}/data" "$BACKUP_DIR"
      rm -rf "$INSTALL_DIR"
      echo "データを ${BACKUP_DIR} に退避し、アプリ本体を削除しました"
    else
      echo "${INSTALL_DIR} は削除されませんでした"
    fi
  fi
else
  echo "${INSTALL_DIR} は見つかりませんでした(スキップ)"
fi

echo ""
echo "=== アンインストール処理が完了しました ==="

タイトルとURLをコピーしました