Add searching feature
This commit is contained in:
		@@ -91,7 +91,8 @@ function App() {
 | 
			
		||||
  const loadData = async () => {
 | 
			
		||||
    setLoading(true);
 | 
			
		||||
    try {
 | 
			
		||||
      const [feedsRes, statsRes, envRes, requestsRes, episodesRes] = await Promise.all([
 | 
			
		||||
      const [feedsRes, statsRes, envRes, requestsRes, episodesRes] =
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
          fetch("/api/admin/feeds"),
 | 
			
		||||
          fetch("/api/admin/stats"),
 | 
			
		||||
          fetch("/api/admin/env"),
 | 
			
		||||
@@ -99,11 +100,18 @@ function App() {
 | 
			
		||||
          fetch("/api/admin/episodes"),
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
      if (!feedsRes.ok || !statsRes.ok || !envRes.ok || !requestsRes.ok || !episodesRes.ok) {
 | 
			
		||||
      if (
 | 
			
		||||
        !feedsRes.ok ||
 | 
			
		||||
        !statsRes.ok ||
 | 
			
		||||
        !envRes.ok ||
 | 
			
		||||
        !requestsRes.ok ||
 | 
			
		||||
        !episodesRes.ok
 | 
			
		||||
      ) {
 | 
			
		||||
        throw new Error("Failed to load data");
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const [feedsData, statsData, envData, requestsData, episodesData] = await Promise.all([
 | 
			
		||||
      const [feedsData, statsData, envData, requestsData, episodesData] =
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
          feedsRes.json(),
 | 
			
		||||
          statsRes.json(),
 | 
			
		||||
          envRes.json(),
 | 
			
		||||
@@ -580,20 +588,36 @@ function App() {
 | 
			
		||||
              </p>
 | 
			
		||||
 | 
			
		||||
              <div style={{ marginBottom: "20px" }}>
 | 
			
		||||
                <div className="stats-grid" style={{ gridTemplateColumns: "repeat(3, 1fr)" }}>
 | 
			
		||||
                <div
 | 
			
		||||
                  className="stats-grid"
 | 
			
		||||
                  style={{ gridTemplateColumns: "repeat(3, 1fr)" }}
 | 
			
		||||
                >
 | 
			
		||||
                  <div className="stat-card">
 | 
			
		||||
                    <div className="value">{episodes.length}</div>
 | 
			
		||||
                    <div className="label">総エピソード数</div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div className="stat-card">
 | 
			
		||||
                    <div className="value">
 | 
			
		||||
                      {episodes.filter(ep => ep.feedCategory).map(ep => ep.feedCategory).filter((category, index, arr) => arr.indexOf(category) === index).length}
 | 
			
		||||
                      {
 | 
			
		||||
                        episodes
 | 
			
		||||
                          .filter((ep) => ep.feedCategory)
 | 
			
		||||
                          .map((ep) => ep.feedCategory)
 | 
			
		||||
                          .filter(
 | 
			
		||||
                            (category, index, arr) =>
 | 
			
		||||
                              arr.indexOf(category) === index,
 | 
			
		||||
                          ).length
 | 
			
		||||
                      }
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div className="label">カテゴリー数</div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div className="stat-card">
 | 
			
		||||
                    <div className="value">
 | 
			
		||||
                      {episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / (1024 * 1024) > 1 
 | 
			
		||||
                      {episodes.reduce(
 | 
			
		||||
                        (acc, ep) => acc + (ep.fileSize || 0),
 | 
			
		||||
                        0,
 | 
			
		||||
                      ) /
 | 
			
		||||
                        (1024 * 1024) >
 | 
			
		||||
                      1
 | 
			
		||||
                        ? `${Math.round(episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / (1024 * 1024))}MB`
 | 
			
		||||
                        : `${Math.round(episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / 1024)}KB`}
 | 
			
		||||
                    </div>
 | 
			
		||||
@@ -620,37 +644,79 @@ function App() {
 | 
			
		||||
                      <li key={episode.id} className="feed-item">
 | 
			
		||||
                        <div className="feed-info">
 | 
			
		||||
                          <h3>{episode.title}</h3>
 | 
			
		||||
                          <div className="url" style={{ fontSize: "14px", color: "#666" }}>
 | 
			
		||||
                          <div
 | 
			
		||||
                            className="url"
 | 
			
		||||
                            style={{ fontSize: "14px", color: "#666" }}
 | 
			
		||||
                          >
 | 
			
		||||
                            フィード: {episode.feedTitle || episode.feedUrl}
 | 
			
		||||
                            {episode.feedCategory && (
 | 
			
		||||
                              <span style={{ marginLeft: "8px", padding: "2px 6px", background: "#e9ecef", borderRadius: "4px", fontSize: "12px" }}>
 | 
			
		||||
                              <span
 | 
			
		||||
                                style={{
 | 
			
		||||
                                  marginLeft: "8px",
 | 
			
		||||
                                  padding: "2px 6px",
 | 
			
		||||
                                  background: "#e9ecef",
 | 
			
		||||
                                  borderRadius: "4px",
 | 
			
		||||
                                  fontSize: "12px",
 | 
			
		||||
                                }}
 | 
			
		||||
                              >
 | 
			
		||||
                                {episode.feedCategory}
 | 
			
		||||
                              </span>
 | 
			
		||||
                            )}
 | 
			
		||||
                          </div>
 | 
			
		||||
                          <div className="url" style={{ fontSize: "14px", color: "#666" }}>
 | 
			
		||||
                            記事: <a href={episode.articleLink} target="_blank" rel="noopener noreferrer">{episode.articleTitle}</a>
 | 
			
		||||
                          <div
 | 
			
		||||
                            className="url"
 | 
			
		||||
                            style={{ fontSize: "14px", color: "#666" }}
 | 
			
		||||
                          >
 | 
			
		||||
                            記事:{" "}
 | 
			
		||||
                            <a
 | 
			
		||||
                              href={episode.articleLink}
 | 
			
		||||
                              target="_blank"
 | 
			
		||||
                              rel="noopener noreferrer"
 | 
			
		||||
                            >
 | 
			
		||||
                              {episode.articleTitle}
 | 
			
		||||
                            </a>
 | 
			
		||||
                          </div>
 | 
			
		||||
                          {episode.description && (
 | 
			
		||||
                            <div style={{ fontSize: "14px", color: "#777", marginTop: "4px" }}>
 | 
			
		||||
                            <div
 | 
			
		||||
                              style={{
 | 
			
		||||
                                fontSize: "14px",
 | 
			
		||||
                                color: "#777",
 | 
			
		||||
                                marginTop: "4px",
 | 
			
		||||
                              }}
 | 
			
		||||
                            >
 | 
			
		||||
                              {episode.description.length > 100
 | 
			
		||||
                                ? episode.description.substring(0, 100) + "..."
 | 
			
		||||
                                : episode.description}
 | 
			
		||||
                            </div>
 | 
			
		||||
                          )}
 | 
			
		||||
                          <div style={{ fontSize: "12px", color: "#999", marginTop: "8px" }}>
 | 
			
		||||
                            <span>作成日: {new Date(episode.createdAt).toLocaleString("ja-JP")}</span>
 | 
			
		||||
                          <div
 | 
			
		||||
                            style={{
 | 
			
		||||
                              fontSize: "12px",
 | 
			
		||||
                              color: "#999",
 | 
			
		||||
                              marginTop: "8px",
 | 
			
		||||
                            }}
 | 
			
		||||
                          >
 | 
			
		||||
                            <span>
 | 
			
		||||
                              作成日:{" "}
 | 
			
		||||
                              {new Date(episode.createdAt).toLocaleString(
 | 
			
		||||
                                "ja-JP",
 | 
			
		||||
                              )}
 | 
			
		||||
                            </span>
 | 
			
		||||
                            {episode.duration && (
 | 
			
		||||
                              <>
 | 
			
		||||
                                <span style={{ margin: "0 8px" }}>|</span>
 | 
			
		||||
                                <span>再生時間: {Math.round(episode.duration / 60)}分</span>
 | 
			
		||||
                                <span>
 | 
			
		||||
                                  再生時間: {Math.round(episode.duration / 60)}
 | 
			
		||||
                                  分
 | 
			
		||||
                                </span>
 | 
			
		||||
                              </>
 | 
			
		||||
                            )}
 | 
			
		||||
                            {episode.fileSize && (
 | 
			
		||||
                              <>
 | 
			
		||||
                                <span style={{ margin: "0 8px" }}>|</span>
 | 
			
		||||
                                <span>
 | 
			
		||||
                                  ファイルサイズ: {episode.fileSize > 1024 * 1024 
 | 
			
		||||
                                  ファイルサイズ:{" "}
 | 
			
		||||
                                  {episode.fileSize > 1024 * 1024
 | 
			
		||||
                                    ? `${Math.round(episode.fileSize / (1024 * 1024))}MB`
 | 
			
		||||
                                    : `${Math.round(episode.fileSize / 1024)}KB`}
 | 
			
		||||
                                </span>
 | 
			
		||||
@@ -665,7 +731,7 @@ function App() {
 | 
			
		||||
                              style={{
 | 
			
		||||
                                fontSize: "12px",
 | 
			
		||||
                                color: "#007bff",
 | 
			
		||||
                                textDecoration: "none"
 | 
			
		||||
                                textDecoration: "none",
 | 
			
		||||
                              }}
 | 
			
		||||
                            >
 | 
			
		||||
                              🎵 音声ファイルを再生
 | 
			
		||||
@@ -675,7 +741,9 @@ function App() {
 | 
			
		||||
                        <div className="feed-actions">
 | 
			
		||||
                          <button
 | 
			
		||||
                            className="btn btn-danger"
 | 
			
		||||
                            onClick={() => deleteEpisode(episode.id, episode.title)}
 | 
			
		||||
                            onClick={() =>
 | 
			
		||||
                              deleteEpisode(episode.id, episode.title)
 | 
			
		||||
                            }
 | 
			
		||||
                          >
 | 
			
		||||
                            削除
 | 
			
		||||
                          </button>
 | 
			
		||||
 
 | 
			
		||||
@@ -8,8 +8,8 @@ import { batchScheduler } from "./services/batch-scheduler.js";
 | 
			
		||||
import { config, validateConfig } from "./services/config.js";
 | 
			
		||||
import { closeBrowser } from "./services/content-extractor.js";
 | 
			
		||||
import {
 | 
			
		||||
  deleteFeed,
 | 
			
		||||
  deleteEpisode,
 | 
			
		||||
  deleteFeed,
 | 
			
		||||
  fetchAllEpisodes,
 | 
			
		||||
  fetchEpisodesWithArticles,
 | 
			
		||||
  getAllCategories,
 | 
			
		||||
@@ -414,7 +414,7 @@ app.get("/api/admin/db-diagnostic", async (c) => {
 | 
			
		||||
    return c.json(
 | 
			
		||||
      {
 | 
			
		||||
        error: "Failed to run database diagnostic",
 | 
			
		||||
        details: error instanceof Error ? error.message : String(error) 
 | 
			
		||||
        details: error instanceof Error ? error.message : String(error),
 | 
			
		||||
      },
 | 
			
		||||
      500,
 | 
			
		||||
    );
 | 
			
		||||
@@ -702,14 +702,14 @@ app.get("/index.html", serveAdminIndex);
 | 
			
		||||
app.get("*", serveAdminIndex);
 | 
			
		||||
 | 
			
		||||
// Graceful shutdown
 | 
			
		||||
process.on('SIGINT', async () => {
 | 
			
		||||
  console.log('\n🛑 Received SIGINT. Graceful shutdown...');
 | 
			
		||||
process.on("SIGINT", async () => {
 | 
			
		||||
  console.log("\n🛑 Received SIGINT. Graceful shutdown...");
 | 
			
		||||
  await closeBrowser();
 | 
			
		||||
  process.exit(0);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
process.on('SIGTERM', async () => {
 | 
			
		||||
  console.log('\n🛑 Received SIGTERM. Graceful shutdown...');
 | 
			
		||||
process.on("SIGTERM", async () => {
 | 
			
		||||
  console.log("\n🛑 Received SIGTERM. Graceful shutdown...");
 | 
			
		||||
  await closeBrowser();
 | 
			
		||||
  process.exit(0);
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -9,9 +9,13 @@ import RSSEndpoints from "./components/RSSEndpoints";
 | 
			
		||||
 | 
			
		||||
function App() {
 | 
			
		||||
  const location = useLocation();
 | 
			
		||||
  const isMainPage = ["/", "/feeds", "/categories", "/feed-requests", "/rss-endpoints"].includes(
 | 
			
		||||
    location.pathname,
 | 
			
		||||
  );
 | 
			
		||||
  const isMainPage = [
 | 
			
		||||
    "/",
 | 
			
		||||
    "/feeds",
 | 
			
		||||
    "/categories",
 | 
			
		||||
    "/feed-requests",
 | 
			
		||||
    "/rss-endpoints",
 | 
			
		||||
  ].includes(location.pathname);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="container">
 | 
			
		||||
 
 | 
			
		||||
@@ -70,7 +70,6 @@ function CategoryList() {
 | 
			
		||||
    return groupedFeeds[category]?.length || 0;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  if (loading) {
 | 
			
		||||
    return <div className="loading">読み込み中...</div>;
 | 
			
		||||
  }
 | 
			
		||||
@@ -138,7 +137,10 @@ function CategoryList() {
 | 
			
		||||
              <div className="category-feeds-preview">
 | 
			
		||||
                {groupedFeeds[category]?.slice(0, 3).map((feed) => (
 | 
			
		||||
                  <div key={feed.id} className="feed-preview">
 | 
			
		||||
                    <Link to={`/feeds/${feed.id}`} className="feed-preview-link">
 | 
			
		||||
                    <Link
 | 
			
		||||
                      to={`/feeds/${feed.id}`}
 | 
			
		||||
                      className="feed-preview-link"
 | 
			
		||||
                    >
 | 
			
		||||
                      {feed.title || feed.url}
 | 
			
		||||
                    </Link>
 | 
			
		||||
                  </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -182,11 +182,17 @@ function EpisodeDetail() {
 | 
			
		||||
        <meta property="og:locale" content="ja_JP" />
 | 
			
		||||
 | 
			
		||||
        {/* Image metadata */}
 | 
			
		||||
        <meta property="og:image" content={`${window.location.origin}/default-thumbnail.svg`} />
 | 
			
		||||
        <meta
 | 
			
		||||
          property="og:image"
 | 
			
		||||
          content={`${window.location.origin}/default-thumbnail.svg`}
 | 
			
		||||
        />
 | 
			
		||||
        <meta property="og:image:type" content="image/svg+xml" />
 | 
			
		||||
        <meta property="og:image:width" content="1200" />
 | 
			
		||||
        <meta property="og:image:height" content="630" />
 | 
			
		||||
        <meta property="og:image:alt" content={`${episode.title} - Voice RSS Summary`} />
 | 
			
		||||
        <meta
 | 
			
		||||
          property="og:image:alt"
 | 
			
		||||
          content={`${episode.title} - Voice RSS Summary`}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        {/* Twitter Card metadata */}
 | 
			
		||||
        <meta name="twitter:card" content="summary_large_image" />
 | 
			
		||||
@@ -195,8 +201,14 @@ function EpisodeDetail() {
 | 
			
		||||
          name="twitter:description"
 | 
			
		||||
          content={episode.description || `${episode.title}のエピソード詳細`}
 | 
			
		||||
        />
 | 
			
		||||
        <meta name="twitter:image" content={`${window.location.origin}/default-thumbnail.svg`} />
 | 
			
		||||
        <meta name="twitter:image:alt" content={`${episode.title} - Voice RSS Summary`} />
 | 
			
		||||
        <meta
 | 
			
		||||
          name="twitter:image"
 | 
			
		||||
          content={`${window.location.origin}/default-thumbnail.svg`}
 | 
			
		||||
        />
 | 
			
		||||
        <meta
 | 
			
		||||
          name="twitter:image:alt"
 | 
			
		||||
          content={`${episode.title} - Voice RSS Summary`}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        {/* Audio-specific metadata */}
 | 
			
		||||
        <meta
 | 
			
		||||
 
 | 
			
		||||
@@ -32,9 +32,13 @@ interface EpisodeWithFeedInfo {
 | 
			
		||||
 | 
			
		||||
function EpisodeList() {
 | 
			
		||||
  const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
 | 
			
		||||
  const [filteredEpisodes, setFilteredEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
 | 
			
		||||
  const [filteredEpisodes, setFilteredEpisodes] = useState<
 | 
			
		||||
    EpisodeWithFeedInfo[]
 | 
			
		||||
  >([]);
 | 
			
		||||
  const [categories, setCategories] = useState<string[]>([]);
 | 
			
		||||
  const [selectedCategory, setSelectedCategory] = useState<string>("");
 | 
			
		||||
  const [searchQuery, setSearchQuery] = useState<string>("");
 | 
			
		||||
  const [isSearching, setIsSearching] = useState(false);
 | 
			
		||||
  const [loading, setLoading] = useState(true);
 | 
			
		||||
  const [error, setError] = useState<string | null>(null);
 | 
			
		||||
  const [currentAudio, setCurrentAudio] = useState<string | null>(null);
 | 
			
		||||
@@ -46,8 +50,12 @@ function EpisodeList() {
 | 
			
		||||
  }, [useDatabase]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (searchQuery.trim()) {
 | 
			
		||||
      performSearch();
 | 
			
		||||
    } else {
 | 
			
		||||
      filterEpisodesByCategory();
 | 
			
		||||
  }, [episodes, selectedCategory]);
 | 
			
		||||
    }
 | 
			
		||||
  }, [episodes, selectedCategory, searchQuery]);
 | 
			
		||||
 | 
			
		||||
  const fetchEpisodes = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
@@ -127,12 +135,41 @@ function EpisodeList() {
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const performSearch = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      setIsSearching(true);
 | 
			
		||||
      setError(null);
 | 
			
		||||
 | 
			
		||||
      const searchParams = new URLSearchParams({
 | 
			
		||||
        q: searchQuery.trim(),
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (selectedCategory) {
 | 
			
		||||
        searchParams.append("category", selectedCategory);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const response = await fetch(`/api/episodes/search?${searchParams}`);
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error("検索に失敗しました");
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const data = await response.json();
 | 
			
		||||
      setFilteredEpisodes(data.episodes || []);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error("Search error:", err);
 | 
			
		||||
      setError(err instanceof Error ? err.message : "検索エラーが発生しました");
 | 
			
		||||
      setFilteredEpisodes([]);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setIsSearching(false);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const filterEpisodesByCategory = () => {
 | 
			
		||||
    if (!selectedCategory) {
 | 
			
		||||
      setFilteredEpisodes(episodes);
 | 
			
		||||
    } else {
 | 
			
		||||
      const filtered = episodes.filter(episode => 
 | 
			
		||||
        episode.feedCategory === selectedCategory
 | 
			
		||||
      const filtered = episodes.filter(
 | 
			
		||||
        (episode) => episode.feedCategory === selectedCategory,
 | 
			
		||||
      );
 | 
			
		||||
      setFilteredEpisodes(filtered);
 | 
			
		||||
    }
 | 
			
		||||
@@ -197,7 +234,40 @@ function EpisodeList() {
 | 
			
		||||
    return <div className="error">{error}</div>;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (filteredEpisodes.length === 0 && episodes.length > 0 && selectedCategory) {
 | 
			
		||||
  if (filteredEpisodes.length === 0 && episodes.length > 0) {
 | 
			
		||||
    if (searchQuery.trim()) {
 | 
			
		||||
      return (
 | 
			
		||||
        <div className="empty-state">
 | 
			
		||||
          <p>「{searchQuery}」の検索結果がありません</p>
 | 
			
		||||
          {selectedCategory && (
 | 
			
		||||
            <p>カテゴリ「{selectedCategory}」内で検索しています</p>
 | 
			
		||||
          )}
 | 
			
		||||
          <div
 | 
			
		||||
            style={{
 | 
			
		||||
              marginTop: "10px",
 | 
			
		||||
              display: "flex",
 | 
			
		||||
              gap: "10px",
 | 
			
		||||
              justifyContent: "center",
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <button
 | 
			
		||||
              className="btn btn-secondary"
 | 
			
		||||
              onClick={() => setSearchQuery("")}
 | 
			
		||||
            >
 | 
			
		||||
              検索をクリア
 | 
			
		||||
            </button>
 | 
			
		||||
            {selectedCategory && (
 | 
			
		||||
              <button
 | 
			
		||||
                className="btn btn-secondary"
 | 
			
		||||
                onClick={() => setSelectedCategory("")}
 | 
			
		||||
              >
 | 
			
		||||
                全カテゴリで検索
 | 
			
		||||
              </button>
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      );
 | 
			
		||||
    } else if (selectedCategory) {
 | 
			
		||||
      return (
 | 
			
		||||
        <div className="empty-state">
 | 
			
		||||
          <p>カテゴリ「{selectedCategory}」のエピソードがありません</p>
 | 
			
		||||
@@ -211,6 +281,7 @@ function EpisodeList() {
 | 
			
		||||
        </div>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (episodes.length === 0) {
 | 
			
		||||
    return (
 | 
			
		||||
@@ -235,22 +306,79 @@ function EpisodeList() {
 | 
			
		||||
      <div
 | 
			
		||||
        style={{
 | 
			
		||||
          marginBottom: "20px",
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            display: "flex",
 | 
			
		||||
            justifyContent: "space-between",
 | 
			
		||||
            alignItems: "center",
 | 
			
		||||
            marginBottom: "15px",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
        <h2>エピソード一覧 ({selectedCategory ? `${filteredEpisodes.length}/${episodes.length}` : episodes.length}件)</h2>
 | 
			
		||||
          <h2>
 | 
			
		||||
            エピソード一覧 (
 | 
			
		||||
            {searchQuery
 | 
			
		||||
              ? `検索結果: ${filteredEpisodes.length}件`
 | 
			
		||||
              : selectedCategory
 | 
			
		||||
                ? `${filteredEpisodes.length}/${episodes.length}件`
 | 
			
		||||
                : `${episodes.length}件`}
 | 
			
		||||
            )
 | 
			
		||||
          </h2>
 | 
			
		||||
          <div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
 | 
			
		||||
            <span style={{ fontSize: "12px", color: "#666" }}>
 | 
			
		||||
              データソース: {useDatabase ? "データベース" : "XML"}
 | 
			
		||||
            </span>
 | 
			
		||||
            <button className="btn btn-secondary" onClick={fetchEpisodes}>
 | 
			
		||||
              更新
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            display: "flex",
 | 
			
		||||
            gap: "10px",
 | 
			
		||||
            alignItems: "center",
 | 
			
		||||
            flexWrap: "wrap",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <input
 | 
			
		||||
            type="text"
 | 
			
		||||
            placeholder="エピソードを検索..."
 | 
			
		||||
            value={searchQuery}
 | 
			
		||||
            onChange={(e) => setSearchQuery(e.target.value)}
 | 
			
		||||
            style={{
 | 
			
		||||
              flex: "1",
 | 
			
		||||
              minWidth: "200px",
 | 
			
		||||
              padding: "8px 12px",
 | 
			
		||||
              fontSize: "14px",
 | 
			
		||||
              border: "1px solid #ccc",
 | 
			
		||||
              borderRadius: "4px",
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
          {searchQuery && (
 | 
			
		||||
            <button
 | 
			
		||||
              className="btn btn-secondary"
 | 
			
		||||
              onClick={() => setSearchQuery("")}
 | 
			
		||||
              style={{
 | 
			
		||||
                padding: "8px 12px",
 | 
			
		||||
                fontSize: "14px",
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              クリア
 | 
			
		||||
            </button>
 | 
			
		||||
          )}
 | 
			
		||||
          {categories.length > 0 && (
 | 
			
		||||
            <select
 | 
			
		||||
              value={selectedCategory}
 | 
			
		||||
              onChange={(e) => setSelectedCategory(e.target.value)}
 | 
			
		||||
              style={{
 | 
			
		||||
                padding: "5px 10px",
 | 
			
		||||
                padding: "8px 12px",
 | 
			
		||||
                fontSize: "14px",
 | 
			
		||||
                border: "1px solid #ccc",
 | 
			
		||||
                borderRadius: "4px",
 | 
			
		||||
                minWidth: "120px",
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <option value="">全カテゴリ</option>
 | 
			
		||||
@@ -261,12 +389,9 @@ function EpisodeList() {
 | 
			
		||||
              ))}
 | 
			
		||||
            </select>
 | 
			
		||||
          )}
 | 
			
		||||
          <span style={{ fontSize: "12px", color: "#666" }}>
 | 
			
		||||
            データソース: {useDatabase ? "データベース" : "XML"}
 | 
			
		||||
          </span>
 | 
			
		||||
          <button className="btn btn-secondary" onClick={fetchEpisodes}>
 | 
			
		||||
            更新
 | 
			
		||||
          </button>
 | 
			
		||||
          {isSearching && (
 | 
			
		||||
            <span style={{ fontSize: "14px", color: "#666" }}>検索中...</span>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
@@ -309,7 +434,13 @@ function EpisodeList() {
 | 
			
		||||
                      {episode.feedTitle}
 | 
			
		||||
                    </Link>
 | 
			
		||||
                    {episode.feedCategory && (
 | 
			
		||||
                      <span style={{ marginLeft: "8px", color: "#999", fontSize: "11px" }}>
 | 
			
		||||
                      <span
 | 
			
		||||
                        style={{
 | 
			
		||||
                          marginLeft: "8px",
 | 
			
		||||
                          color: "#999",
 | 
			
		||||
                          fontSize: "11px",
 | 
			
		||||
                        }}
 | 
			
		||||
                      >
 | 
			
		||||
                        ({episode.feedCategory})
 | 
			
		||||
                      </span>
 | 
			
		||||
                    )}
 | 
			
		||||
 
 | 
			
		||||
@@ -63,8 +63,8 @@ function FeedList() {
 | 
			
		||||
    if (!selectedCategory) {
 | 
			
		||||
      setFilteredFeeds(feeds);
 | 
			
		||||
    } else {
 | 
			
		||||
      const filtered = feeds.filter(feed => 
 | 
			
		||||
        feed.category === selectedCategory
 | 
			
		||||
      const filtered = feeds.filter(
 | 
			
		||||
        (feed) => feed.category === selectedCategory,
 | 
			
		||||
      );
 | 
			
		||||
      setFilteredFeeds(filtered);
 | 
			
		||||
    }
 | 
			
		||||
@@ -125,7 +125,13 @@ function FeedList() {
 | 
			
		||||
          alignItems: "center",
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <h2>フィード一覧 ({selectedCategory ? `${filteredFeeds.length}/${feeds.length}` : feeds.length}件)</h2>
 | 
			
		||||
        <h2>
 | 
			
		||||
          フィード一覧 (
 | 
			
		||||
          {selectedCategory
 | 
			
		||||
            ? `${filteredFeeds.length}/${feeds.length}`
 | 
			
		||||
            : feeds.length}
 | 
			
		||||
          件)
 | 
			
		||||
        </h2>
 | 
			
		||||
        <div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
 | 
			
		||||
          {categories.length > 0 && (
 | 
			
		||||
            <select
 | 
			
		||||
 
 | 
			
		||||
@@ -61,7 +61,9 @@ export default function RSSEndpoints() {
 | 
			
		||||
    <div className="rss-endpoints">
 | 
			
		||||
      <div className="rss-endpoints-header">
 | 
			
		||||
        <h1>RSS 配信エンドポイント</h1>
 | 
			
		||||
        <p>以下のRSSフィードURLをポッドキャストアプリに追加して音声コンテンツをお楽しみください。</p>
 | 
			
		||||
        <p>
 | 
			
		||||
          以下のRSSフィードURLをポッドキャストアプリに追加して音声コンテンツをお楽しみください。
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* メインフィード */}
 | 
			
		||||
@@ -182,15 +184,22 @@ export default function RSSEndpoints() {
 | 
			
		||||
        <div className="usage-cards">
 | 
			
		||||
          <div className="usage-card">
 | 
			
		||||
            <h3>1. ポッドキャストアプリを選択</h3>
 | 
			
		||||
            <p>Apple Podcasts、Google Podcasts、Spotify、Pocket Casts など、RSS フィードに対応したポッドキャストアプリを使用してください。</p>
 | 
			
		||||
            <p>
 | 
			
		||||
              Apple Podcasts、Google Podcasts、Spotify、Pocket Casts など、RSS
 | 
			
		||||
              フィードに対応したポッドキャストアプリを使用してください。
 | 
			
		||||
            </p>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="usage-card">
 | 
			
		||||
            <h3>2. RSS フィードを追加</h3>
 | 
			
		||||
            <p>上記のURLをコピーして、ポッドキャストアプリの「フィードを追加」や「URLから購読」機能を使用してください。</p>
 | 
			
		||||
            <p>
 | 
			
		||||
              上記のURLをコピーして、ポッドキャストアプリの「フィードを追加」や「URLから購読」機能を使用してください。
 | 
			
		||||
            </p>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="usage-card">
 | 
			
		||||
            <h3>3. エピソードを楽しむ</h3>
 | 
			
		||||
            <p>フィードが追加されると、新しいエピソードが自動的にアプリに配信されます。</p>
 | 
			
		||||
            <p>
 | 
			
		||||
              フィードが追加されると、新しいエピソードが自動的にアプリに配信されます。
 | 
			
		||||
            </p>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </section>
 | 
			
		||||
 
 | 
			
		||||
@@ -402,7 +402,7 @@ body {
 | 
			
		||||
  background: none;
 | 
			
		||||
  border: none;
 | 
			
		||||
  color: #495057;
 | 
			
		||||
  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
 | 
			
		||||
  font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
 | 
			
		||||
  font-size: 0.9rem;
 | 
			
		||||
  word-break: break-all;
 | 
			
		||||
  flex: 1;
 | 
			
		||||
@@ -419,7 +419,8 @@ body {
 | 
			
		||||
  flex-shrink: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.copy-btn, .open-btn {
 | 
			
		||||
.copy-btn,
 | 
			
		||||
.open-btn {
 | 
			
		||||
  background: #3498db;
 | 
			
		||||
  color: white;
 | 
			
		||||
  border: none;
 | 
			
		||||
@@ -435,7 +436,8 @@ body {
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.copy-btn:hover, .open-btn:hover {
 | 
			
		||||
.copy-btn:hover,
 | 
			
		||||
.open-btn:hover {
 | 
			
		||||
  background: #2980b9;
 | 
			
		||||
  transform: scale(1.05);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,10 @@ import crypto from "crypto";
 | 
			
		||||
import fs from "fs/promises";
 | 
			
		||||
import Parser from "rss-parser";
 | 
			
		||||
import { config } from "../services/config.js";
 | 
			
		||||
import { enhanceArticleContent, closeBrowser } from "../services/content-extractor.js";
 | 
			
		||||
import {
 | 
			
		||||
  closeBrowser,
 | 
			
		||||
  enhanceArticleContent,
 | 
			
		||||
} from "../services/content-extractor.js";
 | 
			
		||||
import {
 | 
			
		||||
  getFeedById,
 | 
			
		||||
  getFeedByUrl,
 | 
			
		||||
@@ -13,8 +16,8 @@ import {
 | 
			
		||||
  saveFeed,
 | 
			
		||||
} from "../services/database.js";
 | 
			
		||||
import {
 | 
			
		||||
  openAI_ClassifyFeed,
 | 
			
		||||
  openAI_ClassifyEpisode,
 | 
			
		||||
  openAI_ClassifyFeed,
 | 
			
		||||
  openAI_GeneratePodcastContent,
 | 
			
		||||
} from "../services/llm.js";
 | 
			
		||||
import { updatePodcastRSS } from "../services/podcast.js";
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										112
									
								
								server.ts
									
									
									
									
									
								
							
							
						
						
									
										112
									
								
								server.ts
									
									
									
									
									
								
							@@ -136,7 +136,10 @@ app.get("/podcast/category/:category.xml", async (c) => {
 | 
			
		||||
      "Cache-Control": "public, max-age=3600", // Cache for 1 hour
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(`Error generating category RSS for "${c.req.param("category")}":`, error);
 | 
			
		||||
    console.error(
 | 
			
		||||
      `Error generating category RSS for "${c.req.param("category")}":`,
 | 
			
		||||
      error,
 | 
			
		||||
    );
 | 
			
		||||
    return c.notFound();
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
@@ -157,7 +160,10 @@ app.get("/podcast/feed/:feedId.xml", async (c) => {
 | 
			
		||||
      "Cache-Control": "public, max-age=3600", // Cache for 1 hour
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(`Error generating feed RSS for "${c.req.param("feedId")}":`, error);
 | 
			
		||||
    console.error(
 | 
			
		||||
      `Error generating feed RSS for "${c.req.param("feedId")}":`,
 | 
			
		||||
      error,
 | 
			
		||||
    );
 | 
			
		||||
    return c.notFound();
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
@@ -206,7 +212,9 @@ async function serveIndex(c: any) {
 | 
			
		||||
async function serveEpisodePage(c: any, episodeId: string) {
 | 
			
		||||
  try {
 | 
			
		||||
    // First, try to get episode from database
 | 
			
		||||
    const { fetchEpisodeWithSourceInfo } = await import("./services/database.js");
 | 
			
		||||
    const { fetchEpisodeWithSourceInfo } = await import(
 | 
			
		||||
      "./services/database.js"
 | 
			
		||||
    );
 | 
			
		||||
    let episode = null;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
@@ -275,26 +283,24 @@ async function serveEpisodePage(c: any, episodeId: string) {
 | 
			
		||||
 | 
			
		||||
    // Replace title and add OG metadata
 | 
			
		||||
    const title = `${episode.title} - Voice RSS Summary`;
 | 
			
		||||
    const description = episode.description || `${episode.title}のエピソード詳細`;
 | 
			
		||||
    const description =
 | 
			
		||||
      episode.description || `${episode.title}のエピソード詳細`;
 | 
			
		||||
    const episodeUrl = `${baseUrl}/episode/${episodeId}`;
 | 
			
		||||
    const imageUrl = `${baseUrl}/default-thumbnail.svg`;
 | 
			
		||||
    const audioUrl = episode.audioPath.startsWith('/') 
 | 
			
		||||
    const audioUrl = episode.audioPath.startsWith("/")
 | 
			
		||||
      ? `${baseUrl}${episode.audioPath}`
 | 
			
		||||
      : `${baseUrl}/podcast_audio/${episode.audioPath}`;
 | 
			
		||||
 | 
			
		||||
    // Replace the title
 | 
			
		||||
    html = html.replace(
 | 
			
		||||
      /<title>.*?<\/title>/,
 | 
			
		||||
      `<title>${title}</title>`
 | 
			
		||||
    );
 | 
			
		||||
    html = html.replace(/<title>.*?<\/title>/, `<title>${title}</title>`);
 | 
			
		||||
 | 
			
		||||
    // Add OG metadata before closing </head>
 | 
			
		||||
    const ogMetadata = `
 | 
			
		||||
    <meta name="description" content="${description.replace(/"/g, '"')}" />
 | 
			
		||||
    <meta name="description" content="${description.replace(/"/g, """)}" />
 | 
			
		||||
    
 | 
			
		||||
    <!-- OpenGraph metadata -->
 | 
			
		||||
    <meta property="og:title" content="${episode.title.replace(/"/g, '"')}" />
 | 
			
		||||
    <meta property="og:description" content="${description.replace(/"/g, '"')}" />
 | 
			
		||||
    <meta property="og:title" content="${episode.title.replace(/"/g, """)}" />
 | 
			
		||||
    <meta property="og:description" content="${description.replace(/"/g, """)}" />
 | 
			
		||||
    <meta property="og:type" content="article" />
 | 
			
		||||
    <meta property="og:url" content="${episodeUrl}" />
 | 
			
		||||
    <meta property="og:site_name" content="Voice RSS Summary" />
 | 
			
		||||
@@ -303,25 +309,28 @@ async function serveEpisodePage(c: any, episodeId: string) {
 | 
			
		||||
    <meta property="og:image:type" content="image/svg+xml" />
 | 
			
		||||
    <meta property="og:image:width" content="1200" />
 | 
			
		||||
    <meta property="og:image:height" content="630" />
 | 
			
		||||
    <meta property="og:image:alt" content="${episode.title.replace(/"/g, '"')} - Voice RSS Summary" />
 | 
			
		||||
    <meta property="og:image:alt" content="${episode.title.replace(/"/g, """)} - Voice RSS Summary" />
 | 
			
		||||
    <meta property="og:audio" content="${audioUrl}" />
 | 
			
		||||
    <meta property="og:audio:type" content="audio/mpeg" />
 | 
			
		||||
    
 | 
			
		||||
    <!-- Twitter Card metadata -->
 | 
			
		||||
    <meta name="twitter:card" content="summary_large_image" />
 | 
			
		||||
    <meta name="twitter:title" content="${episode.title.replace(/"/g, '"')}" />
 | 
			
		||||
    <meta name="twitter:description" content="${description.replace(/"/g, '"')}" />
 | 
			
		||||
    <meta name="twitter:title" content="${episode.title.replace(/"/g, """)}" />
 | 
			
		||||
    <meta name="twitter:description" content="${description.replace(/"/g, """)}" />
 | 
			
		||||
    <meta name="twitter:image" content="${imageUrl}" />
 | 
			
		||||
    <meta name="twitter:image:alt" content="${episode.title.replace(/"/g, '"')} - Voice RSS Summary" />
 | 
			
		||||
    <meta name="twitter:image:alt" content="${episode.title.replace(/"/g, """)} - Voice RSS Summary" />
 | 
			
		||||
    
 | 
			
		||||
    <!-- Article metadata -->
 | 
			
		||||
    <meta property="article:published_time" content="${episode.createdAt}" />
 | 
			
		||||
    ${episode.articlePubDate && episode.articlePubDate !== episode.createdAt ? 
 | 
			
		||||
      `<meta property="article:modified_time" content="${episode.articlePubDate}" />` : ''}
 | 
			
		||||
    ${episode.feedTitle ? `<meta property="article:section" content="${episode.feedTitle.replace(/"/g, '"')}" />` : ''}
 | 
			
		||||
    ${
 | 
			
		||||
      episode.articlePubDate && episode.articlePubDate !== episode.createdAt
 | 
			
		||||
        ? `<meta property="article:modified_time" content="${episode.articlePubDate}" />`
 | 
			
		||||
        : ""
 | 
			
		||||
    }
 | 
			
		||||
    ${episode.feedTitle ? `<meta property="article:section" content="${episode.feedTitle.replace(/"/g, """)}" />` : ""}
 | 
			
		||||
  `;
 | 
			
		||||
 | 
			
		||||
    html = html.replace('</head>', `${ogMetadata}</head>`);
 | 
			
		||||
    html = html.replace("</head>", `${ogMetadata}</head>`);
 | 
			
		||||
 | 
			
		||||
    return c.body(html, 200, { "Content-Type": "text/html; charset=utf-8" });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
@@ -334,7 +343,6 @@ app.get("/", serveIndex);
 | 
			
		||||
 | 
			
		||||
app.get("/index.html", serveIndex);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// API endpoints for frontend
 | 
			
		||||
app.get("/api/episodes", async (c) => {
 | 
			
		||||
  try {
 | 
			
		||||
@@ -517,7 +525,9 @@ app.get("/api/feeds/by-category", async (c) => {
 | 
			
		||||
 | 
			
		||||
app.get("/api/feeds/grouped-by-category", async (c) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { getFeedsGroupedByCategory } = await import("./services/database.js");
 | 
			
		||||
    const { getFeedsGroupedByCategory } = await import(
 | 
			
		||||
      "./services/database.js"
 | 
			
		||||
    );
 | 
			
		||||
    const groupedFeeds = await getFeedsGroupedByCategory();
 | 
			
		||||
    return c.json({ groupedFeeds });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
@@ -558,6 +568,26 @@ app.get("/api/episodes-with-feed-info", async (c) => {
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.get("/api/episodes/search", async (c) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const query = c.req.query("q");
 | 
			
		||||
    const category = c.req.query("category");
 | 
			
		||||
 | 
			
		||||
    if (!query || query.trim() === "") {
 | 
			
		||||
      return c.json({ error: "Search query is required" }, 400);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { searchEpisodesWithFeedInfo } = await import(
 | 
			
		||||
      "./services/database.js"
 | 
			
		||||
    );
 | 
			
		||||
    const episodes = await searchEpisodesWithFeedInfo(query.trim(), category);
 | 
			
		||||
    return c.json({ episodes, query, category });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("Error searching episodes:", error);
 | 
			
		||||
    return c.json({ error: "Failed to search episodes" }, 500);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.get("/api/episode-with-source/:episodeId", async (c) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const episodeId = c.req.param("episodeId");
 | 
			
		||||
@@ -629,12 +659,17 @@ app.get("/api/episodes/by-category", async (c) => {
 | 
			
		||||
 | 
			
		||||
app.get("/api/episodes/grouped-by-category", async (c) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { getEpisodesGroupedByCategory } = await import("./services/database.js");
 | 
			
		||||
    const { getEpisodesGroupedByCategory } = await import(
 | 
			
		||||
      "./services/database.js"
 | 
			
		||||
    );
 | 
			
		||||
    const groupedEpisodes = await getEpisodesGroupedByCategory();
 | 
			
		||||
    return c.json({ groupedEpisodes });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("Error fetching episodes grouped by category:", error);
 | 
			
		||||
    return c.json({ error: "Failed to fetch episodes grouped by category" }, 500);
 | 
			
		||||
    return c.json(
 | 
			
		||||
      { error: "Failed to fetch episodes grouped by category" },
 | 
			
		||||
      500,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -652,14 +687,13 @@ app.get("/api/episode-category-stats", async (c) => {
 | 
			
		||||
// RSS endpoints information API
 | 
			
		||||
app.get("/api/rss-endpoints", async (c) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { 
 | 
			
		||||
      getAllEpisodeCategories, 
 | 
			
		||||
      fetchActiveFeeds 
 | 
			
		||||
    } = await import("./services/database.js");
 | 
			
		||||
    const { getAllEpisodeCategories, fetchActiveFeeds } = await import(
 | 
			
		||||
      "./services/database.js"
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const [episodeCategories, activeFeeds] = await Promise.all([
 | 
			
		||||
      getAllEpisodeCategories(),
 | 
			
		||||
      fetchActiveFeeds()
 | 
			
		||||
      fetchActiveFeeds(),
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    const protocol = c.req.header("x-forwarded-proto") || "http";
 | 
			
		||||
@@ -670,19 +704,19 @@ app.get("/api/rss-endpoints", async (c) => {
 | 
			
		||||
      main: {
 | 
			
		||||
        title: "全エピソード",
 | 
			
		||||
        url: `${baseUrl}/podcast.xml`,
 | 
			
		||||
        description: "すべてのエピソードを含むメインRSSフィード"
 | 
			
		||||
        description: "すべてのエピソードを含むメインRSSフィード",
 | 
			
		||||
      },
 | 
			
		||||
      categories: episodeCategories.map(category => ({
 | 
			
		||||
      categories: episodeCategories.map((category) => ({
 | 
			
		||||
        title: `カテゴリ: ${category}`,
 | 
			
		||||
        url: `${baseUrl}/podcast/category/${encodeURIComponent(category)}.xml`,
 | 
			
		||||
        description: `「${category}」カテゴリのエピソードのみ`
 | 
			
		||||
        description: `「${category}」カテゴリのエピソードのみ`,
 | 
			
		||||
      })),
 | 
			
		||||
      feeds: activeFeeds.map(feed => ({
 | 
			
		||||
      feeds: activeFeeds.map((feed) => ({
 | 
			
		||||
        title: `フィード: ${feed.title || feed.url}`,
 | 
			
		||||
        url: `${baseUrl}/podcast/feed/${feed.id}.xml`,
 | 
			
		||||
        description: `「${feed.title || feed.url}」からのエピソードのみ`,
 | 
			
		||||
        feedCategory: feed.category
 | 
			
		||||
      }))
 | 
			
		||||
        feedCategory: feed.category,
 | 
			
		||||
      })),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return c.json({ endpoints });
 | 
			
		||||
@@ -705,14 +739,14 @@ app.get("*", serveIndex);
 | 
			
		||||
console.log("🔄 Batch scheduler initialized and ready");
 | 
			
		||||
 | 
			
		||||
// Graceful shutdown
 | 
			
		||||
process.on('SIGINT', async () => {
 | 
			
		||||
  console.log('\n🛑 Received SIGINT. Graceful shutdown...');
 | 
			
		||||
process.on("SIGINT", async () => {
 | 
			
		||||
  console.log("\n🛑 Received SIGINT. Graceful shutdown...");
 | 
			
		||||
  await closeBrowser();
 | 
			
		||||
  process.exit(0);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
process.on('SIGTERM', async () => {
 | 
			
		||||
  console.log('\n🛑 Received SIGTERM. Graceful shutdown...');
 | 
			
		||||
process.on("SIGTERM", async () => {
 | 
			
		||||
  console.log("\n🛑 Received SIGTERM. Graceful shutdown...");
 | 
			
		||||
  await closeBrowser();
 | 
			
		||||
  process.exit(0);
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -103,9 +103,12 @@ class BatchScheduler {
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // Episode category migration
 | 
			
		||||
          const { migrateEpisodesWithCategories, getEpisodeCategoryMigrationStatus } =
 | 
			
		||||
            await import("./database.js");
 | 
			
		||||
          const episodeMigrationStatus = await getEpisodeCategoryMigrationStatus();
 | 
			
		||||
          const {
 | 
			
		||||
            migrateEpisodesWithCategories,
 | 
			
		||||
            getEpisodeCategoryMigrationStatus,
 | 
			
		||||
          } = await import("./database.js");
 | 
			
		||||
          const episodeMigrationStatus =
 | 
			
		||||
            await getEpisodeCategoryMigrationStatus();
 | 
			
		||||
 | 
			
		||||
          if (!episodeMigrationStatus.migrationComplete) {
 | 
			
		||||
            console.log("🔄 Running episode category migration...");
 | 
			
		||||
@@ -117,10 +120,7 @@ class BatchScheduler {
 | 
			
		||||
 | 
			
		||||
          this.migrationCompleted = true;
 | 
			
		||||
        } catch (migrationError) {
 | 
			
		||||
          console.error(
 | 
			
		||||
            "❌ Error during category migrations:",
 | 
			
		||||
            migrationError,
 | 
			
		||||
          );
 | 
			
		||||
          console.error("❌ Error during category migrations:", migrationError);
 | 
			
		||||
          // Don't fail the entire batch process due to migration error
 | 
			
		||||
          this.migrationCompleted = true; // Mark as completed to avoid retrying every batch
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -67,7 +67,7 @@ export async function extractArticleContent(
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Wait for potential dynamic content
 | 
			
		||||
    await new Promise(resolve => setTimeout(resolve, 2000));
 | 
			
		||||
    await new Promise((resolve) => setTimeout(resolve, 2000));
 | 
			
		||||
 | 
			
		||||
    // Extract content using page.evaluate
 | 
			
		||||
    const extractedData = await page.evaluate(() => {
 | 
			
		||||
@@ -112,7 +112,9 @@ export async function extractArticleContent(
 | 
			
		||||
        "";
 | 
			
		||||
 | 
			
		||||
      // Extract description
 | 
			
		||||
      const descriptionMeta = document.querySelector('meta[name="description"]');
 | 
			
		||||
      const descriptionMeta = document.querySelector(
 | 
			
		||||
        'meta[name="description"]',
 | 
			
		||||
      );
 | 
			
		||||
      const ogDescriptionMeta = document.querySelector(
 | 
			
		||||
        'meta[property="og:description"]',
 | 
			
		||||
      );
 | 
			
		||||
 
 | 
			
		||||
@@ -218,7 +218,9 @@ function initializeDatabase(): Database {
 | 
			
		||||
 | 
			
		||||
  // Ensure the category column exists in episodes
 | 
			
		||||
  const episodeInfos = db.prepare("PRAGMA table_info(episodes);").all();
 | 
			
		||||
  const hasEpisodeCategory = episodeInfos.some((col: any) => col.name === "category");
 | 
			
		||||
  const hasEpisodeCategory = episodeInfos.some(
 | 
			
		||||
    (col: any) => col.name === "category",
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (!hasEpisodeCategory) {
 | 
			
		||||
    db.exec("ALTER TABLE episodes ADD COLUMN category TEXT DEFAULT NULL;");
 | 
			
		||||
@@ -581,6 +583,88 @@ export async function fetchEpisodeWithSourceInfo(
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Search episodes with feed information
 | 
			
		||||
export async function searchEpisodesWithFeedInfo(
 | 
			
		||||
  query: string,
 | 
			
		||||
  category?: string,
 | 
			
		||||
): Promise<EpisodeWithFeedInfo[]> {
 | 
			
		||||
  try {
 | 
			
		||||
    let whereClause = `
 | 
			
		||||
      WHERE f.active = 1 
 | 
			
		||||
      AND (
 | 
			
		||||
        e.title LIKE ? 
 | 
			
		||||
        OR e.description LIKE ? 
 | 
			
		||||
        OR a.title LIKE ? 
 | 
			
		||||
        OR a.description LIKE ? 
 | 
			
		||||
        OR a.content LIKE ?
 | 
			
		||||
      )
 | 
			
		||||
    `;
 | 
			
		||||
 | 
			
		||||
    const searchPattern = `%${query}%`;
 | 
			
		||||
    const params = [
 | 
			
		||||
      searchPattern,
 | 
			
		||||
      searchPattern,
 | 
			
		||||
      searchPattern,
 | 
			
		||||
      searchPattern,
 | 
			
		||||
      searchPattern,
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    if (category) {
 | 
			
		||||
      whereClause += " AND f.category = ?";
 | 
			
		||||
      params.push(category);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const stmt = db.prepare(`
 | 
			
		||||
      SELECT 
 | 
			
		||||
        e.id,
 | 
			
		||||
        e.title,
 | 
			
		||||
        e.description,
 | 
			
		||||
        e.audio_path as audioPath,
 | 
			
		||||
        e.duration,
 | 
			
		||||
        e.file_size as fileSize,
 | 
			
		||||
        e.category,
 | 
			
		||||
        e.created_at as createdAt,
 | 
			
		||||
        e.article_id as articleId,
 | 
			
		||||
        a.title as articleTitle,
 | 
			
		||||
        a.link as articleLink,
 | 
			
		||||
        a.pub_date as articlePubDate,
 | 
			
		||||
        f.id as feedId,
 | 
			
		||||
        f.title as feedTitle,
 | 
			
		||||
        f.url as feedUrl,
 | 
			
		||||
        f.category as feedCategory
 | 
			
		||||
      FROM episodes e
 | 
			
		||||
      JOIN articles a ON e.article_id = a.id
 | 
			
		||||
      JOIN feeds f ON a.feed_id = f.id
 | 
			
		||||
      ${whereClause}
 | 
			
		||||
      ORDER BY e.created_at DESC
 | 
			
		||||
    `);
 | 
			
		||||
 | 
			
		||||
    const rows = stmt.all(...params) as any[];
 | 
			
		||||
 | 
			
		||||
    return rows.map((row) => ({
 | 
			
		||||
      id: row.id,
 | 
			
		||||
      title: row.title,
 | 
			
		||||
      description: row.description,
 | 
			
		||||
      audioPath: row.audioPath,
 | 
			
		||||
      duration: row.duration,
 | 
			
		||||
      fileSize: row.fileSize,
 | 
			
		||||
      category: row.category,
 | 
			
		||||
      createdAt: row.createdAt,
 | 
			
		||||
      articleId: row.articleId,
 | 
			
		||||
      articleTitle: row.articleTitle,
 | 
			
		||||
      articleLink: row.articleLink,
 | 
			
		||||
      articlePubDate: row.articlePubDate,
 | 
			
		||||
      feedId: row.feedId,
 | 
			
		||||
      feedTitle: row.feedTitle,
 | 
			
		||||
      feedUrl: row.feedUrl,
 | 
			
		||||
      feedCategory: row.feedCategory,
 | 
			
		||||
    }));
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("Error searching episodes with feed info:", error);
 | 
			
		||||
    throw error;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getAllFeedsIncludingInactive(): Promise<Feed[]> {
 | 
			
		||||
  try {
 | 
			
		||||
    const stmt = db.prepare("SELECT * FROM feeds ORDER BY created_at DESC");
 | 
			
		||||
@@ -1309,7 +1393,9 @@ export async function deleteEpisode(episodeId: string): Promise<boolean> {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Episode category management functions
 | 
			
		||||
export async function getEpisodesByCategory(category?: string): Promise<EpisodeWithFeedInfo[]> {
 | 
			
		||||
export async function getEpisodesByCategory(
 | 
			
		||||
  category?: string,
 | 
			
		||||
): Promise<EpisodeWithFeedInfo[]> {
 | 
			
		||||
  try {
 | 
			
		||||
    let stmt;
 | 
			
		||||
    let rows;
 | 
			
		||||
@@ -1458,7 +1544,10 @@ export async function getEpisodeCategoryStats(): Promise<{
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function updateEpisodeCategory(episodeId: string, category: string): Promise<boolean> {
 | 
			
		||||
export async function updateEpisodeCategory(
 | 
			
		||||
  episodeId: string,
 | 
			
		||||
  category: string,
 | 
			
		||||
): Promise<boolean> {
 | 
			
		||||
  try {
 | 
			
		||||
    const stmt = db.prepare("UPDATE episodes SET category = ? WHERE id = ?");
 | 
			
		||||
    const result = stmt.run(category, episodeId);
 | 
			
		||||
@@ -1519,10 +1608,7 @@ export async function migrateEpisodesWithCategories(): Promise<void> {
 | 
			
		||||
        // Add a small delay to avoid rate limiting
 | 
			
		||||
        await new Promise((resolve) => setTimeout(resolve, 1000));
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error(
 | 
			
		||||
          `❌ Failed to classify episode ${episode.title}:`,
 | 
			
		||||
          error,
 | 
			
		||||
        );
 | 
			
		||||
        console.error(`❌ Failed to classify episode ${episode.title}:`, error);
 | 
			
		||||
        errorCount++;
 | 
			
		||||
 | 
			
		||||
        // Set a default category for failed classifications
 | 
			
		||||
 
 | 
			
		||||
@@ -162,7 +162,8 @@ export async function openAI_ClassifyEpisode(
 | 
			
		||||
 | 
			
		||||
  if (content && content.trim()) {
 | 
			
		||||
    const maxContentLength = 1500;
 | 
			
		||||
    const truncatedContent = content.length > maxContentLength 
 | 
			
		||||
    const truncatedContent =
 | 
			
		||||
      content.length > maxContentLength
 | 
			
		||||
        ? content.substring(0, maxContentLength) + "..."
 | 
			
		||||
        : content;
 | 
			
		||||
    textForClassification += `\n内容: ${truncatedContent}`;
 | 
			
		||||
 
 | 
			
		||||
@@ -4,9 +4,9 @@ import path from "node:path";
 | 
			
		||||
import { dirname } from "path";
 | 
			
		||||
import { config } from "./config.js";
 | 
			
		||||
import {
 | 
			
		||||
  fetchEpisodesByFeedId,
 | 
			
		||||
  fetchEpisodesWithFeedInfo,
 | 
			
		||||
  getEpisodesByCategory,
 | 
			
		||||
  fetchEpisodesByFeedId 
 | 
			
		||||
} from "./database.js";
 | 
			
		||||
 | 
			
		||||
function escapeXml(text: string): string {
 | 
			
		||||
@@ -89,7 +89,7 @@ function generateRSSXml(
 | 
			
		||||
  episodes: any[],
 | 
			
		||||
  title: string,
 | 
			
		||||
  description: string,
 | 
			
		||||
  link?: string
 | 
			
		||||
  link?: string,
 | 
			
		||||
): string {
 | 
			
		||||
  const lastBuildDate = new Date().toUTCString();
 | 
			
		||||
  const itemsXml = episodes.map(createItemXml).join("\n");
 | 
			
		||||
@@ -123,7 +123,7 @@ export async function updatePodcastRSS(): Promise<void> {
 | 
			
		||||
    const rssXml = generateRSSXml(
 | 
			
		||||
      validEpisodes,
 | 
			
		||||
      config.podcast.title,
 | 
			
		||||
      config.podcast.description
 | 
			
		||||
      config.podcast.description,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Ensure directory exists
 | 
			
		||||
@@ -162,8 +162,14 @@ export async function generateCategoryRSS(category: string): Promise<string> {
 | 
			
		||||
export async function saveCategoryRSS(category: string): Promise<void> {
 | 
			
		||||
  try {
 | 
			
		||||
    const rssXml = await generateCategoryRSS(category);
 | 
			
		||||
    const safeCategory = category.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/g, "_");
 | 
			
		||||
    const outputPath = path.join(config.paths.publicDir, `podcast_category_${safeCategory}.xml`);
 | 
			
		||||
    const safeCategory = category.replace(
 | 
			
		||||
      /[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/g,
 | 
			
		||||
      "_",
 | 
			
		||||
    );
 | 
			
		||||
    const outputPath = path.join(
 | 
			
		||||
      config.paths.publicDir,
 | 
			
		||||
      `podcast_category_${safeCategory}.xml`,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Ensure directory exists
 | 
			
		||||
    await fs.mkdir(dirname(outputPath), { recursive: true });
 | 
			
		||||
@@ -187,7 +193,8 @@ export async function generateFeedRSS(feedId: string): Promise<string> {
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Use feed info for RSS metadata if available
 | 
			
		||||
    const feedTitle = validEpisodes.length > 0 ? validEpisodes[0].feedTitle : "Unknown Feed";
 | 
			
		||||
    const feedTitle =
 | 
			
		||||
      validEpisodes.length > 0 ? validEpisodes[0].feedTitle : "Unknown Feed";
 | 
			
		||||
    const title = `${config.podcast.title} - ${feedTitle}`;
 | 
			
		||||
    const description = `${config.podcast.description} フィード: ${feedTitle}`;
 | 
			
		||||
 | 
			
		||||
@@ -201,7 +208,10 @@ export async function generateFeedRSS(feedId: string): Promise<string> {
 | 
			
		||||
export async function saveFeedRSS(feedId: string): Promise<void> {
 | 
			
		||||
  try {
 | 
			
		||||
    const rssXml = await generateFeedRSS(feedId);
 | 
			
		||||
    const outputPath = path.join(config.paths.publicDir, `podcast_feed_${feedId}.xml`);
 | 
			
		||||
    const outputPath = path.join(
 | 
			
		||||
      config.paths.publicDir,
 | 
			
		||||
      `podcast_feed_${feedId}.xml`,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Ensure directory exists
 | 
			
		||||
    await fs.mkdir(dirname(outputPath), { recursive: true });
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user