diff --git a/.github/workflows/fetch_news.yml b/.github/workflows/fetch_news.yml
new file mode 100644
index 000000000..1399b6375
--- /dev/null
+++ b/.github/workflows/fetch_news.yml
@@ -0,0 +1,42 @@
+name: Fetch News
+
+on:
+ schedule:
+ # 毎朝 9:00 JST
+ - cron: '0 0 * * *'
+ workflow_dispatch:
+ pull_request:
+ branches:
+ - "*"
+
+jobs:
+ fetch:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: .ruby-version
+ bundler-cache: true
+
+ - name: Install dependencies
+ run: bundle install --jobs 4 --retry 3
+
+ - name: Run news:fetch task
+ run: bin/rails news:fetch
+
+ - name: Commit updated news.yml
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add db/news.yml
+ if ! git diff --cached --quiet; then
+ git commit -m "chore: update news.yml via GitHub Actions"
+ git push
+ else
+ echo "No changes in db/news.yml"
+ fi
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index e9d05b21c..0604d289a 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -3,5 +3,6 @@ def show
@dojo_count = Dojo.active_dojos_count
@regions_and_dojos = Dojo.group_by_region_on_active
@prefectures_and_dojos = Dojo.group_by_prefecture_on_active
+ @news_items = News.recent.limit(7)
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 83bad2c32..da3d2f83f 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -203,4 +203,8 @@ def translate_dojo_tag(tag_name)
tag_translations[tag_name] || tag_name
end
+ def format_news_title(news)
+ has_emoji = news.title[0]&.match?(/[\p{Emoji}&&[^0-9#*]]/)
+ has_emoji ? news.title : "📰 #{news.title}"
+ end
end
diff --git a/app/models/news.rb b/app/models/news.rb
new file mode 100644
index 000000000..9a303f12a
--- /dev/null
+++ b/app/models/news.rb
@@ -0,0 +1,9 @@
+class News < ApplicationRecord
+ scope :recent, -> { order(published_at: :desc) }
+
+ validates :title, presence: true
+ validates :url, presence: true,
+ uniqueness: true,
+ format: { with: /\Ahttps?:\/\/.*\z/i }
+ validates :published_at, presence: true
+end
diff --git a/app/views/home/show.html.erb b/app/views/home/show.html.erb
index 79910fe7d..2e59646df 100644
--- a/app/views/home/show.html.erb
+++ b/app/views/home/show.html.erb
@@ -177,27 +177,11 @@
最新情報はメールで受け取れます。
diff --git a/db/migrate/20250630040611_create_news.rb b/db/migrate/20250630040611_create_news.rb
new file mode 100644
index 000000000..04273478c
--- /dev/null
+++ b/db/migrate/20250630040611_create_news.rb
@@ -0,0 +1,13 @@
+class CreateNews < ActiveRecord::Migration[8.0]
+ def change
+ create_table :news do |t|
+ t.string :title
+ t.string :url
+ t.datetime :published_at
+
+ t.timestamps
+ end
+
+ add_index :news, :url, unique: true
+ end
+end
diff --git a/db/news.yml b/db/news.yml
new file mode 100644
index 000000000..c82230958
--- /dev/null
+++ b/db/news.yml
@@ -0,0 +1,42 @@
+---
+news:
+- id: 10
+ url: https://news.coderdojo.jp/2025/07/14/233-laptops-to-coderdojo/
+ title: 米国系 IT 企業から CoderDojo へ、233 台のノート PC 寄贈
+ published_at: Mon, 14 Jul 2025 05:50:31 +0000
+- id: 9
+ url: https://news.coderdojo.jp/2025/07/10/dojoletter-vol-86-2025%e5%b9%b405%e6%9c%88%e5%8f%b7/
+ title: DojoLetter Vol.86 2025年05月号
+ published_at: Thu, 10 Jul 2025 04:00:07 +0000
+- id: 8
+ url: https://news.coderdojo.jp/2025/06/10/dojoletter-vol-85-2025%e5%b9%b404%e6%9c%88%e5%8f%b7/
+ title: DojoLetter Vol.85 2025年04月号
+ published_at: Tue, 10 Jun 2025 03:30:18 +0000
+- id: 7
+ url: https://news.coderdojo.jp/2025/05/12/dojoletter-vol-84-2025%e5%b9%b403%e6%9c%88%e5%8f%b7/
+ title: DojoLetter Vol.84 2025年03月号
+ published_at: Mon, 12 May 2025 04:00:33 +0000
+- id: 6
+ url: https://news.coderdojo.jp/2025/04/10/dojoletter-vol-83-2025%e5%b9%b402%e6%9c%88%e5%8f%b7/
+ title: DojoLetter Vol.83 2025年02月号
+ published_at: Thu, 10 Apr 2025 03:45:27 +0000
+- id: 5
+ url: https://news.coderdojo.jp/2025/04/04/55-laptops-to-coderdojo/
+ title: 米国系 IT 企業から CoderDojo へ、55 台のノート PC 寄贈
+ published_at: Fri, 04 Apr 2025 10:00:32 +0000
+- id: 4
+ url: https://news.coderdojo.jp/2025/03/10/dojoletter-vol-82-2025%e5%b9%b401%e6%9c%88%e5%8f%b7/
+ title: DojoLetter Vol.82 2025年01月号
+ published_at: Mon, 10 Mar 2025 04:00:33 +0000
+- id: 3
+ url: https://news.coderdojo.jp/2025/02/14/coderdojo-de-nyaicecode/
+ title: "\U0001F3B2 ダイス×プログラミング『ニャイス!コード』を、CoderDojo に75台寄贈"
+ published_at: Fri, 14 Feb 2025 08:24:07 +0000
+- id: 2
+ url: https://news.coderdojo.jp/2025/02/10/dojoletter-vol-80-2024%e5%b9%b412%e6%9c%88%e5%8f%b7/
+ title: DojoLetter Vol.80 2024年12月号
+ published_at: Mon, 10 Feb 2025 04:00:55 +0000
+- id: 1
+ url: https://news.coderdojo.jp/2025/01/14/dojoletter-vol-79-2024%e5%b9%b411%e6%9c%88%e5%8f%b7/
+ title: DojoLetter Vol.79 2024年11月号
+ published_at: Tue, 14 Jan 2025 03:30:45 +0000
diff --git a/db/schema.rb b/db/schema.rb
index 00d9982f0..af544a199 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,10 +10,10 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2025_05_20_091834) do
+ActiveRecord::Schema[8.0].define(version: 2025_06_30_040611) do
# These are extensions that must be enabled in order to support this database
+ enable_extension "pg_catalog.plpgsql"
enable_extension "pg_stat_statements"
- enable_extension "plpgsql"
create_table "dojo_event_services", id: :serial, force: :cascade do |t|
t.integer "dojo_id", null: false
@@ -58,6 +58,15 @@
t.index ["service_name", "event_id"], name: "index_event_histories_on_service_name_and_event_id", unique: true
end
+ create_table "news", force: :cascade do |t|
+ t.string "title"
+ t.string "url"
+ t.datetime "published_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["url"], name: "index_news_on_url", unique: true
+ end
+
create_table "podcasts", force: :cascade do |t|
t.string "enclosure_url", null: false
t.string "title", null: false
diff --git a/lib/tasks/fetch_news.rake b/lib/tasks/fetch_news.rake
new file mode 100644
index 000000000..932c3f73f
--- /dev/null
+++ b/lib/tasks/fetch_news.rake
@@ -0,0 +1,128 @@
+require 'rss'
+require 'net/http'
+require 'uri'
+require 'yaml'
+require 'time'
+require 'active_support/broadcast_logger'
+
+def safe_open(url)
+ uri = URI.parse(url)
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
+ raise "不正なURLです: #{url}"
+ end
+
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
+ request = Net::HTTP::Get.new(uri)
+ response = http.request(request)
+ response.body
+ end
+end
+
+namespace :news do
+ desc 'RSS フィードから最新ニュースを取得し、db/news.yml に書き出す'
+ task fetch: :environment do
+ # ロガー設定(ファイル+コンソール出力)
+ file_logger = ActiveSupport::Logger.new('log/news.log')
+ console = ActiveSupport::Logger.new(STDOUT)
+ logger = ActiveSupport::BroadcastLogger.new(file_logger, console)
+
+ logger.info('==== START news:fetch ====')
+
+ # 既存の news.yml を読み込み
+ yaml_path = Rails.root.join('db', 'news.yml')
+ existing_news = if File.exist?(yaml_path)
+ YAML.safe_load(File.read(yaml_path), permitted_classes: [Time], aliases: true)['news'] || []
+ else
+ []
+ end
+
+ # テスト/ステージング環境ではサンプルファイル、本番は実サイトのフィード
+ feed_urls = if Rails.env.test? || Rails.env.staging?
+ [Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s]
+ else
+ [
+ 'https://news.coderdojo.jp/feed/',
+ # 必要に応じて他 Dojo の RSS もここに追加可能
+ # 'https://coderdojotokyo.org/feed',
+ ]
+ end
+
+ # RSS 取得&パース
+ new_items = feed_urls.flat_map do |url|
+ logger.info("Fetching RSS → #{url}")
+ begin
+ rss = safe_open(url)
+ feed = RSS::Parser.parse(rss, false)
+ feed.items.map do |item|
+ {
+ 'url' => item.link,
+ 'title' => item.title,
+ 'published_at' => item.pubDate.to_s
+ }
+ end
+ rescue => e
+ logger.warn("⚠️ Failed to fetch #{url}: #{e.message}")
+ []
+ end
+ end
+
+ # 既存データをハッシュに変換(URL をキーに)
+ existing_items_hash = existing_news.index_by { |item| item['url'] }
+
+ # 新しいアイテムと既存アイテムを分離
+ truly_new_items = []
+ updated_items = []
+
+ new_items.each do |new_item|
+ if existing_items_hash.key?(new_item['url'])
+ # 既存アイテムの更新
+ existing_item = existing_items_hash[new_item['url']]
+ updated_item = existing_item.merge(new_item) # 新しい情報で更新
+ updated_items << updated_item
+ else
+ # 完全に新しいアイテム
+ truly_new_items << new_item
+ end
+ end
+
+ # 既存の最大IDを取得
+ max_existing_id = existing_news.map { |item| item['id'].to_i }.max || 0
+
+ # 新しいアイテムのみに ID を割り当て(古い順)
+ truly_new_items_sorted = truly_new_items.sort_by { |item|
+ Time.parse(item['published_at'])
+ }
+
+ truly_new_items_sorted.each_with_index do |item, index|
+ item['id'] = max_existing_id + index + 1
+ end
+
+ # 更新されなかった既存アイテムを取得
+ updated_urls = updated_items.map { |item| item['url'] }
+ unchanged_items = existing_news.reject { |item| updated_urls.include?(item['url']) }
+
+ # 全アイテムをマージ
+ all_items = unchanged_items + updated_items + truly_new_items_sorted
+
+ # 日付降順ソート
+ sorted_items = all_items.sort_by { |item|
+ Time.parse(item['published_at'])
+ }.reverse
+
+ File.open('db/news.yml', 'w') do |f|
+ formatted_items = sorted_items.map do |item|
+ {
+ 'id' => item['id'],
+ 'url' => item['url'],
+ 'title' => item['title'],
+ 'published_at' => item['published_at']
+ }
+ end
+
+ f.write({ 'news' => formatted_items }.to_yaml)
+end
+
+ logger.info("✅ Wrote #{sorted_items.size} items to db/news.yml (#{truly_new_items_sorted.size} new, #{updated_items.size} updated)")
+ logger.info('==== END news:fetch ====')
+ end
+end
diff --git a/lib/tasks/import_news.rake b/lib/tasks/import_news.rake
new file mode 100644
index 000000000..3a0351e40
--- /dev/null
+++ b/lib/tasks/import_news.rake
@@ -0,0 +1,24 @@
+require 'yaml'
+
+namespace :news do
+ desc "db/news.yml を読み込んで News テーブルを upsert する"
+ task import_from_yaml: :environment do
+ yaml_path = Rails.root.join('db', 'news.yml')
+ raw = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time], aliases: true)
+
+ # entries を計算
+ entries = raw['news'] || []
+
+ entries.each do |attrs|
+ news = News.find_or_initialize_by(url: attrs['url'])
+ news.assign_attributes(
+ title: attrs['title'],
+ published_at: attrs['published_at']
+ )
+ news.save!
+ puts "[news] #{news.published_at.to_date} #{news.title}"
+ end
+
+ puts "Imported #{entries.size} items."
+ end
+end
diff --git a/script/release.sh b/script/release.sh
index bea511eb8..9c303d2d7 100755
--- a/script/release.sh
+++ b/script/release.sh
@@ -3,5 +3,6 @@ set -e
bundle exec rails db:migrate
bundle exec rails db:seed
bundle exec rails dojos:update_db_by_yaml
+bundle exec rails news:import_from_yaml
bundle exec rails dojo_event_services:upsert
bundle exec rails podcasts:upsert
diff --git a/spec/factories/news.rb b/spec/factories/news.rb
new file mode 100644
index 000000000..26b683ba7
--- /dev/null
+++ b/spec/factories/news.rb
@@ -0,0 +1,7 @@
+FactoryBot.define do
+ factory :news do
+ sequence(:title) { |n| "Test News Article #{n}" }
+ sequence(:url) { |n| "https://news.coderdojo.jp/#{n}" }
+ published_at { 1.day.ago }
+ end
+end
diff --git a/spec/features/news_spec.rb b/spec/features/news_spec.rb
index dde44640d..5f3155686 100644
--- a/spec/features/news_spec.rb
+++ b/spec/features/news_spec.rb
@@ -1,11 +1,16 @@
# -*- coding: utf-8 -*-
require 'rails_helper'
-RSpec.feature "News", type: :feature do
- describe "GET /news/2016/12/12/new-backend" do
- scenario "Title should be formatted" do
- visit "/docs/post-backend-update-history"
- expect(page).to have_title "CoderDojo Japan のバックエンド刷新"
+RSpec.feature "NewsSection", type: :feature do
+ let!(:news_item) { create(:news) }
+
+ scenario "ニュースセクションにニュース項目が表示される" do
+ visit root_path(anchor: 'news')
+
+ within 'section#news' do
+ expect(page).to have_link(href: news_item.url)
+ expect(page).to have_content(news_item.title)
+ expect(page).to have_selector("a[target='_blank']")
end
end
end
diff --git a/spec/fixtures/sample_news.rss b/spec/fixtures/sample_news.rss
new file mode 100644
index 000000000..fcc9c5be5
--- /dev/null
+++ b/spec/fixtures/sample_news.rss
@@ -0,0 +1,30 @@
+
+