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 @@ + + + + Sample News Feed + https://coderdojo.jp/ + テスト用のサンプルニュースフィード + + + テスト記事① + https://example.com/articles/1 + Mon, 01 Jun 2025 10:00:00 +0900 + サンプル記事の本文① + + + + テスト記事② + https://example.com/articles/2 + Tue, 02 Jun 2025 11:30:00 +0900 + サンプル記事の本文② + + + + 🎉 テスト記事③ + https://example.com/articles/3 + Wed, 03 Jun 2025 12:00:00 +0900 + 絵文字ありのサンプル記事の本文③ + + + + diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb new file mode 100644 index 000000000..182d901b2 --- /dev/null +++ b/spec/helpers/application_helper_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe ApplicationHelper, type: :helper do + describe '#format_news_title' do + it '先頭文字が絵文字ならそのまま、そうでなければ 📰 を付与する' do + { + '🔔 新着' => '🔔 新着', + '更新情報' => '📰 更新情報', + '1つ目のお知らせ' => '📰 1つ目のお知らせ' + }.each do |input, expected| + news = double('news', title: input) + expect(helper.format_news_title(news)).to eq expected + end + end + end +end diff --git a/spec/models/news_spec.rb b/spec/models/news_spec.rb new file mode 100644 index 000000000..0063aaf42 --- /dev/null +++ b/spec/models/news_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe News, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end