diff --git a/.github/workflows/fetch_news.yml b/.github/workflows/fetch_news.yml new file mode 100644 index 000000000..7df780d13 --- /dev/null +++ b/.github/workflows/fetch_news.yml @@ -0,0 +1,39 @@ +name: Fetch News + +on: + schedule: + # 毎朝 9:00 JST + - cron: '0 0 * * *' + workflow_dispatch: + +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..0dd08cbc2 --- /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..415d5df25 --- /dev/null +++ b/lib/tasks/fetch_news.rake @@ -0,0 +1,126 @@ +require 'rss' +require 'net/http' +require 'uri' +require 'yaml' +require 'time' +require 'active_support/broadcast_logger' + +def safe_open(url) + uri = URI.parse(url) + raise "不正なURLです: #{url}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + + 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..bebe76611 --- /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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy