├── log └── .keep ├── storage └── .keep ├── tmp ├── .keep └── pids │ └── .keep ├── vendor └── .keep ├── lib ├── assets │ └── .keep ├── tasks │ ├── .keep │ └── sale_report.rake ├── crawler │ ├── crawler.rb │ ├── dmm_crawler.rb │ ├── rakuten_crawler.rb │ ├── amazon_crawler.rb │ └── seshop_crawler.rb ├── mail_sender.rb └── comparer.rb ├── .ruby-version ├── app ├── assets │ ├── builds │ │ └── .keep │ ├── images │ │ └── .keep │ ├── config │ │ └── manifest.js │ └── stylesheets │ │ ├── styles │ │ ├── _form.scss │ │ ├── _fixed-footer.scss │ │ ├── _material-icons.scss │ │ ├── _header.scss │ │ ├── _footer.scss │ │ ├── _welcome.scss │ │ ├── _index.scss │ │ ├── _common.scss │ │ ├── _button.scss │ │ └── _searched_books.scss │ │ └── application.bulma.scss ├── models │ ├── concerns │ │ └── .keep │ ├── application_record.rb │ ├── list.rb │ ├── list_detail.rb │ ├── book.rb │ ├── rakuten_books_searcher.rb │ └── user.rb ├── controllers │ ├── concerns │ │ └── .keep │ ├── api │ │ ├── base_controller.rb │ │ ├── list_details │ │ │ └── id_controller.rb │ │ ├── books_controller.rb │ │ ├── users │ │ │ └── lists_controller.rb │ │ └── list_details_controller.rb │ ├── welcome_controller.rb │ ├── home_controller.rb │ ├── application_controller.rb │ ├── users │ │ ├── lists_controller.rb │ │ ├── registrations_controller.rb │ │ └── omniauth_callbacks_controller.rb │ └── books_controller.rb ├── views │ ├── layouts │ │ ├── mailer.text.slim │ │ ├── mailer.html.slim │ │ └── application.html.slim │ ├── devise │ │ ├── mailer │ │ │ ├── password_change.html.slim │ │ │ ├── confirmation_instructions.html.slim │ │ │ ├── unlock_instructions.html.slim │ │ │ ├── email_changed.html.slim │ │ │ └── reset_password_instructions.html.slim │ │ ├── shared │ │ │ ├── _error_messages.html.slim │ │ │ └── _links.html.slim │ │ ├── passwords │ │ │ ├── new.html.slim │ │ │ └── edit.html.slim │ │ ├── confirmations │ │ │ └── new.html.slim │ │ ├── registrations │ │ │ ├── new.html.slim │ │ │ └── edit.html.slim │ │ └── sessions │ │ │ └── new.html.slim │ ├── home │ │ └── index.html.slim │ ├── users │ │ └── lists │ │ │ └── show.html.slim │ ├── application │ │ ├── _google_analytics.html.slim │ │ └── _footer.html.slim │ ├── books │ │ ├── shared │ │ │ └── _search_form.html.slim │ │ └── index.html.slim │ ├── api │ │ └── users │ │ │ └── lists │ │ │ └── show.json.jbuilder │ ├── welcome │ │ ├── index.html.slim │ │ ├── privacy_policy.html.slim │ │ └── tos.html.slim │ └── sale_mailer │ │ ├── sale_email.text.slim │ │ └── sale_email.html.slim ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── mailers │ ├── application_mailer.rb │ └── sale_mailer.rb ├── helpers │ ├── application_helper.rb │ ├── books_helper.rb │ └── meta_tags_helper.rb ├── javascript │ ├── channels │ │ ├── index.js │ │ └── consumer.js │ ├── users_lists_books.js │ ├── searched_books.vue │ ├── searched_books.js │ ├── users_lists_books.vue │ └── users_lists_book.vue └── jobs │ └── application_job.rb ├── .browserslistrc ├── public ├── apple-touch-icon.png ├── apple-touch-icon-precomposed.png ├── logo.png ├── .DS_Store ├── g-logo.png ├── favicon.ico ├── ogp │ └── ogp.png ├── only_logo.png ├── icons │ ├── github.png │ └── twitter.png ├── robots.txt ├── 500.html ├── 422.html └── 404.html ├── .prettierrc ├── config ├── app │ └── assets │ │ └── builds │ │ └── application.js ├── webpack │ ├── loaders │ │ └── vue.js │ └── webpack.config.js ├── spring.rb ├── environment.rb ├── initializers │ ├── mime_types.rb │ ├── application_controller_renderer.rb │ ├── cookies_serializer.rb │ ├── filter_parameter_logging.rb │ ├── aws.rb │ ├── permissions_policy.rb │ ├── wrap_parameters.rb │ ├── backtrace_silencers.rb │ ├── assets.rb │ ├── inflections.rb │ ├── content_security_policy.rb │ └── meta_tags.rb ├── cable.yml ├── boot.rb ├── credentials.yml.enc ├── locales │ ├── en.yml │ ├── devise.en.yml │ └── ja.yml ├── application.rb ├── routes.rb ├── storage.yml ├── puma.rb ├── environments │ ├── test.rb │ └── development.rb └── database.yml ├── .rspec ├── .slim-lint.yml ├── .DS_Store ├── Procfile.dev ├── bin ├── lint ├── rake ├── dev ├── rails ├── spring ├── yarn ├── setup └── bundle ├── .github ├── dependabot.yml └── workflows │ ├── opened-issues-triage.yml │ ├── test.yml │ └── lint.yml ├── entry_point.sh ├── spec ├── factories │ ├── lists.rb │ ├── list_details.rb │ ├── users.rb │ └── books.rb ├── support │ ├── driver_setting.rb │ ├── login_module.rb │ ├── google_oauth_mock_helper.rb │ └── request_spec_helper.rb ├── requests │ ├── welcome_spec.rb │ ├── google_oauth_spec.rb │ └── api │ │ ├── list_details │ │ └── id_spec.rb │ │ ├── books_spec.rb │ │ ├── users │ │ └── lists_spec.rb │ │ └── list_details_spec.rb ├── system │ ├── dmm_crawler_spec.rb │ ├── rakuten_crawler_spec.rb │ ├── amazon_crawler_spec.rb │ ├── welcomes_spec.rb │ ├── home_spec.rb │ ├── list_details_spec.rb │ ├── seshop_crawler_spec.rb │ ├── users_lists_spec.rb │ ├── application_spec.rb │ └── books_spec.rb ├── mailers │ └── sale_mailer_spec.rb ├── helpers │ └── books_helper_spec.rb ├── models │ ├── list_spec.rb │ ├── list_detail_spec.rb │ ├── rakuten_books_searcher_spec.rb │ ├── user_spec.rb │ └── book_spec.rb ├── lib │ └── mail_sender_spec.rb └── rails_helper.rb ├── db ├── migrate │ ├── 20210816054104_add_default_name_to_name_column.rb │ ├── 20210910075410_add_index_uid_and_provider_to_users.rb │ ├── 20211103043039_rename_isbn_13_column_to_isbn13.rb │ ├── 20210816011750_create_books.rb │ ├── 20210909064118_add_omniauth_to_users.rb │ ├── 20211002092405_add_discount_rating_to_users.rb │ ├── 20210816005509_create_lists.rb │ ├── 20210816012317_create_list_details.rb │ ├── 20210818034413_add_title_and_author_and_image_and_url_and_sales_date_to_books.rb │ └── 20210808071330_devise_create_users.rb ├── seeds.rb ├── fixtures │ ├── lists.yml │ ├── list_details.yml │ ├── books.yml │ └── users.yml └── schema.rb ├── config.ru ├── Rakefile ├── postcss.config.js ├── .gitattributes ├── .rubocop_todo.yml ├── docker-compose.yml ├── .eslintrc ├── scripts └── one_shot │ └── destroy_books_not_exists_in_list_details.rb ├── Dockerfile ├── .gitignore ├── .rubocop.yml ├── README.md ├── package.json ├── babel.config.js └── Gemfile /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/pids/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.2 2 | -------------------------------------------------------------------------------- /app/assets/builds/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | "prettier-config-standard" 2 | -------------------------------------------------------------------------------- /config/app/assets/builds/application.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.slim: -------------------------------------------------------------------------------- 1 | = yield 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --format documentation 3 | -------------------------------------------------------------------------------- /.slim-lint.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | LineLength: 3 | max: 150 4 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fugakkbn/serurepo/HEAD/.DS_Store -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fugakkbn/serurepo/HEAD/public/logo.png -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_tree ../builds 3 | -------------------------------------------------------------------------------- /public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fugakkbn/serurepo/HEAD/public/.DS_Store -------------------------------------------------------------------------------- /public/g-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fugakkbn/serurepo/HEAD/public/g-logo.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fugakkbn/serurepo/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/ogp/ogp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fugakkbn/serurepo/HEAD/public/ogp/ogp.png -------------------------------------------------------------------------------- /public/only_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fugakkbn/serurepo/HEAD/public/only_logo.png -------------------------------------------------------------------------------- /public/icons/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fugakkbn/serurepo/HEAD/public/icons/github.png -------------------------------------------------------------------------------- /public/icons/twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fugakkbn/serurepo/HEAD/public/icons/twitter.png -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bin/rails server -p 3000 -b '0.0.0.0' 2 | js: yarn build --watch 3 | css: yarn build:css --watch 4 | -------------------------------------------------------------------------------- /bin/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | bundle exec rubocop 4 | bundle exec slim-lint app/views 5 | yarn eslint 6 | yarn prettier 7 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /app/assets/stylesheets/styles/_form.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | .columns:not(:last-child) { 4 | margin-bottom: 0; 5 | } 6 | -------------------------------------------------------------------------------- /app/views/devise/mailer/password_change.html.slim: -------------------------------------------------------------------------------- 1 | p 2 | = t('.greeting', recipient: @resource.email) 3 | p 4 | = t('.message') 5 | -------------------------------------------------------------------------------- /app/controllers/api/base_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class API::BaseController < ApplicationController 4 | end 5 | -------------------------------------------------------------------------------- /config/webpack/loaders/vue.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | test: /\.vue(\.erb)?$/, 3 | use: [{ 4 | loader: 'vue-loader' 5 | }] 6 | } 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'bundler' 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /entry_point.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Remove a potentially pre-existing server.pid for Rails. 5 | rm -f ./tmp/pids/server.pid 6 | 7 | bin/dev 8 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Channel < ActionCable::Channel::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Spring.watch( 4 | '.ruby-version', 5 | '.rbenv-vars', 6 | 'tmp/restart.txt', 7 | 'tmp/caching-dev.txt' 8 | ) 9 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Connection < ActionCable::Connection::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationMailer < ActionMailer::Base 4 | default from: 'from@example.com' 5 | layout 'mailer' 6 | end 7 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | load File.expand_path('spring', __dir__) 5 | require_relative '../config/boot' 6 | require 'rake' 7 | Rake.application.run 8 | -------------------------------------------------------------------------------- /spec/factories/lists.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :list, class: 'List' do 5 | user factory: :alice 6 | name { '通知リスト' } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if ! foreman version &> /dev/null 4 | then 5 | echo "Installing foreman..." 6 | gem install foreman 7 | fi 8 | 9 | foreman start -f Procfile.dev "$@" 10 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /app/views/home/index.html.slim: -------------------------------------------------------------------------------- 1 | - provide(:title, '書籍検索') 2 | section.container.is-max-desktop.py-4 3 | h1.title.is-4.has-text-centered 4 | = yield(:title) 5 | 6 | = render 'books/shared/search_form' 7 | -------------------------------------------------------------------------------- /spec/factories/list_details.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :list_detail_one, class: 'ListDetail' do 5 | list 6 | book factory: :cherry 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/driver_setting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.before(:each, type: :system) do 5 | driven_by(:selenium_chrome_headless) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20210816054104_add_default_name_to_name_column.rb: -------------------------------------------------------------------------------- 1 | class AddDefaultNameToNameColumn < ActiveRecord::Migration[6.1] 2 | def change 3 | change_column_default(:lists, :name, '通知リスト') 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20210910075410_add_index_uid_and_provider_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddIndexUidAndProviderToUsers < ActiveRecord::Migration[6.1] 2 | def change 3 | add_index :users, %i[uid provider], unique: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record/fixtures' 4 | 5 | ActiveRecord::FixtureSet.create_fixtures 'db/fixtures', %i[ 6 | users 7 | books 8 | lists 9 | list_details 10 | ] 11 | -------------------------------------------------------------------------------- /app/assets/stylesheets/styles/_fixed-footer.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | body { 4 | display: flex; 5 | flex-direction: column; 6 | min-height: 100vh; 7 | } 8 | 9 | .footer { 10 | margin-top: auto; 11 | } 12 | -------------------------------------------------------------------------------- /app/views/devise/mailer/confirmation_instructions.html.slim: -------------------------------------------------------------------------------- 1 | p 2 | = t('.greeting', recipient: @email) 3 | p 4 | = t('.instruction') 5 | p 6 | = link_to t('.action'), confirmation_url(@resource, confirmation_token: @token) 7 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative 'config/environment' 6 | 7 | run Rails.application 8 | Rails.application.load_server 9 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Add new mime types for use in respond_to blocks: 5 | # Mime::Type.register "text/richtext", :rtf 6 | -------------------------------------------------------------------------------- /db/fixtures/lists.yml: -------------------------------------------------------------------------------- 1 | list1: 2 | user: rating_even 3 | 4 | list2: 5 | user: rating_over10 6 | 7 | list3: 8 | user: rating_over20 9 | 10 | list4: 11 | user: rating_over30 12 | 13 | list5: 14 | user: rating_over50 15 | -------------------------------------------------------------------------------- /app/assets/stylesheets/styles/_material-icons.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | .material-icons { 4 | &.md-18 { font-size: 18px; } 5 | &.md-24 { font-size: 24px; } 6 | &.md-36 { font-size: 36px; } 7 | &.md-48 { font-size: 48px; } 8 | } 9 | -------------------------------------------------------------------------------- /app/controllers/welcome_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class WelcomeController < ApplicationController 4 | skip_before_action :authenticate_user! 5 | 6 | def tos; end 7 | 8 | def privacy_policy; end 9 | end 10 | -------------------------------------------------------------------------------- /app/views/devise/mailer/unlock_instructions.html.slim: -------------------------------------------------------------------------------- 1 | p 2 | = t('.greeting', recipient: @resource.email) 3 | p 4 | = t('.message') 5 | p 6 | = t('.instruction') 7 | p 8 | = link_to t('.action'), unlock_url(@resource, unlock_token: @token) 9 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | load File.expand_path('spring', __dir__) 5 | APP_PATH = File.expand_path('../config/application', __dir__) 6 | require_relative '../config/boot' 7 | require 'rails/commands' 8 | -------------------------------------------------------------------------------- /db/migrate/20211103043039_rename_isbn_13_column_to_isbn13.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameIsbn13ColumnToIsbn13 < ActiveRecord::Migration[6.1] 4 | def change 5 | rename_column :books, :isbn_13, :isbn13 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationHelper 4 | def full_title(page_title = '') 5 | base_title = 'せるれぽ' 6 | page_title.empty? ? base_title : "#{page_title} | #{base_title}" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: serurepo_production 11 | -------------------------------------------------------------------------------- /app/javascript/channels/index.js: -------------------------------------------------------------------------------- 1 | // Load all the channels within this directory and all subdirectories. 2 | // Channel files must be named *_channel.js. 3 | 4 | const channels = require.context('.', true, /_channel\.js$/) 5 | channels.keys().forEach(channels) 6 | -------------------------------------------------------------------------------- /app/assets/stylesheets/styles/_header.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | .navbar-brand { 4 | justify-content: space-between; 5 | width: 100%; 6 | 7 | h1 { 8 | margin: 0; 9 | } 10 | 11 | .title.is-4 { 12 | margin-bottom: 0; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /db/migrate/20210816011750_create_books.rb: -------------------------------------------------------------------------------- 1 | class CreateBooks < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :books do |t| 4 | t.string :isbn_13 5 | t.integer :price 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/helpers/books_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module BooksHelper 4 | def format_price(price) 5 | price = price.to_i 6 | return '情報なし' if price.zero? 7 | 8 | price = price.to_fs(:delimited) 9 | "#{price}円" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 4 | 5 | require 'bundler/setup' # Set up gems listed in the Gemfile. 6 | require 'bootsnap/setup' # Speed up boot time by caching expensive operations. 7 | -------------------------------------------------------------------------------- /app/views/devise/mailer/email_changed.html.slim: -------------------------------------------------------------------------------- 1 | p 2 | = t('.greeting', recipient: @email) 3 | - if @resource.try(:unconfirmed_email?) 4 | p 5 | = t('.message_unconfirmed', email: @resource.unconfirmed_email) 6 | - else 7 | p 8 | = t('.message', email: @resource.email) 9 | -------------------------------------------------------------------------------- /db/migrate/20210909064118_add_omniauth_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddOmniauthToUsers < ActiveRecord::Migration[6.1] 2 | def change 3 | add_column :users, :provider, :string, null: false, default: '' 4 | add_column :users, :uid, :string, null: false, default: '' 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20211002092405_add_discount_rating_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDiscountRatingToUsers < ActiveRecord::Migration[6.1] 4 | def change 5 | add_column :users, :discount_rating, :integer, null:false, default: 1, limit: 8 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/users/lists/show.html.slim: -------------------------------------------------------------------------------- 1 | - provide(:title, 'セール通知リスト') 2 | section.container.is-max-desktop.py-4 3 | h1.title.is-4.has-text-centered 4 | = yield(:title) 5 | 6 | #js-users-lists-books(data-list-id="#{@list_id}") 7 | 8 | = javascript_include_tag 'users_lists_books' 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import'), 4 | require('postcss-flexbugs-fixes'), 5 | require('postcss-preset-env')({ 6 | autoprefixer: { 7 | flexbox: 'no-2009' 8 | }, 9 | stage: 3 10 | }) 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /app/models/list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class List < ApplicationRecord 4 | belongs_to :user 5 | has_many :list_details, dependent: :destroy 6 | has_many :books, through: :list_details 7 | 8 | validates :user_id, presence: true, numericality: { only_integer: true } 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20210816005509_create_lists.rb: -------------------------------------------------------------------------------- 1 | class CreateLists < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :lists do |t| 4 | t.belongs_to :user, index: { unique: true }, foreign_key: true 5 | t.string :name 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/application/_google_analytics.html.slim: -------------------------------------------------------------------------------- 1 | script async="" src="https://www.googletagmanager.com/gtag/js?id=G-D276J9CM1X" 2 | javascript: 3 | window.dataLayer = window.dataLayer || []; 4 | function gtag(){dataLayer.push(arguments);} 5 | gtag('js', new Date()); 6 | 7 | gtag('config', 'G-D276J9CM1X'); 8 | -------------------------------------------------------------------------------- /app/views/devise/mailer/reset_password_instructions.html.slim: -------------------------------------------------------------------------------- 1 | p 2 | = t('.greeting', recipient: @resource.email) 3 | p 4 | = t('.instruction') 5 | p 6 | = link_to t('.action'), edit_password_url(@resource, reset_password_token: @token) 7 | p 8 | = t('.instruction_2') 9 | p 10 | = t('.instruction_3') 11 | -------------------------------------------------------------------------------- /app/controllers/home_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class HomeController < ApplicationController 4 | skip_before_action :authenticate_user! 5 | 6 | def index 7 | if user_signed_in? 8 | render :index 9 | else 10 | render 'welcome/index' 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/javascript/channels/consumer.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the `bin/rails generate channel` command. 3 | 4 | import { createConsumer } from '@rails/actioncable' 5 | 6 | export default createConsumer() 7 | -------------------------------------------------------------------------------- /app/models/list_detail.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ListDetail < ApplicationRecord 4 | belongs_to :list 5 | belongs_to :book 6 | 7 | validates :list_id, presence: true, numericality: { only_integer: true } 8 | validates :book_id, presence: true, numericality: { only_integer: true } 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20210816012317_create_list_details.rb: -------------------------------------------------------------------------------- 1 | class CreateListDetails < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :list_details do |t| 4 | t.belongs_to :list, foreign_key: true 5 | t.belongs_to :book, foreign_key: true 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.slim: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | meta[http-equiv="Content-Type" content="text/html; charset=utf-8"] 5 | = stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' 6 | style 7 | | /* Email styles need to be inline */ 8 | body 9 | = yield 10 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # ActiveSupport::Reloader.to_prepare do 5 | # ApplicationController.renderer.defaults.merge!( 6 | # http_host: 'example.org', 7 | # https: false 8 | # ) 9 | # end 10 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Specify a serializer for the signed and encrypted cookie jars. 6 | # Valid options are :json, :marshal, and :hybrid. 7 | Rails.application.config.action_dispatch.cookies_serializer = :json 8 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Configure sensitive parameters which will be filtered from the log file. 6 | Rails.application.config.filter_parameters += %i[ 7 | passw secret token _key crypt salt certificate otp ssn 8 | ] 9 | -------------------------------------------------------------------------------- /app/views/devise/shared/_error_messages.html.slim: -------------------------------------------------------------------------------- 1 | .block 2 | - if resource.errors.any? 3 | #error_explanation 4 | 5 | article.message.is-danger 6 | .message-header 7 | p 8 | | エラー 9 | .message-body 10 | - resource.errors.full_messages.each do |message| 11 | li 12 | = message 13 | -------------------------------------------------------------------------------- /config/initializers/aws.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | creds = Aws::Credentials.new( 4 | Rails.application.credentials.aws[:access_key_id], 5 | Rails.application.credentials.aws[:secret_access_key]) 6 | 7 | Aws::Rails.add_action_mailer_delivery_method( 8 | :ses, 9 | credentials: creds, 10 | region: 'ap-northeast-1' 11 | ) 12 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationJob < ActiveJob::Base 4 | # Automatically retry jobs that encountered a deadlock 5 | # retry_on ActiveRecord::Deadlocked 6 | 7 | # Most jobs are safe to ignore if the underlying records are no longer available 8 | # discard_on ActiveJob::DeserializationError 9 | end 10 | -------------------------------------------------------------------------------- /lib/tasks/sale_report.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../comparer' 4 | require_relative '../mail_sender' 5 | 6 | namespace :sale_report do 7 | desc 'run crawler, comparer, and mail sender' 8 | task start: :environment do 9 | compared_data = Comparer::Books.run 10 | MailSender.sale_report(compared_data) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/assets/stylesheets/styles/_footer.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | .footer { 4 | background-color: white; 5 | font-size: 14px !important; 6 | 7 | &-item { 8 | list-style: none; 9 | } 10 | 11 | &-icon { 12 | .icon { 13 | height: 1.75rem; 14 | width: 1.75rem; 15 | } 16 | &:hover { 17 | opacity: 0.6; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark the yarn lockfile as having been generated. 7 | yarn.lock linguist-generated 8 | 9 | # Mark any vendored files as having been vendored. 10 | vendor/* linguist-vendored 11 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | Metrics/AbcSize: 2 | Exclude: 3 | - 'lib/mail_sender.rb' 4 | - 'lib/comparer.rb' 5 | - 'lib/crawler/seshop_crawler.rb' 6 | - 'lib/crawler/rakuten_crawler.rb' 7 | - 'lib/crawler/dmm_crawler.rb' 8 | - 'lib/crawler/amazon_crawler.rb' 9 | - 'app/controllers/books_controller.rb' 10 | 11 | Metrics/CyclomaticComplexity: 12 | Exclude: 13 | - 'lib/mail_sender.rb' 14 | -------------------------------------------------------------------------------- /spec/support/login_module.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LoginModule 4 | def login(user) 5 | visit new_user_session_path 6 | fill_in 'Eメール', with: user.email 7 | fill_in 'パスワード', with: 'password' 8 | click_button 'ログイン' 9 | end 10 | 11 | def visit_with_auth(url, user) 12 | login_user = create(user) 13 | login(login_user) 14 | visit url 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/mailers/sale_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SaleMailer < ApplicationMailer 4 | helper :books 5 | 6 | default from: 'せるれぽ ' 7 | 8 | def sale_email 9 | @user = params[:user] 10 | @sale_data = params[:sale_data] 11 | @book = Book.find(@sale_data[:book_id]) 12 | mail(to: @user.email, subject: "【せるれぽ】#{@book.title}がセールになっています") 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | before_action :authenticate_user! 5 | before_action :configure_permitted_parameters, if: :devise_controller? 6 | 7 | protected 8 | 9 | def configure_permitted_parameters 10 | devise_parameter_sanitizer.permit(:account_update, keys: [:discount_rating]) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/controllers/users/lists_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Users::ListsController < ApplicationController 4 | before_action :require_self_list, only: %i[show] 5 | 6 | def show 7 | @list_id = params[:id] 8 | end 9 | 10 | private 11 | 12 | def require_self_list 13 | redirect_to root_path, alert: 'URLが不正です。' if current_user.list&.id != params[:id].to_i 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20210818034413_add_title_and_author_and_image_and_url_and_sales_date_to_books.rb: -------------------------------------------------------------------------------- 1 | class AddTitleAndAuthorAndImageAndUrlAndSalesDateToBooks < ActiveRecord::Migration[6.1] 2 | def change 3 | add_column :books, :title, :text 4 | add_column :books, :author, :text 5 | add_column :books, :image, :text 6 | add_column :books, :url, :text 7 | add_column :books, :sales_date, :text 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.github/workflows/opened-issues-triage.yml: -------------------------------------------------------------------------------- 1 | name: Move new issues into serurepo 2 | on: 3 | issues: 4 | types: [opened] 5 | 6 | jobs: 7 | automate-project-columns: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: alex-page/github-project-automation-plus@v0.8.1 11 | with: 12 | project: 開発 13 | column: いつかやる 14 | repo-token: ${{ secrets.GH_TOKEN }} 15 | 16 | -------------------------------------------------------------------------------- /app/assets/stylesheets/styles/_welcome.scss: -------------------------------------------------------------------------------- 1 | section.hero { 2 | .hero-body { 3 | .title { 4 | line-height: 1.5; 5 | margin-top: 0; 6 | font-weight: 600 !important; 7 | } 8 | .subtitle { 9 | line-height: 1.6; 10 | font-size: 18px !important; 11 | font-weight: bold; 12 | } 13 | } 14 | 15 | .button { 16 | border: #D8C54A solid 1px; 17 | font-weight: bold; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/javascript/users_lists_books.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import Books from './users_lists_books.vue' 3 | 4 | document.addEventListener('DOMContentLoaded', () => { 5 | const selector = '#js-users-lists-books' 6 | const list = document.querySelector(selector) 7 | 8 | if (list) { 9 | const listId = list.getAttribute('data-list-id') 10 | createApp(Books, { listId: listId }).mount(selector) 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /app/assets/stylesheets/styles/_index.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | .title { 4 | font-family: 'Kiwi Maru', serif; 5 | } 6 | 7 | section.hero { 8 | background: rgba(255, 255, 255, 0.8) url("/only_logo.png") no-repeat top 0 right -210px; 9 | background-blend-mode: lighten; 10 | height: 500px; 11 | 12 | @include tablet { 13 | background: rgba(255, 255, 255, 0.8) url("/only_logo.png") no-repeat top 0 right -180px; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/controllers/api/list_details/id_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class API::ListDetails::IdController < API::BaseController 4 | def index 5 | book = Book.find_by(isbn13: params['isbn']) 6 | list = current_user.list 7 | list_detail = ListDetail.find_by(list_id: list&.id, book_id: book&.id) 8 | 9 | id = list_detail.present? ? list_detail.id : nil 10 | render status: :ok, json: { listDetailId: id } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/javascript/searched_books.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 23 | -------------------------------------------------------------------------------- /app/views/books/shared/_search_form.html.slim: -------------------------------------------------------------------------------- 1 | = form_with(url: books_path, method: :get) do |f| 2 | .columns 3 | .column 4 | .field 5 | = f.label :query, '検索ワード or ISBN', class: 'label' 6 | .control 7 | = f.text_field :query, class: 'input', placeholder: '例)吾輩は猫である / 9784774193977' 8 | 9 | .actions 10 | .field.is-grouped.is-grouped-left 11 | = f.submit '検索する', name: nil, class: 'button max quarter-tablet is-info' 12 | -------------------------------------------------------------------------------- /app/views/api/users/lists/show.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.books do 2 | json.array!(@list_details) do |detail| 3 | json.list_detail_id detail.id 4 | json.book do 5 | book = detail.book 6 | json.id book.id 7 | json.isbn13 book.isbn13 8 | json.price book.price 9 | json.title book.title 10 | json.author book.author 11 | json.image book.image 12 | json.url book.url 13 | json.sales_date book.sales_date 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/requests/welcome_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Welcome', type: :request do 6 | describe 'GET /tos' do 7 | it 'success' do 8 | get tos_path 9 | expect(response).to have_http_status :ok 10 | end 11 | end 12 | 13 | describe 'GET /privacy_policy' do 14 | it 'success' do 15 | get privacy_policy_path 16 | expect(response).to have_http_status :ok 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/controllers/users/registrations_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Users::RegistrationsController < Devise::RegistrationsController 4 | def build_resource(hash = {}) 5 | hash[:uid] = User.create_unique_string 6 | super 7 | end 8 | 9 | protected 10 | 11 | def update_resource(resource, params) 12 | return super if params['password'].present? 13 | 14 | resource.update_without_password(params.except('current_password')) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/views/welcome/index.html.slim: -------------------------------------------------------------------------------- 1 | section.hero.mt-5 2 | .hero-body 3 | h1.title.is-4.is-spaced.has-text-centered-desktop 4 | | "いつか読みたい本"
安い時に買いませんか? 5 | p.subtitle.has-text-centered-desktop.pb-6 6 | | いつか読みたい本がセールになったら
メールでお知らせ。 7 | 8 | .block.block-centered-desktop 9 | p = link_to 'アカウント登録', :new_user_registration, class: 'button is-medium is-warning px-6' 10 | p.mt-4 11 | = link_to 'ログイン', :new_user_session, class: 'under-line-link' 12 | -------------------------------------------------------------------------------- /app/assets/stylesheets/styles/_common.scss: -------------------------------------------------------------------------------- 1 | @include desktop { 2 | .block-centered-desktop { 3 | display: flex; 4 | align-items: center; 5 | flex-direction: column; 6 | } 7 | } 8 | 9 | .container { 10 | max-width: 680px !important; 11 | 12 | @include mobile { 13 | width: 90%; 14 | } 15 | @include tablet { 16 | min-width: 400px; 17 | } 18 | } 19 | 20 | .under-line-link { 21 | text-decoration: underline; 22 | } 23 | 24 | .title { 25 | font-weight: 400; 26 | } 27 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Define an application-wide HTTP permissions policy. For further 3 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 4 | # 5 | # Rails.application.config.permissions_policy do |f| 6 | # f.camera :none 7 | # f.gyroscope :none 8 | # f.microphone :none 9 | # f.usb :none 10 | # f.fullscreen :self 11 | # f.payment :self, "https://secure.example.com" 12 | # end 13 | -------------------------------------------------------------------------------- /app/javascript/searched_books.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import searchedBooks from './searched_books.vue' 3 | 4 | document.addEventListener('DOMContentLoaded', () => { 5 | const selector = '#js-searched-books' 6 | const booksDom = document.querySelector(selector) 7 | 8 | if (booksDom) { 9 | let books = booksDom.getAttribute('data-searched-books') 10 | books = JSON.parse(books) 11 | createApp(searchedBooks, { 12 | books: books 13 | }).mount(selector) 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /app/assets/stylesheets/styles/_button.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | &.max { 3 | width: 100%; 4 | } 5 | &.max-mobile { 6 | @include mobile { 7 | width: 100%; 8 | } 9 | } 10 | &.quarter-tablet { 11 | @include tablet { 12 | width: 25%; 13 | } 14 | } 15 | } 16 | 17 | .break { 18 | background-color: rgba(0,0,0,0); 19 | padding: 0; 20 | border: none; 21 | color: #485fc7; 22 | cursor: pointer; 23 | text-decoration: none; 24 | &:hover { 25 | color: #363636; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /spec/support/google_oauth_mock_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GoogleOauthMockHelper 4 | def google_oauth_mock 5 | OmniAuth.config.test_mode = true 6 | OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new( 7 | { 8 | provider: 'google_oauth2', 9 | uid: '123456789', 10 | info: { 11 | email: 'bob@example.com' 12 | }, 13 | credentials: { 14 | token: 'token' 15 | } 16 | } 17 | ) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/assets/stylesheets/styles/_searched_books.scss: -------------------------------------------------------------------------------- 1 | @include tablet { 2 | .media-content.float { 3 | position: relative; 4 | padding-right: 200px; 5 | 6 | .level { 7 | position: absolute; 8 | right: 0; 9 | top: 0; 10 | } 11 | } 12 | } 13 | 14 | @include mobile { 15 | .media.float { 16 | position: relative; 17 | padding-bottom: 50px; 18 | 19 | .level { 20 | position: absolute; 21 | left: 0; 22 | bottom: 0; 23 | width: 100%; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | volumes: 4 | db-data: 5 | 6 | services: 7 | db: 8 | image: library/postgres:14 9 | volumes: 10 | - db-data:/var/lib/postgresql/data 11 | environment: 12 | POSTGRES_USER: postgres 13 | POSTGRES_PASSWORD: postgres 14 | web: 15 | build: 16 | context: . 17 | dockerfile: Dockerfile 18 | command: bash entry_point.sh 19 | depends_on: 20 | - db 21 | ports: 22 | - '3000:3000' 23 | volumes: 24 | - .:/serurepo 25 | -------------------------------------------------------------------------------- /app/models/book.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Book < ApplicationRecord 4 | has_many :list_details, dependent: :destroy 5 | has_many :lists, through: :list_details 6 | 7 | validates :isbn13, presence: true, length: { is: 13 } 8 | validates :price, presence: true, numericality: { only_integer: true } 9 | validates :title, presence: true 10 | validates :image, presence: true 11 | validates :url, presence: true 12 | 13 | def not_in_list_details_destroy! 14 | destroy! if list_details.empty? 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/system/dmm_crawler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | require_relative '../../lib/crawler/dmm_crawler' 5 | 6 | RSpec.describe 'DmmCrawler', type: :system do 7 | describe 'run' do 8 | let(:crawler) { DmmCrawler.new } 9 | let(:book) { build(:perfect_rails) } 10 | 11 | it '金額は数値、URLはhttps://book.dmm.com/detail/から始まること' do 12 | crawler.run(book.title) 13 | expect(crawler.price).to be_integer 14 | expect(crawler.book_url).to be_include 'https://book.dmm.com/detail/' 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/request_spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RequestSpecHelper 4 | include Warden::Test::Helpers 5 | 6 | def self.included(base) 7 | base.before { Warden.test_mode! } 8 | base.after { Warden.test_reset! } 9 | end 10 | 11 | def sign_in(resource) 12 | login_as(resource, scope: warden_scope(resource)) 13 | end 14 | 15 | def sign_out(resource) 16 | logout(warden_scope(resource)) 17 | end 18 | 19 | private 20 | 21 | def warden_scope(resource) 22 | resource.class.name.underscore.to_sym 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | if !defined?(Spring) && [nil, 'development', 'test'].include?(ENV['RAILS_ENV']) 5 | gem 'bundler' 6 | require 'bundler' 7 | 8 | # Load Spring without loading other gems in the Gemfile, for speed. 9 | Bundler.locked_gems&.specs&.find { |spec| spec.name == 'spring' }&.tap do |spring| 10 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 11 | gem 'spring', spring.version 12 | require 'spring/binstub' 13 | rescue Gem::LoadError 14 | # Ignore when Spring is not installed. 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/system/rakuten_crawler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | require_relative '../../lib/crawler/rakuten_crawler' 5 | 6 | RSpec.describe 'RakutenCrawler', type: :system do 7 | describe 'run' do 8 | let(:crawler) { RakutenCrawler.new } 9 | let(:book) { build(:perfect_rails) } 10 | 11 | it '全ての金額が数値で返ってくること' do 12 | crawler.run(book.isbn13) 13 | expect(crawler.single_price).to be_integer 14 | expect(crawler.e_book_price).to be_integer 15 | expect(crawler.paper_book_price).to be_integer 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:vue/recommended", 4 | "eslint:recommended", 5 | "standard", 6 | "prettier" 7 | ], 8 | "env": { 9 | "jquery": true, 10 | "node": true 11 | }, 12 | "globals": { 13 | "window": true, 14 | "fetch": true, 15 | "FileReader": true, 16 | "Event": true, 17 | "FormData": true 18 | }, 19 | "root": true, 20 | "parserOptions": { 21 | "ecmaVersion": 2020 22 | }, 23 | "rules": { 24 | "no-unused-vars": ["error", { 25 | "args": "all", 26 | "argsIgnorePattern": "^_" 27 | }] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # This file contains settings for ActionController::ParamsWrapper which 6 | # is enabled by default. 7 | 8 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 9 | ActiveSupport.on_load(:action_controller) do 10 | wrap_parameters format: [:json] 11 | end 12 | 13 | # To enable root element in JSON for ActiveRecord objects. 14 | # ActiveSupport.on_load(:active_record) do 15 | # self.include_root_in_json = true 16 | # end 17 | -------------------------------------------------------------------------------- /spec/requests/google_oauth_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'GoogleOauth', type: :request do 6 | include GoogleOauthMockHelper 7 | 8 | describe 'Google認証' do 9 | before do 10 | OmniAuth.config.mock_auth[:google_oauth2] = nil 11 | Rails.application.env_config['devise.mapping'] = Devise.mappings[:user] 12 | Rails.application.env_config['omniauth.auth'] = google_oauth_mock 13 | end 14 | 15 | it '成功すること' do 16 | post '/users/auth/google_oauth2/callback' 17 | expect(response).to have_http_status :found 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 6 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } 7 | 8 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code 9 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". 10 | Rails.backtrace_cleaner.remove_silencers! if ENV['BACKTRACE'] 11 | -------------------------------------------------------------------------------- /app/views/devise/passwords/new.html.slim: -------------------------------------------------------------------------------- 1 | - provide(:title, t('.forgot_your_password')) 2 | section.container.is-max-desktop 3 | h1.title.is-4.has-text-centered 4 | = yield(:title) 5 | 6 | = form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| 7 | = render 'devise/shared/error_messages', resource: resource 8 | 9 | .field 10 | = f.label :email, class: 'label' 11 | = f.email_field :email, autofocus: true, autocomplete: 'email', class: 'input' 12 | 13 | .actions 14 | = f.submit t('.send_me_reset_password_instructions'), class: 'button max is-info' 15 | 16 | = render 'devise/shared/links' 17 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | APP_ROOT = File.expand_path('..', __dir__) 5 | Dir.chdir(APP_ROOT) do 6 | yarn = ENV['PATH'].split(File::PATH_SEPARATOR) 7 | .reject { |dir| File.expand_path(dir) == __dir__ } 8 | .product(['yarn', 'yarn.cmd', 'yarn.ps1']) 9 | .map { |dir, file| File.expand_path(file, dir) } 10 | .find { |file| File.executable?(file) } 11 | 12 | if yarn 13 | exec yarn, *ARGV 14 | else 15 | warn 'Yarn executable was not detected in the system.' 16 | warn 'Download Yarn at https://yarnpkg.com/en/docs/install' 17 | exit 1 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.bulma.scss: -------------------------------------------------------------------------------- 1 | @import '../app/assets/stylesheets/styles/reset'; 2 | 3 | @import 'bulma/bulma'; 4 | 5 | @import '../app/assets/stylesheets/styles/button'; 6 | @import '../app/assets/stylesheets/styles/common'; 7 | @import '../app/assets/stylesheets/styles/fixed-footer'; 8 | @import '../app/assets/stylesheets/styles/footer'; 9 | @import '../app/assets/stylesheets/styles/form'; 10 | @import '../app/assets/stylesheets/styles/header'; 11 | @import '../app/assets/stylesheets/styles/index'; 12 | @import '../app/assets/stylesheets/styles/material-icons'; 13 | @import '../app/assets/stylesheets/styles/searched_books'; 14 | @import '../app/assets/stylesheets/styles/welcome'; 15 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Version of your assets, change this if you want to expire all your assets. 6 | Rails.application.config.assets.version = '1.0' 7 | 8 | # Add additional assets to the asset load path. 9 | # Rails.application.config.assets.paths << Emoji.images_path 10 | # Add Yarn node_modules folder to the asset load path. 11 | Rails.application.config.assets.paths << Rails.root.join('node_modules') 12 | 13 | # Precompile additional assets. 14 | # application.js, application.css, and all non-JS/CSS in the app/assets 15 | # folder are already added. 16 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 17 | -------------------------------------------------------------------------------- /app/models/rakuten_books_searcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RakutenBooksSearcher 4 | def initialize(query, page_num, count_per_page) 5 | uri = URI('https://app.rakuten.co.jp/services/api/BooksBook/Search/20170404') 6 | uri.query = { 7 | format: :json, 8 | hits: count_per_page, 9 | affiliateId: Rails.application.credentials.rakuten[:af_id], 10 | applicationId: Rails.application.credentials.rakuten[:app_id], 11 | page: page_num, 12 | (/^978[0-9]{10}$/.match?(query) ? :isbn : :title) => query 13 | }.to_query 14 | @url = uri.to_s 15 | end 16 | 17 | def run 18 | client = HTTPClient.new 19 | request = client.get(@url) 20 | JSON.parse(request.body) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/controllers/api/books_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class API::BooksController < API::BaseController 4 | def create 5 | book = Book.create_with(book_params).find_or_initialize_by(isbn13: params['book']['isbn13']) 6 | 7 | if book.new_record? 8 | if book.save 9 | render status: :created, 10 | json: { bookId: book.id } 11 | else 12 | render status: :unprocessable_entity, 13 | json: { errorMessage: '登録に失敗しました。' } 14 | end 15 | else 16 | render status: :ok, json: { bookId: book.id } 17 | end 18 | end 19 | 20 | private 21 | 22 | def book_params 23 | params.require(:book).permit(:isbn13, :price, :title, :author, :image, :url, :sales_date) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/system/amazon_crawler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | require_relative '../../lib/crawler/amazon_crawler' 5 | 6 | RSpec.describe 'AmazonCrawler', type: :system do 7 | describe 'run' do 8 | let(:crawler) { AmazonCrawler.new } 9 | let(:book) { build(:perfect_rails) } 10 | let(:dokugaku) { build(:dokugaku) } 11 | 12 | it '金額は数値、ASINは10桁の文字列が返ってくること' do 13 | crawler.run(book.isbn13) 14 | expect(crawler.kindle_price).to be_integer 15 | expect(crawler.paper_price).to be_integer 16 | expect(crawler.asin.size).to eq 10 17 | end 18 | 19 | it 'audible版がある書籍でも0円にならないこと' do 20 | crawler.run(dokugaku.isbn13) 21 | expect(crawler.paper_price).not_to eq 0 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/views/devise/confirmations/new.html.slim: -------------------------------------------------------------------------------- 1 | - provide(:title, t('.resend_confirmation_instructions')) 2 | section.container.is-max-desktop 3 | h1.title.is-4.has-text-centered 4 | = yield(:title) 5 | 6 | = form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| 7 | = render 'devise/shared/error_messages', resource: resource 8 | 9 | .field 10 | = f.label :email, class: 'label' 11 | = f.email_field :email, autofocus: true, autocomplete: 'email', class: 'input', 12 | value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) 13 | 14 | .actions 15 | = f.submit t('.resend_confirmation_instructions'), class: 'button max is-info' 16 | 17 | = render 'devise/shared/links' 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Docker 17 | shell: bash 18 | run: | 19 | docker-compose build 20 | docker-compose run web yarn install --check-files 21 | docker-compose run web yarn upgrade 22 | docker-compose run web yarn build 23 | docker-compose run web yarn build:css 24 | docker-compose run web echo ${{ secrets.RAILS_MASTER_KEY }} >> config/master.key 25 | docker-compose run web bin/rails db:create 26 | 27 | - name: Run tests 28 | run: docker-compose run web bundle exec rspec 29 | -------------------------------------------------------------------------------- /app/controllers/api/users/lists_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class API::Users::ListsController < API::BaseController 4 | def show 5 | list_id = params[:id] 6 | @list_details = ListDetail.where(list_id:) 7 | end 8 | 9 | def create 10 | list = List.create_with(list_params).find_or_initialize_by(user: current_user) 11 | 12 | if list.new_record? 13 | if list.save 14 | render status: :created, json: { listId: list.id } 15 | else 16 | render status: :unprocessable_entity, json: { errorMessage: '登録に失敗しました。' } 17 | end 18 | else 19 | render status: :ok, json: { listId: list.id } 20 | end 21 | end 22 | 23 | private 24 | 25 | def list_params 26 | params.permit(:user_id).merge(user_id: current_user.id) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/controllers/books_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BooksController < ApplicationController 4 | def index 5 | if params[:query].present? 6 | @query = params[:query] 7 | @page_num = (params[:page] || 1).to_i 8 | 9 | # APIの仕様上ページ指定は100が上限 10 | return redirect_to root_path, alert: 'パラメーターが不正です。' unless @page_num.between?(1, 100) 11 | 12 | count_per_page = 20 13 | 14 | response = RakutenBooksSearcher.new(@query, @page_num, count_per_page).run 15 | 16 | @max_page_num = response['pageCount'] 17 | @total_num = response['count'] 18 | @first_num = response['first'] 19 | @last_num = response['last'] 20 | @items = response['Items'] 21 | else 22 | redirect_to root_path, alert: '検索ワードを入力してください。' 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/controllers/users/omniauth_callbacks_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController 4 | skip_before_action :verify_authenticity_token, only: :google_oauth2 5 | 6 | def google_oauth2 7 | callback_for(:google) 8 | end 9 | 10 | def callback_for(provider) 11 | @user = User.from_omniauth(request.env['omniauth.auth']) 12 | 13 | if @user.persisted? 14 | sign_in_and_redirect @user, event: :authentication 15 | set_flash_message(:notice, :success, kind: provider.to_s.capitalize) if is_navigational_format? 16 | else 17 | session["devise.#{provider}_data"] = request.env['omniauth.auth'].except(:extra) 18 | redirect_to new_user_registration_url 19 | end 20 | end 21 | 22 | def failure 23 | redirect_to root_path 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Add new inflection rules using the following format. Inflections 5 | # are locale specific, and you may define rules for as many different 6 | # locales as you wish. All of these examples are active by default: 7 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 8 | # inflect.plural /^(ox)$/i, '\1en' 9 | # inflect.singular /^(ox)en/i, '\1' 10 | # inflect.irregular 'person', 'people' 11 | # inflect.uncountable %w( fish sheep ) 12 | # end 13 | 14 | # These inflection rules are supported but not enabled by default: 15 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 16 | # inflect.acronym 'RESTful' 17 | # end 18 | ActiveSupport::Inflector.inflections(:en) do |inflect| 19 | inflect.acronym 'API' 20 | end 21 | -------------------------------------------------------------------------------- /spec/mailers/sale_mailer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe SaleMailer, type: :mailer do 6 | describe 'sale_email' do 7 | let(:list) { create(:list) } 8 | let(:user) { list.user } 9 | let(:book) { create(:cherry) } 10 | let(:sale_data) do 11 | { book_id: book.id, 12 | amazon: 13 | { price: 2926, 14 | url: 'https://www.amazon.co.jp/dp/B0734GH91L/' } } 15 | end 16 | let(:mail) { described_class.with(user:, sale_data:).sale_email } 17 | 18 | it 'ユーザーのメールアドレスに送ること' do 19 | expect(mail.to).to eq [user.email] 20 | end 21 | 22 | it '指定のメールアドレスから送信すること' do 23 | expect(mail.from).to eq ['noreply@serurepo.com'] 24 | end 25 | 26 | it '正しい件名で送信すること' do 27 | expect(mail.subject).to eq "【せるれぽ】#{book.title}がセールになっています" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Setup docker 16 | shell: bash 17 | run: | 18 | docker-compose build 19 | docker-compose run web yarn install --check-files 20 | docker-compose run web yarn upgrade 21 | 22 | - name: Rubocop 23 | run: docker-compose run web bundle exec rubocop 24 | 25 | - name: Slim Lint 26 | run: docker-compose run web bundle exec slim-lint app/views 27 | 28 | - name: ESLint 29 | run: docker-compose run web bin/yarn eslint 'app/javascript/**/*.{js,vue}' --max-warnings=0 30 | 31 | - name: Prettier 32 | run: docker-compose run web bin/yarn prettier app/javascript/**/*.{js,vue} --check 33 | -------------------------------------------------------------------------------- /app/helpers/meta_tags_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MetaTagsHelper 4 | def default_meta_tags # rubocop:disable Metrics/MethodLength 5 | { 6 | site: 'せるれぽ', 7 | reverse: true, 8 | charset: 'utf-8', 9 | description: '「いつか読みたい本」を安いときに。書籍のセール通知サービス。', 10 | viewport: 'width=device-width, initial-scale=1.0', 11 | og: { 12 | title: 'せるれぽ', 13 | type: 'website', 14 | site_name: 'serurepo', 15 | description: '「いつか読みたい本」を安いときに。書籍のセール通知サービス。', 16 | image: 'https://serurepo.com/ogp/ogp.png', 17 | url: 'https://serurepo.com' 18 | }, 19 | twitter: { 20 | card: 'summary_large_image', 21 | site: '@fugakkbn', 22 | description: '「いつか読みたい本」を安いときに。書籍のセール通知サービス。', 23 | image: 'https://serurepo.com/ogp/ogp.png', 24 | domain: 'https://serurepo.com' 25 | } 26 | } 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/views/devise/passwords/edit.html.slim: -------------------------------------------------------------------------------- 1 | - provide(:title, t('.change_your_password')) 2 | section.container.is-max-desktop 3 | h1.title.is-4.has-text-centered 4 | = yield(:title) 5 | 6 | = form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| 7 | = render 'devise/shared/error_messages', resource: resource 8 | = f.hidden_field :reset_password_token 9 | 10 | .field 11 | = f.label :password, t('.new_password'), class: 'label' 12 | = f.password_field :password, autofocus: true, autocomplete: 'new-password', class: 'input' 13 | 14 | .field 15 | = f.label :password_confirmation, t('.confirm_new_password'), class: 'label' 16 | = f.password_field :password_confirmation, autocomplete: 'new-password', class: 'input' 17 | 18 | .actions 19 | = f.submit t('.change_my_password'), class: 'button max is-info' 20 | 21 | = render 'devise/shared/links' 22 | -------------------------------------------------------------------------------- /app/views/devise/registrations/new.html.slim: -------------------------------------------------------------------------------- 1 | - provide(:title, t('.sign_up')) 2 | section.container.is-max-desktop 3 | h1.title.is-4.has-text-centered 4 | = yield(:title) 5 | 6 | = form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| 7 | = render 'devise/shared/error_messages', resource: resource 8 | 9 | .field 10 | = f.label :email, class: 'label' 11 | = f.email_field :email, autofocus: true, autocomplete: 'email', class: 'input' 12 | 13 | .field 14 | = f.label :password, class: 'label' 15 | = f.password_field :password, autocomplete: 'new-password', class: 'input' 16 | 17 | .field 18 | = f.label :password_confirmation, class: 'label' 19 | = f.password_field :password_confirmation, autocomplete: 'new-password', class: 'input' 20 | 21 | .actions 22 | = f.submit t('.sign_up'), class: 'button max is-info' 23 | 24 | = render 'devise/shared/links' 25 | -------------------------------------------------------------------------------- /spec/helpers/books_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe BooksHelper, type: :helper do 6 | describe '#format_price' do 7 | context '数値の場合' do 8 | it '4桁の場合、カンマ区切りに円が付くこと' do 9 | price = 1500 10 | expect(format_price(price)).to eq '1,500円' 11 | end 12 | 13 | it '3桁の場合、カンマなしで円が付くこと' do 14 | price = 900 15 | expect(format_price(price)).to eq '900円' 16 | end 17 | end 18 | 19 | context '文字列の場合' do 20 | it '4桁の場合、カンマ区切りに円が付くこと' do 21 | price = '1500' 22 | expect(format_price(price)).to eq '1,500円' 23 | end 24 | 25 | it '3桁の場合、カンマなしで円が付くこと' do 26 | price = '900' 27 | expect(format_price(price)).to eq '900円' 28 | end 29 | 30 | it '半角数字以外の場合、「情報なし」と返ること' do 31 | price = 'あいう' 32 | expect(format_price(price)).to eq '情報なし' 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/models/list_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe List, type: :model do 6 | describe '正常系' do 7 | context '全て想定の値の場合' do 8 | it '登録成功' do 9 | list = create(:list) 10 | expect(list).to be_valid 11 | end 12 | end 13 | 14 | context 'nameが空の場合' do 15 | it '登録成功' do 16 | list = create(:list, name: '') 17 | expect(list).to be_valid 18 | end 19 | end 20 | end 21 | 22 | describe 'user_id' do 23 | context '空の場合' do 24 | it '登録失敗' do 25 | list = build(:list, user_id: '') 26 | list.valid? 27 | expect(list.errors[:user_id]).to include('を入力してください') 28 | end 29 | end 30 | 31 | context '文字列の場合' do 32 | it '登録失敗' do 33 | list = build(:list, user_id: 'テスト') 34 | list.valid? 35 | expect(list.errors[:user_id]).to include('は数値で入力してください') 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | bWJJf1ZHctnf5jBhI68xTqqYHlx7y8WEomUFjNReSDON8C2HwGKvybo0Zf6UckbtWoLpF7hDv0OoWXwEZuuG/Iqi2xavck44S/BuH9J79MYPO1Ef/TjE4u/x+2W2liC62aE+ln/i5+e5Q7HpqsrSR3mIKO5/0iZu7m9x7DKkyeQwXJdLcKuHoO2BFnSbJVawdm9Gd6J1ZHy/qMELW9w4i/siPSHOWXf0GdMgc2RwJov5Y0lCA+dgvt2vF0blVj2Q/AB/CC4qDPSFWb+tGxz3Z2OAUntdPi4JKgFMKdTarGRoBruzrW1MfDReteD5+GvW5GEB3+wJ3jYzUVMcMHxSQU6wHyGD/z/0pjIHvLrjxmA/TqeXC42iixJkytN4UXLtqHD03wwBoEYOv3zeLwI9qIbQhhBT074LA59DrUBmoIQKiB1kSHTaLBliygT1QVWMQ4LpioJNW/DqsZj9D/AGekEemlkome6prgZ5+QvpZBs1KwELEGr+Bq4g/LVyFMYnjefPRGjeC1+ERLKMFDFEgT/cDZQXGzJzvQV//b8aWVwanYnd/nSbUWLAX2f7K7l3BhWND/wkIAnaDby7ssAk+cYHgVnCegclzMrjfFM4HYQiDWFWFR4+iFoM6ps0mMgaD5DxAH7y/oGHIKVhqDzBSDmFTTjE7jKndkwuqo72ilCIU5JB4gYwoPSWdi3bFL3oDnQ4iPmulXV8W5OmU4VqgbhuNBC+DipuW+ngKwgBZYM5fxYqWWc1We0Z2W1V8h5Ph6gqg9hXUr/ePOPL0FD6XgJgN13W+95cN+iDHsmzjZsYSPlHBDe+umApP77wlPk+olHwBeydPMJO3wnc6v4YLbL3XvGR4kGiQ0gQ8xmZmzI5hLmKr7md4vd7K//w339G8pTy+m8Ds09GUQpviy13uk7kSxEkJPhov3g1oBq1HNZBhdf0jYCiD+p124DXhPD74h8=--7zBDdsAHqwCgglW6--0735g9h/l759LbveknDzow== -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'rails/all' 6 | 7 | Bundler.require(*Rails.groups) 8 | 9 | module Serurepo 10 | class Application < Rails::Application 11 | config.load_defaults 6.1 12 | config.i18n.default_locale = :ja 13 | 14 | config.generators do |g| 15 | g.test_framework :rspec, 16 | view_specs: false, 17 | helper_specs: false, 18 | controller_specs: false, 19 | routing_specs: false 20 | g.template_engine = :slim 21 | g.stylesheet_engine :sass 22 | g.javascripts false 23 | g.helper false 24 | g.assets false 25 | end 26 | 27 | config.generators.after_generate do |files| 28 | system('bundle exec rubocop --auto-correct-all ' + files.join(' '), exception: true) 29 | end 30 | 31 | config.time_zone = 'Tokyo' 32 | config.active_record.default_timezone = :local 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/views/application/_footer.html.slim: -------------------------------------------------------------------------------- 1 | footer.footer.px-4.py-5 2 | ul.block.is-flex.is-justify-content-center 3 | li.footer-item 4 | = link_to '利用規約', tos_path, class: 'mx-3' 5 | li.footer-item 6 | = link_to 'プライバシーポリシー', privacy_policy_path, class: 'mx-3' 7 | - if user_signed_in? 8 | li.footer-item 9 | / NOTE: Turbo を有効にできないので button_to でお茶を濁している 10 | = button_to 'ログアウト', :destroy_user_session, method: :delete, class: 'mx-3 break' 11 | 12 | ul.block.is-flex.is-justify-content-center 13 | li.footer-item 14 | = link_to (image_tag '/icons/github.png', class: 'icon is-medium mx-2'), 15 | 'https://github.com/fugakkbn/serurepo', 16 | class: 'footer-icon' 17 | li.footer-item 18 | = link_to (image_tag '/icons/twitter.png', class: 'icon is-medium mx-2'), 19 | 'https://twitter.com/fugakkbn', 20 | class: 'footer-icon' 21 | 22 | p.has-text-centered 23 | | © 24 | = Time.zone.today.year 25 | | ふーが 26 | -------------------------------------------------------------------------------- /scripts/one_shot/destroy_books_not_exists_in_list_details.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # 4 | # 現在どのリストにも登録されていない本を 5 | # 削除するための one_shot スクリプト 6 | # 7 | # リストから本を削除した時に、その本が他のリストに 8 | # 登録されているかを確認して登録されていなければ削除する 9 | # 機能を以下の PR で実装したので、実行は1度きりで OK 10 | # https://github.com/fugakkbn/serurepo/pull/227 11 | # 12 | # Usage: 13 | # 14 | # $ RAILS_ENV=production bin/rails r scripts/one_shot/destroy_books_not_exists_in_list_details.rb 15 | 16 | Rails.logger.info '*' * 30 17 | Rails.logger.info "== Start scripts/one_shot/destroy_books_not_exists_in_list_details at #{Time.current}" 18 | Rails.logger.info '*' * 30 19 | 20 | Book.find_each do |book| 21 | if book.list_details.empty? 22 | Rails.logger.info "#{book.title} を削除します。" 23 | book.destroy! 24 | else 25 | Rails.logger.info "#{book.title} は削除しません。" 26 | end 27 | end 28 | 29 | Rails.logger.info '*' * 30 30 | Rails.logger.info "== End scripts/one_shot/destroy_books_not_exists_in_list_details at #{Time.current}" 31 | Rails.logger.info '*' * 30 32 | -------------------------------------------------------------------------------- /spec/system/welcomes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Welcomes', type: :system do 6 | describe '#tos' do 7 | context '未ログインの場合' do 8 | it '利用規約ページが表示されること' do 9 | visit tos_path 10 | expect(page).to have_selector 'h1', text: '利用規約' 11 | end 12 | end 13 | 14 | context 'ログイン状態の場合' do 15 | it '利用規約ページが表示されること' do 16 | visit_with_auth '/tos', :alice 17 | expect(page).to have_selector 'h1', text: '利用規約' 18 | end 19 | end 20 | end 21 | 22 | describe '#privacy_policy' do 23 | context '未ログインの場合' do 24 | it '利用規約ページが表示されること' do 25 | visit privacy_policy_path 26 | expect(page).to have_selector 'h1', text: 'プライバシーポリシー' 27 | end 28 | end 29 | 30 | context 'ログイン状態の場合' do 31 | it '利用規約ページが表示されること' do 32 | visit_with_auth '/privacy_policy', :alice 33 | expect(page).to have_selector 'h1', text: 'プライバシーポリシー' 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/views/devise/sessions/new.html.slim: -------------------------------------------------------------------------------- 1 | - provide(:title, t('.sign_in')) 2 | section.container.is-max-desktop 3 | h1.title.is-4.has-text-centered 4 | = yield(:title) 5 | 6 | = form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| 7 | .field 8 | = f.label :email, class: 'label' 9 | = f.email_field :email, autofocus: true, autocomplete: 'email', class: 'input' 10 | 11 | .field 12 | = f.label :password, class: 'label' 13 | = f.password_field :password, autocomplete: 'current-password', class: 'input' 14 | - if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' 15 | p = link_to t('.forgot_your_password'), new_password_path(resource_name) 16 | 17 | - if devise_mapping.rememberable? 18 | .field 19 | = f.check_box :remember_me 20 | = f.label :remember_me, class: 'checkbox' 21 | 22 | .actions 23 | = f.submit t('.sign_in'), class: 'button max is-info' 24 | 25 | = render 'devise/shared/links' 26 | -------------------------------------------------------------------------------- /spec/system/home_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'home', type: :system do 6 | describe '#index' do 7 | context 'ログインせずに/にアクセスした場合' do 8 | it 'welcome#indexを表示' do 9 | visit root_path 10 | expect(page).to have_selector 'h1', text: "\"いつか読みたい本\"\n安い時に買いませんか?" 11 | end 12 | 13 | it '利用規約とプライバシーポリシーのリンクが表示されること' do 14 | visit root_path 15 | expect(page).to have_link '利用規約', href: tos_path 16 | expect(page).to have_link 'プライバシーポリシー', href: privacy_policy_path 17 | end 18 | end 19 | 20 | context 'ログインして/にアクセスした場合' do 21 | before do 22 | visit_with_auth '/', :alice 23 | end 24 | 25 | it 'home#indexを表示' do 26 | expect(page).to have_selector 'h1', text: '書籍検索' 27 | end 28 | 29 | it '利用規約とプライバシーポリシーのリンクが表示されること' do 30 | expect(page).to have_link '利用規約', href: tos_path 31 | expect(page).to have_link 'プライバシーポリシー', href: privacy_policy_path 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ApplicationRecord 4 | # Include default devise modules. Others available are: 5 | # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable 6 | devise :database_authenticatable, :registerable, 7 | :recoverable, :rememberable, :validatable, 8 | :trackable, :confirmable, 9 | :omniauthable, omniauth_providers: %i[google_oauth2] 10 | 11 | has_one :list, dependent: :destroy 12 | 13 | flag :discount_rating, %i[even over10 over20 over30 over50] 14 | 15 | validates :uid, presence: true, uniqueness: { scope: :provider }, if: -> { uid.present? } 16 | validates :discount_rating, numericality: { only_integer: true, other_than: 0, message: 'は無効な値です。' } 17 | 18 | def self.from_omniauth(auth) 19 | where(provider: auth.provider, uid: auth.uid).first_or_create do |user| 20 | user.email = auth.info.email 21 | user.password = Devise.friendly_token[0, 20] 22 | end 23 | end 24 | 25 | def self.create_unique_string 26 | SecureRandom.uuid 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /config/webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const glob = require('glob') 3 | const { VueLoaderPlugin } = require("vue-loader") 4 | const webpack = require("webpack") 5 | 6 | const entries = {}; 7 | glob.sync('./app/javascript/*.js').forEach((file) => { 8 | const name = file.replace('./app/javascript/', '').split('.')[0]; 9 | entries[name] = file; 10 | }); 11 | 12 | module.exports = { 13 | mode: "production", 14 | devtool: "source-map", 15 | entry: entries, 16 | output: { 17 | filename: "[name].js", 18 | sourceMapFilename: "[file].map", 19 | path: path.resolve(__dirname, "..", "..", "app/assets/builds"), 20 | }, 21 | plugins: [ 22 | new VueLoaderPlugin(), 23 | new webpack.optimize.LimitChunkCountPlugin({ 24 | maxChunks: 1 25 | }) 26 | ], 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.(js)$/, 31 | exclude: /node_modules/, 32 | use: ["babel-loader"] 33 | }, 34 | { 35 | test: /\.vue$/, 36 | loader: "vue-loader" 37 | }, 38 | ], 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /spec/system/list_details_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'ListDetails', type: :system do 6 | describe '#destroy' do 7 | context '削除ボタンをクリックした場合' do 8 | before do 9 | list_detail = create(:list_detail_one) 10 | login(list_detail.list.user) 11 | visit users_list_path list_detail.list.id 12 | end 13 | 14 | it '削除の確認ダイアログが表示される' do 15 | find('.button.is-danger', text: '削除する').click 16 | expect(page.accept_confirm).to eq '削除してよろしいですか?' 17 | end 18 | 19 | it '削除しましたと表示される' do 20 | page.accept_confirm do 21 | find('.button.is-danger', text: '削除する').click 22 | end 23 | expect(page.accept_confirm).to eq '削除しました。' 24 | end 25 | 26 | it '削除した書籍が一覧からなくなっている' do 27 | page.accept_confirm do 28 | find('.button.is-danger', text: '削除する').click 29 | page.accept_confirm 30 | end 31 | expect(page).not_to have_content 'プロを目指す人のためのRuby入門' 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | root 'home#index' 5 | 6 | get 'tos', to: 'welcome#tos', as: 'tos' 7 | get 'privacy_policy', to: 'welcome#privacy_policy', as: 'privacy_policy' 8 | 9 | devise_for :users, controllers: { 10 | omniauth_callbacks: 'users/omniauth_callbacks', 11 | registrations: 'users/registrations' 12 | } 13 | 14 | namespace :api, defaults: { format: :json } do 15 | namespace :users do 16 | resources :lists, only: %i[show create] 17 | end 18 | namespace :list_details do 19 | resources :id, only: %i[index], controller: 'id' 20 | end 21 | resources :books, only: %i[create] 22 | resources :list_details, only: %i[create destroy] 23 | end 24 | 25 | namespace :users do 26 | resources :lists, only: %i[show] 27 | end 28 | 29 | resources :books, only: %i[index] 30 | 31 | # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html 32 | mount LetterOpenerWeb::Engine, at: '/letter_opener' if Rails.env.development? 33 | end 34 | -------------------------------------------------------------------------------- /app/views/devise/shared/_links.html.slim: -------------------------------------------------------------------------------- 1 | - if devise_mapping.omniauthable? 2 | - resource_class.omniauth_providers.each do |provider| 3 | = button_to omniauth_authorize_path(resource_name, provider), method: :post, class: 'button max mt-4' 4 | img.icon src='/g-logo.png' 5 | - if controller_name == 'registrations' 6 | span Googleアカウントで登録 7 | - else 8 | spen Googleアカウントでログイン 9 | 10 | .mt-5 11 | - if controller_name != 'sessions' 12 | p.mb-2 = link_to t('.sign_in'), new_session_path(resource_name) 13 | 14 | - if devise_mapping.registerable? && controller_name != 'registrations' 15 | p.mb-2 = link_to t('.sign_up'), new_registration_path(resource_name) 16 | 17 | - if devise_mapping.confirmable? && controller_name != 'confirmations' 18 | p.mb-2 = link_to t('.didn_t_receive_confirmation_instructions'), new_confirmation_path(resource_name) 19 | 20 | - if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' 21 | p.mb-2 = link_to t('.didn_t_receive_unlock_instructions'), new_unlock_path(resource_name) 22 | -------------------------------------------------------------------------------- /app/views/sale_mailer/sale_email.text.slim: -------------------------------------------------------------------------------- 1 | | こんにちは!! 2 | br 3 | | 登録されている書籍のセール情報をお届けします。 4 | 5 | | 【書籍タイトル】 6 | br 7 | = @book.title 8 | br 9 | | 【著者】 10 | br 11 | = @book.author 12 | br 13 | | 【定価】 14 | br 15 | = format_price(@book.price) 16 | 17 | br 18 | br 19 | 20 | - if @sale_data[:amazon].present? 21 | | 【Amazon】 22 | br 23 | = format_price(@sale_data[:amazon][:price]) 24 | br 25 | = @sale_data[:amazon][:url] 26 | br 27 | - if @sale_data[:dmm].present? 28 | | 【DMMブックス】 29 | br 30 | = format_price(@sale_data[:dmm][:price]) 31 | br 32 | = @sale_data[:dmm][:url] 33 | br 34 | - if @sale_data[:rakuten].present? 35 | | 【楽天ブックス】 36 | br 37 | = format_price(@sale_data[:rakuten][:price]) 38 | br 39 | = @sale_data[:rakuten][:url] 40 | br 41 | - if @sale_data[:seshop].present? 42 | | 【SEshop】 43 | br 44 | = format_price(@sale_data[:seshop][:price]) 45 | br 46 | = @sale_data[:seshop][:url] 47 | br 48 | 49 | br 50 | 51 | | 書籍を購入したら、 52 | = link_to 'こちら', users_list_url(@user.list) 53 | | から削除すると通知が届かなくなります。 54 | 55 | br 56 | br 57 | 58 | | ご不明点、お問い合わせは下記メールアドレス宛にお送りください。 59 | br 60 | | support@serurepo.com 61 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.1.2 2 | 3 | RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ 4 | && echo 'deb http://dl.yarnpkg.com/debian/ stable main' > /etc/apt/sources.list.d/yarn.list 5 | RUN apt-get update && apt-get install -y --no-install-recommends \ 6 | fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcups2 \ 7 | libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 libxcomposite1 libxdamage1 libxfixes3 xdg-utils \ 8 | nodejs yarn \ 9 | postgresql-client \ 10 | build-essential \ 11 | fonts-ipafont-gothic vim \ 12 | && apt-get clean \ 13 | && rm -rf /var/lib/apt/lists/* 14 | RUN curl -O https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \ 15 | && dpkg -i google-chrome-stable_current_amd64.deb 16 | RUN wget https://chromedriver.storage.googleapis.com/102.0.5005.61/chromedriver_linux64.zip \ 17 | && unzip chromedriver_linux64.zip \ 18 | && mv chromedriver /usr/local/bin/ \ 19 | && chmod 755 /usr/local/bin/chromedriver 20 | 21 | WORKDIR /serurepo 22 | 23 | COPY . /serurepo 24 | 25 | RUN gem install bundler 26 | RUN bundle install 27 | RUN yarn install 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | <<<<<<< HEAD 11 | # Ignore the default SQLite database. 12 | /db/*.sqlite3 13 | /db/*.sqlite3-* 14 | 15 | ======= 16 | >>>>>>> 2ab16a5 (initial commit) 17 | # Ignore all logfiles and tempfiles. 18 | /log/* 19 | /tmp/* 20 | !/log/.keep 21 | !/tmp/.keep 22 | 23 | # Ignore pidfiles, but keep the directory. 24 | /tmp/pids/* 25 | !/tmp/pids/ 26 | !/tmp/pids/.keep 27 | 28 | # Ignore uploaded files in development. 29 | /storage/* 30 | !/storage/.keep 31 | 32 | /public/assets 33 | .byebug_history 34 | 35 | # Ignore master key for decrypting credentials and more. 36 | /config/master.key 37 | 38 | /public/packs 39 | /public/packs-test 40 | /node_modules 41 | /yarn-error.log 42 | yarn-debug.log* 43 | .yarn-integrity 44 | 45 | /.idea 46 | 47 | .env 48 | 49 | /coverage 50 | 51 | /app/assets/builds/* 52 | !/app/assets/builds/.keep 53 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | require: 4 | - rubocop-rails 5 | - rubocop-rspec 6 | 7 | AllCops: 8 | TargetRubyVersion: 3.1 9 | NewCops: enable 10 | Exclude: 11 | - config/**/* 12 | - node_modules/**/* 13 | - db/migrate/* 14 | - db/schema.rb 15 | - vendor/**/* 16 | - bin/**/* 17 | - app/views/**/* 18 | 19 | Style/Documentation: 20 | Enabled: false 21 | 22 | Style/IfUnlessModifier: 23 | Enabled: false 24 | 25 | Style/ClassAndModuleChildren: 26 | Enabled: false 27 | 28 | Metrics/BlockLength: 29 | Exclude: 30 | - spec/**/* 31 | 32 | Metrics/ClassLength: 33 | Exclude: 34 | - spec/**/* 35 | 36 | Metrics/MethodLength: 37 | CountComments: false 38 | Max: 20 39 | 40 | Metrics/AbcSize: 41 | Max: 20 42 | 43 | ContextWording: 44 | Exclude: 45 | - spec/**/* 46 | 47 | NestedGroups: 48 | Exclude: 49 | - spec/**/* 50 | 51 | Rails/I18nLocaleTexts: 52 | Enabled: false 53 | 54 | Rails/RedundantPresenceValidationOnBelongsTo: 55 | Enabled: false 56 | 57 | RSpec/NamedSubject: 58 | Exclude: 59 | - spec/**/* 60 | 61 | RSpec/ExampleLength: 62 | Exclude: 63 | - spec/**/* 64 | 65 | RSpec/MultipleExpectations: 66 | Exclude: 67 | - spec/system/* 68 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'fileutils' 5 | 6 | # path to your application root. 7 | APP_ROOT = File.expand_path('..', __dir__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | FileUtils.chdir APP_ROOT do 14 | # This script is a way to set up or update your development environment automatically. 15 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 16 | # Add necessary setup steps to this file. 17 | 18 | puts '== Installing dependencies ==' 19 | system! 'gem install bundler --conservative' 20 | system('bundle check') || system!('bundle install') 21 | 22 | # Install JavaScript dependencies 23 | system! 'bin/yarn' 24 | 25 | # puts "\n== Copying sample files ==" 26 | # unless File.exist?('config/database.yml') 27 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 28 | # end 29 | 30 | puts "\n== Preparing database ==" 31 | system! 'bin/rails db:prepare' 32 | 33 | puts "\n== Removing old logs and tempfiles ==" 34 | system! 'bin/rails log:clear tmp:clear' 35 | 36 | puts "\n== Restarting application server ==" 37 | system! 'bin/rails restart' 38 | end 39 | -------------------------------------------------------------------------------- /app/controllers/api/list_details_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class API::ListDetailsController < API::BaseController 4 | def create 5 | list_detail = params['list_detail'] 6 | new_detail = ListDetail.create_with(list_detail_params) 7 | .find_or_initialize_by(list_id: list_detail['list_id'], book_id: list_detail['book_id']) 8 | 9 | if new_detail.new_record? 10 | if new_detail.save 11 | render status: :created, 12 | json: { message: 'リストに追加しました!' } 13 | else 14 | render status: :unprocessable_entity, 15 | json: { errorMessage: new_detail.errors.full_messages } 16 | end 17 | else 18 | render status: :bad_request, json: { errorMessage: 'すでに登録済みです。' } 19 | end 20 | end 21 | 22 | def destroy 23 | list_detail = ListDetail.find(params[:id]) 24 | 25 | if list_detail.destroy 26 | list_detail.book.not_in_list_details_destroy! 27 | 28 | render status: :ok, 29 | json: { successMessage: '削除しました。' } 30 | else 31 | render status: :unprocessable_entity, 32 | json: { errorMessage: '削除できませんでした。' } 33 | end 34 | end 35 | 36 | private 37 | 38 | def list_detail_params 39 | params.require(:list_detail).permit(:list_id, :book_id) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/models/list_detail_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe ListDetail, type: :model do 6 | context '全ての想定の値の場合' do 7 | it '登録成功' do 8 | detail = create(:list_detail_one) 9 | expect(detail).to be_valid 10 | end 11 | end 12 | 13 | describe 'list_id' do 14 | context '空の場合' do 15 | it '登録失敗' do 16 | detail = build(:list_detail_one, list_id: '') 17 | detail.valid? 18 | expect(detail.errors[:list_id]).to include('を入力してください') 19 | end 20 | end 21 | 22 | context '文字列の場合' do 23 | it '登録失敗' do 24 | detail = build(:list_detail_one, list_id: 'テスト') 25 | detail.valid? 26 | expect(detail.errors[:list_id]).to include('は数値で入力してください') 27 | end 28 | end 29 | end 30 | 31 | describe 'book_id' do 32 | context '空の場合' do 33 | it '登録失敗' do 34 | detail = build(:list_detail_one, book_id: '') 35 | detail.valid? 36 | expect(detail.errors[:book_id]).to include('を入力してください') 37 | end 38 | end 39 | 40 | context '文字列の場合' do 41 | it '登録失敗' do 42 | detail = build(:list_detail_one, book_id: 'テスト') 43 | detail.valid? 44 | expect(detail.errors[:book_id]).to include('は数値で入力してください') 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/crawler/crawler.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'capybara' 5 | require 'capybara/dsl' 6 | require 'selenium-webdriver' 7 | 8 | Capybara.register_driver :selenium do |app| 9 | caps = Selenium::WebDriver::Remote::Capabilities.chrome( 10 | 'goog:chromeOptions' => { args: %w[headless 11 | disable-gpu 12 | no-sandbox 13 | disable-dev-shm-usage 14 | remote-debugging-port=9222 15 | window-size=1280,800] } 16 | ) 17 | Capybara::Selenium::Driver.new(app, 18 | browser: :chrome, 19 | capabilities: caps, 20 | timeout: 600) 21 | end 22 | Capybara.javascript_driver = :selenium 23 | Capybara.default_driver = :selenium 24 | 25 | class Crawler 26 | def start_scraping(url, &) 27 | Capybara::Session.new(:selenium).tap do |session| 28 | session.visit url 29 | session.instance_eval(&) 30 | rescue StandardError => e 31 | logger = Logger.new('log/crawler.log') 32 | logger << "scraping_error: #{session.inspect} #{e.message}\n" 33 | ensure 34 | session.quit 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/mail_sender.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MailSender 4 | def self.sale_report(compared_data) 5 | compared_data.each do |data| 6 | next if all_data_nil?(data) 7 | 8 | book = Book.find(data[:book_id]) 9 | book.lists.each do |list| 10 | user = list.user 11 | discount_rating = get_rating(user) 12 | discounted_price = (book.price * discount_rating).truncate 13 | 14 | data.each do |shop, detail| 15 | next if shop == :book_id 16 | 17 | data[shop] = nil if higher_than_discounted_price?(detail, discounted_price) 18 | end 19 | next if all_data_nil?(data) 20 | 21 | SaleMailer.with(user:, sale_data: data).sale_email.deliver_now 22 | end 23 | end 24 | end 25 | 26 | def self.all_data_nil?(data) 27 | data[:amazon].nil? && data[:dmm].nil? && data[:rakuten].nil? && data[:seshop].nil? 28 | end 29 | 30 | def self.get_rating(user) 31 | case user.discount_rating.to_a 32 | when [:over10] 33 | 0.9 34 | when [:over20] 35 | 0.8 36 | when [:over30] 37 | 0.7 38 | when [:over50] 39 | 0.5 40 | else 41 | 1 42 | end 43 | end 44 | 45 | def self.higher_than_discounted_price?(price_data, discounted_price) 46 | price_data.present? && (price_data[:price] >= discounted_price) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/crawler/dmm_crawler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'crawler' 4 | 5 | class DmmCrawler < Crawler 6 | attr_reader :price, :book_url 7 | 8 | def initialize 9 | super 10 | @url = 'https://book.dmm.com/search/?service=ebook&searchstr=' 11 | @price = '' 12 | @book_url = '' 13 | @logger = Logger.new('log/crawler.log') 14 | end 15 | 16 | def run(book_title) # rubocop:disable Metrics/MethodLength 17 | url = @url + book_title 18 | data = [] 19 | begin 20 | @logger << "-- DMM Crawler started at: #{Time.current}\n" 21 | start_scraping url do 22 | next unless all('p', text: '一致する商品は見つかりませんでした。').size.zero? 23 | 24 | find('#fn-list').first('a').click 25 | 26 | price = find('.m-boxSubDetailPurchase__price') 27 | .text 28 | .split[1] 29 | .delete(',円') 30 | .to_i 31 | 32 | data << price 33 | data << current_url 34 | end 35 | rescue StandardError => e 36 | @logger << "#{'*' * 30}\n" 37 | @logger << "*****Error caused DMM Crawler*****\n" 38 | @logger << "【Error Message title: #{book_title}】#{e.message}\n" 39 | @logger << "#{'*' * 30}\n" 40 | end 41 | 42 | @logger << "【DMM title: #{book_title}】#{data}\n" 43 | @logger << "-- DMM Crawler ended.\n" 44 | 45 | @price, @book_url = data 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /app/javascript/users_lists_books.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 59 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Define an application-wide content security policy 5 | # For further information see the following documentation 6 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 7 | 8 | # Rails.application.config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # If you are using webpack-dev-server then specify webpack-dev-server host 16 | # policy.connect_src :self, :https, "http://localhost:3035", "ws://localhost:3035" if Rails.env.development? 17 | 18 | # # Specify URI for violation reports 19 | # # policy.report_uri "/csp-violation-report-endpoint" 20 | # end 21 | 22 | # If you are using UJS then enable automatic nonce generation 23 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 24 | 25 | # Set the nonce only to specific directives 26 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 27 | 28 | # Report CSP violations to a specified URI 29 | # For further information see the following documentation: 30 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 31 | # Rails.application.config.content_security_policy_report_only = true 32 | -------------------------------------------------------------------------------- /db/migrate/20210808071330_devise_create_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DeviseCreateUsers < ActiveRecord::Migration[6.1] 4 | def change 5 | create_table :users do |t| 6 | ## Database authenticatable 7 | t.string :email, null: false, default: "" 8 | t.string :encrypted_password, null: false, default: "" 9 | 10 | ## Recoverable 11 | t.string :reset_password_token 12 | t.datetime :reset_password_sent_at 13 | 14 | ## Rememberable 15 | t.datetime :remember_created_at 16 | 17 | ## Trackable 18 | t.integer :sign_in_count, default: 0, null: false 19 | t.datetime :current_sign_in_at 20 | t.datetime :last_sign_in_at 21 | t.string :current_sign_in_ip 22 | t.string :last_sign_in_ip 23 | 24 | ## Confirmable 25 | t.string :confirmation_token 26 | t.datetime :confirmed_at 27 | t.datetime :confirmation_sent_at 28 | t.string :unconfirmed_email # Only if using reconfirmable 29 | 30 | ## Lockable 31 | # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts 32 | # t.string :unlock_token # Only if unlock strategy is :email or :both 33 | # t.datetime :locked_at 34 | 35 | 36 | t.timestamps null: false 37 | end 38 | 39 | add_index :users, :email, unique: true 40 | add_index :users, :reset_password_token, unique: true 41 | add_index :users, :confirmation_token, unique: true 42 | # add_index :users, :unlock_token, unique: true 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/system/seshop_crawler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | require_relative '../../lib/crawler/seshop_crawler' 5 | 6 | RSpec.describe 'SeshopCrawler', type: :system do 7 | describe 'run' do 8 | let(:crawler) { SeshopCrawler.new } 9 | 10 | context '紙版、PDF版ともにある書籍の場合' do 11 | let(:book) { build(:dokusyu_js) } 12 | 13 | it 'それぞれの金額が数値、URLもあること' do 14 | crawler.run(book.isbn13) 15 | expect(crawler.paper_price).to be_integer 16 | expect(crawler.pdf_price).to be_integer 17 | expect(crawler.paper_url).to include('https://www.seshop.com/product/detail/') 18 | expect(crawler.pdf_url).to include('https://www.seshop.com/product/detail/') 19 | end 20 | end 21 | 22 | context '紙版のみの書籍の場合' do 23 | let(:book) { build(:kumikomi_os) } 24 | 25 | it '紙版は金額とURLがあり、pdf版はそれぞれ空文字であること' do 26 | crawler.run(book.isbn13) 27 | expect(crawler.paper_price).to be_integer 28 | expect(crawler.paper_url).to include('https://www.seshop.com/product/detail/') 29 | expect(crawler.pdf_price).to be_blank 30 | expect(crawler.pdf_url).to be_blank 31 | end 32 | end 33 | 34 | context '販売されていない書籍の場合' do 35 | let(:book) { build(:perfect_rails) } 36 | 37 | it 'すべて空文字であること' do 38 | crawler.run(book.isbn13) 39 | expect(crawler.paper_price).to be_blank 40 | expect(crawler.paper_url).to be_blank 41 | expect(crawler.pdf_price).to be_blank 42 | expect(crawler.pdf_url).to be_blank 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /config/initializers/meta_tags.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Use this setup block to configure all options available in MetaTags. 4 | MetaTags.configure do |config| 5 | # How many characters should the title meta tag have at most. Default is 70. 6 | # Set to nil or 0 to remove limits. 7 | # config.title_limit = 70 8 | 9 | # When true, site title will be truncated instead of title. Default is false. 10 | # config.truncate_site_title_first = false 11 | 12 | # Maximum length of the page description. Default is 300. 13 | # Set to nil or 0 to remove limits. 14 | # config.description_limit = 300 15 | 16 | # Maximum length of the keywords meta tag. Default is 255. 17 | # config.keywords_limit = 255 18 | 19 | # Default separator for keywords meta tag (used when an Array passed with 20 | # the list of keywords). Default is ", ". 21 | # config.keywords_separator = ', ' 22 | 23 | # When true, keywords will be converted to lowercase, otherwise they will 24 | # appear on the page as is. Default is true. 25 | # config.keywords_lowercase = true 26 | 27 | # When true, the output will not include new line characters between meta tags. 28 | # Default is false. 29 | # config.minify_output = false 30 | 31 | # When false, generated meta tags will be self-closing () instead 32 | # of open (``). Default is true. 33 | # config.open_meta_tags = true 34 | 35 | # List of additional meta tags that should use "property" attribute instead 36 | # of "name" attribute in tags. 37 | # config.property_tags.push( 38 | # 'x-hearthstone:deck', 39 | # ) 40 | end 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Lint](https://github.com/fugakkbn/serurepo/actions/workflows/lint.yml/badge.svg)](https://github.com/fugakkbn/serurepo/actions/workflows/lint.yml) 2 | [![test](https://github.com/fugakkbn/serurepo/actions/workflows/test.yml/badge.svg)](https://github.com/fugakkbn/serurepo/actions/workflows/test.yml) 3 | [![codecov](https://codecov.io/gh/FUGA0618/serurepo/branch/main/graph/badge.svg?token=XPH2NIQQGP)](https://codecov.io/gh/FUGA0618/serurepo) 4 | 5 | ![ロゴ_横長](https://user-images.githubusercontent.com/58870882/139534536-65598064-7be0-4a2b-975a-3a722f1a8f8a.png) 6 | 7 | # About 8 | 「いつか買おうと思っている書籍」がセールになったときに通知するサービスです。
9 | サイト上で書籍を検索して「通知を受け取る」を押しておくと、その書籍がセールになったときに通知します。
10 | 書籍は何冊でも追加できます。
11 | もう、いろんなサイトでセールをやっていないか確認する必要はないし、書籍のセールを見逃すことはありません。 12 | 13 | # URL 14 | 15 | ``` 16 | https://serurepo.com 17 | ``` 18 | 19 | # Features 20 | - サイト上で書籍名または13桁のISBNから、書籍を検索できます。 21 | - 検索結果から「セール通知を受け取る」をクリックすると、その書籍がセールになった時にメールで通知されます。 22 | - セールとみなす割引率は、設定ページで設定できます。 23 | 24 | # Composition Diagram 25 | ![せるれぽ](https://user-images.githubusercontent.com/58870882/138904938-f4698b13-3405-4c8d-b333-054b9eaef9b6.png) 26 | 27 | # Setup 28 | 29 | ```bash 30 | $ git clone https://github.com/fugakkbn/serurepo.git 31 | ``` 32 | 33 | ```bash 34 | $ cd serurepo 35 | ``` 36 | 37 | ```bash 38 | $ bin/setup 39 | ``` 40 | 41 | ```bash 42 | $ bin/rails s 43 | ``` 44 | 45 | # Seeds 46 | 47 | ```bash 48 | $ bin/rails db:seed 49 | ``` 50 | 51 | # Lint & Tests 52 | 53 | ```shell 54 | $ ./bin/lint 55 | $ bundle exec rspec 56 | ``` 57 | 58 | # Log in 59 | 60 | ``` 61 | Email: test@example.com 62 | PASS: password 63 | ``` 64 | -------------------------------------------------------------------------------- /spec/requests/api/list_details/id_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Api::ListDetails::Id', type: :request do 6 | describe 'GET /index' do 7 | let(:list_detail) { create(:list_detail_one) } 8 | let(:list) { list_detail.list } 9 | let(:user) { list.user } 10 | let(:book) { list_detail.book } 11 | let(:other_book) { build(:fun_ruby) } 12 | 13 | context 'ログイン状態の場合' do 14 | context '登録済みの書籍の場合' do 15 | it '200が返ること' do 16 | sign_in user 17 | get api_list_details_id_index_path({ isbn: book.isbn13 }) 18 | expect(response).to have_http_status(:ok) 19 | end 20 | 21 | it 'リスト詳細IDが返ること' do 22 | sign_in user 23 | get api_list_details_id_index_path({ isbn: book.isbn13 }) 24 | expect(JSON.parse(response.body)['listDetailId']).to eq list_detail.id 25 | end 26 | end 27 | 28 | context '登録済みでない場合' do 29 | it '200が返ること' do 30 | sign_in user 31 | get api_list_details_id_index_path({ isbn: other_book.isbn13 }) 32 | expect(response).to have_http_status(:ok) 33 | end 34 | 35 | it 'リスト詳細IDはnilであること' do 36 | sign_in user 37 | get api_list_details_id_index_path({ isbn: other_book.isbn13 }) 38 | expect(JSON.parse(response.body)['listDetailId']).to be_nil 39 | end 40 | end 41 | end 42 | 43 | context '未ログインの場合' do 44 | it '401が返ること' do 45 | get api_list_details_id_index_path({ isbn: book.isbn13 }) 46 | expect(response).to have_http_status(:unauthorized) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.slim: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title 5 | = full_title(yield(:title)) 6 | meta[name="viewport" content="width=device-width,initial-scale=1"] 7 | = display_meta_tags default_meta_tags 8 | = csrf_meta_tags 9 | = csp_meta_tag 10 | = stylesheet_link_tag 'https://fonts.googleapis.com/icon?family=Material+Icons', media: 'all' 11 | = stylesheet_link_tag 'https://fonts.googleapis.com/css2?family=Kiwi+Maru&display=swap', media: 'all' 12 | = stylesheet_link_tag 'application', media: 'all' 13 | - if Rails.env.production? 14 | = render 'google_analytics' 15 | 16 | body 17 | nav.navbar 18 | .container.is-max-desktop 19 | .navbar-brand 20 | .navbar-item 21 | a href='/' 22 | figure 23 | = image_tag '/logo.png' 24 | - if user_signed_in? 25 | .navbar-item 26 | - if current_user.list.present? 27 | = link_to users_list_path(current_user.list.id), class: 'has-text-info mr-3' 28 | span.material-icons.md-36 29 | | receipt_long 30 | = link_to edit_user_registration_path, class: 'has-text-info' 31 | span.material-icons.md-36 32 | | manage_accounts 33 | 34 | - if flash.present? 35 | - flash.each do |key, value| 36 | - case key 37 | - when 'alert' 38 | .notification.is-danger.is-light 39 | = tag.div(value, class: 'has-text-centered') 40 | - when 'notice' 41 | .notification.is-success.is-light 42 | = tag.div(value, class: 'has-text-centered') 43 | 44 | = yield 45 | 46 | = render 'footer' 47 | -------------------------------------------------------------------------------- /app/views/books/index.html.slim: -------------------------------------------------------------------------------- 1 | - provide(:title, '検索結果') 2 | section.container.is-max-desktop.py-4 3 | h1.title.is-4.has-text-centered 4 | = yield(:title) 5 | 6 | - if @total_num.zero? 7 | .block 8 | p.has-text-centered 9 | | 該当する書籍がありませんでした。 10 | 11 | - else 12 | .block.is-flex.is-justify-content-space-between 13 | p = "検索結果:#{@total_num}件" 14 | p = "#{@first_num} - #{@last_num}件目" 15 | 16 | #js-searched-books.block(data-searched-books="#{@items.to_json}") 17 | 18 | nav.pagination.is-centered 19 | - if @page_num != 1 20 | = link_to 'Previous', books_path(query: @query, page: @page_num - 1), class: 'pagination-previous' 21 | - if @page_num != @max_page_num 22 | = link_to 'Next', books_path(query: @query, page: @page_num + 1), class: 'pagination-next' 23 | ul.pagination-list 24 | - if @page_num != 1 25 | li 26 | = link_to '1', books_path(query: @query), class: 'pagination-link' 27 | li: span.pagination-ellipsis … 28 | li 29 | = link_to @page_num - 1, books_path(query: @query, page: @page_num - 1), class: 'pagination-link' 30 | li 31 | = link_to @page_num, books_path(query: @query, page: @page_num), class: 'pagination-link is-current' 32 | - if @page_num != @max_page_num 33 | li 34 | = link_to @page_num + 1, books_path(query: @query, page: @page_num + 1), class: 'pagination-link' 35 | li: span.pagination-ellipsis … 36 | li 37 | = link_to @max_page_num, books_path(query: @query, page: @max_page_num), class: 'pagination-link' 38 | 39 | = render 'books/shared/search_form' 40 | 41 | = javascript_include_tag 'searched_books' 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serurepo", 3 | "private": true, 4 | "scripts": { 5 | "eslint": "eslint 'app/javascript/**/*.{js,vue}' --max-warnings=0", 6 | "prettier": "prettier app/javascript/**/*.{js,vue} --check", 7 | "prettier-w": "prettier app/javascript/**/*.{js,vue} --write", 8 | "build": "webpack --config config/webpack/webpack.config.js", 9 | "start": "webpack serve --config config/webpack/webpack.config.js", 10 | "build:css": "sass ./app/assets/stylesheets/application.bulma.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules" 11 | }, 12 | "dependencies": { 13 | "@babel/core": "^7.18.5", 14 | "@babel/preset-env": "^7.18.2", 15 | "@rails/actioncable": "^6.0.0", 16 | "@rails/activestorage": "^6.0.0", 17 | "babel-loader": "^8.2.5", 18 | "bulma": "^0.9.4", 19 | "glob": "^8.0.3", 20 | "sass": "^1.53.0", 21 | "vue": "^3.2.11", 22 | "vue-loader": "^16.5.0", 23 | "webpack": "^5.73.0", 24 | "webpack-cli": "^4.10.0" 25 | }, 26 | "version": "0.1.0", 27 | "babel": { 28 | "presets": [ 29 | "@babel/env" 30 | ] 31 | }, 32 | "devDependencies": { 33 | "@babel/plugin-transform-runtime": "^7.18.5", 34 | "@vue/compiler-sfc": "^3.2.11", 35 | "babel-plugin-macros": "^3.1.0", 36 | "eslint": "^7.32.0", 37 | "eslint-config-prettier": "^8.3.0", 38 | "eslint-config-standard": "^16.0.3", 39 | "eslint-plugin-import": "^2.24.2", 40 | "eslint-plugin-node": "^11.1.0", 41 | "eslint-plugin-promise": "^5.1.0", 42 | "eslint-plugin-standard": "^5.0.0", 43 | "eslint-plugin-vue": "^7.17.0", 44 | "prettier": "^2.4.0", 45 | "prettier-config-standard": "^4.0.0", 46 | "webpack-dev-server": "^3.11.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /db/fixtures/list_details.yml: -------------------------------------------------------------------------------- 1 | list_detail1: 2 | list: list1 3 | book: cherry 4 | 5 | list_detai2: 6 | list: list1 7 | book: fun_ruby 8 | 9 | list_detail3: 10 | list: list1 11 | book: perfect_rails 12 | 13 | list_detail4: 14 | list: list1 15 | book: cho_nyumon 16 | 17 | list_detail5: 18 | list: list1 19 | book: genba_rails 20 | 21 | list_detail6: 22 | list: list2 23 | book: cherry 24 | 25 | list_detai7: 26 | list: list2 27 | book: fun_ruby 28 | 29 | list_detail8: 30 | list: list2 31 | book: perfect_rails 32 | 33 | list_detail9: 34 | list: list2 35 | book: cho_nyumon 36 | 37 | list_detail10: 38 | list: list2 39 | book: genba_rails 40 | 41 | list_detail11: 42 | list: list3 43 | book: cherry 44 | 45 | list_detai12: 46 | list: list3 47 | book: fun_ruby 48 | 49 | list_detail13: 50 | list: list3 51 | book: perfect_rails 52 | 53 | list_detail14: 54 | list: list3 55 | book: cho_nyumon 56 | 57 | list_detail15: 58 | list: list3 59 | book: genba_rails 60 | 61 | list_detail16: 62 | list: list4 63 | book: cherry 64 | 65 | list_detai17: 66 | list: list4 67 | book: fun_ruby 68 | 69 | list_detail18: 70 | list: list4 71 | book: perfect_rails 72 | 73 | list_detail19: 74 | list: list4 75 | book: cho_nyumon 76 | 77 | list_detail20: 78 | list: list4 79 | book: genba_rails 80 | 81 | list_detail21: 82 | list: list5 83 | book: cherry 84 | 85 | list_detai22: 86 | list: list5 87 | book: fun_ruby 88 | 89 | list_detail23: 90 | list: list5 91 | book: perfect_rails 92 | 93 | list_detail24: 94 | list: list5 95 | book: cho_nyumon 96 | 97 | list_detail25: 98 | list: list5 99 | book: genba_rails 100 | -------------------------------------------------------------------------------- /lib/crawler/rakuten_crawler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'crawler' 4 | 5 | class RakutenCrawler < Crawler 6 | attr_reader :single_price, :e_book_price, :paper_book_price, :book_url 7 | 8 | def initialize 9 | super 10 | @url = 'https://books.rakuten.co.jp/' 11 | @single_price = '' 12 | @e_book_price = '' 13 | @paper_book_price = '' 14 | @logger = Logger.new('log/crawler.log') 15 | end 16 | 17 | def run(isbn) # rubocop:disable Metrics/MethodLength 18 | data = [] 19 | begin 20 | @logger << "-- Rakuten Crawler started at: #{Time.current}\n" 21 | start_scraping @url do 22 | fill_in id: 'searchWords', with: isbn 23 | find_by_id('searchBtn').click 24 | 25 | first('.rbcomp__item-list__item__details__lead').first('a').click 26 | 27 | sleep 5 28 | 29 | price = find('.productPrice span.price').text.delete(',円').to_i 30 | data << price 31 | 32 | price_list_dom = all('.linkOtherFormat ul .linkOtherFormat__list') 33 | 34 | next if price_list_dom.size.zero? 35 | 36 | e_book_price = price_list_dom[0].text.split[-1].delete(',円').to_i 37 | data << e_book_price 38 | 39 | paper_book_price = price_list_dom[1].text.split[-1].delete(',円').to_i 40 | data << paper_book_price 41 | end 42 | rescue StandardError => e 43 | @logger << "#{'*' * 30}\n" 44 | @logger << "*****Error caused Rakuten Crawler*****\n" 45 | @logger << "【Error Message isbn: #{isbn}】#{e.message}\n" 46 | @logger << "#{'*' * 30}\n" 47 | end 48 | 49 | @logger << "【Rakuten isbn: #{isbn}】#{data}\n" 50 | @logger << "-- Rakuten Crawler ended.\n" 51 | 52 | @single_price, @e_book_price, @paper_book_price = data 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/views/sale_mailer/sale_email.html.slim: -------------------------------------------------------------------------------- 1 | .section.is-medium 2 | p.block 3 | | こんにちは!! 4 | br 5 | | 登録されている書籍のセール情報をお届けします。 6 | 7 | article.media.block 8 | figure.media-left.image.is-240x240 9 | = image_tag @book.image 10 | 11 | .media-content 12 | .field 13 | p.title.is-6 14 | = @book.title 15 | p.is-size-7 16 | = "著者 #{@book.author}" 17 | br 18 | = "定価 #{format_price(@book.price)}" 19 | 20 | .columns 21 | .column.is-narrow 22 | - if @sale_data[:amazon].present? 23 | p.block 24 | = link_to "Amazon #{format_price(@sale_data[:amazon][:price])}", 25 | @sale_data[:amazon][:url], 26 | class: 'button is-warning', style: 'width: 300px;' 27 | - if @sale_data[:dmm].present? 28 | p.block 29 | = link_to "DMMブックス #{format_price(@sale_data[:dmm][:price])}", 30 | @sale_data[:dmm][:url], 31 | class: 'button is-link', style: 'width: 300px;' 32 | - if @sale_data[:rakuten].present? 33 | p.block 34 | = link_to "楽天ブックス #{format_price(@sale_data[:rakuten][:price])}", 35 | @sale_data[:rakuten][:url], 36 | class: 'button is-primary', style: 'width: 300px;' 37 | - if @sale_data[:seshop].present? 38 | p.block 39 | = link_to "SEshop #{format_price(@sale_data[:seshop][:price])}", 40 | @sale_data[:seshop][:url], 41 | class: 'button is-success', style: 'width: 300px;' 42 | 43 | p.block 44 | | 書籍を購入したら、 45 | = link_to 'こちら', users_list_url(@user.list.id) 46 | | から削除すると通知が届かなくなります。 47 | 48 | p.block 49 | | ご不明点、お問い合わせは下記メールアドレス宛にお送りください。 50 | br 51 | | support@serurepo.com 52 | -------------------------------------------------------------------------------- /lib/crawler/amazon_crawler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'crawler' 4 | 5 | class AmazonCrawler < Crawler 6 | attr_reader :kindle_price, :paper_price, :asin 7 | 8 | def initialize 9 | super 10 | @url = 'https://www.amazon.co.jp/' 11 | @kindle_price = '' 12 | @paper_price = '' 13 | @asin = '' 14 | @logger = Logger.new('log/crawler.log') 15 | end 16 | 17 | def run(isbn) # rubocop:disable Metrics/MethodLength 18 | data = [] 19 | begin 20 | @logger << "-- Amazon Crawler started at: #{Time.current}\n" 21 | start_scraping @url do 22 | fill_in 'twotabsearchtextbox', with: isbn 23 | find('#nav-search-submit-button').click 24 | within 'h2.a-size-mini.a-spacing-none.a-color-base' do 25 | find('.a-link-normal.a-text-normal').click 26 | end 27 | 28 | switch_to_window(windows.last) 29 | 30 | ver_list_dom = all('.a-unordered-list.a-nostyle.a-button-list.a-horizontal li') 31 | 32 | kindle_price = ver_list_dom[0].text.split("\n")[1].delete('¥,').to_i 33 | data << kindle_price 34 | 35 | paper_price = ver_list_dom[-1].text.split("\n")[1].delete('¥,').to_i 36 | data << paper_price 37 | 38 | ver_list_dom[0].click 39 | 40 | split_url = current_url.split('/') 41 | before_target_index = split_url.index('dp') 42 | data << split_url[before_target_index.next] 43 | end 44 | rescue StandardError => e 45 | @logger << "#{'*' * 30}\n" 46 | @logger << "*****Error caused Amazon Crawler*****\n" 47 | @logger << "【Error Message isbn: #{isbn}】#{e.message}\n" 48 | @logger << "#{'*' * 30}\n" 49 | end 50 | 51 | @logger << "【Amazon isbn: #{isbn}】#{data}\n" 52 | @logger << "-- Amazon Crawler ended.\n" 53 | 54 | @kindle_price, @paper_price, @asin = data 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/system/users_lists_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'users/lists', type: :system do 6 | describe '#create' do 7 | context 'ログイン状態で通知を受け取るボタンを押した場合' do 8 | it '登録に成功してボタンが非活性になっている' do 9 | visit_with_auth root_path, :alice 10 | fill_in '検索ワード or ISBN', with: '9784297124373' 11 | click_button '検索する' 12 | accept_alert do 13 | click_button 'セール通知を受け取る' 14 | end 15 | expect(page).to have_button 'リスト登録済み', disabled: true 16 | end 17 | end 18 | end 19 | 20 | describe '#show' do 21 | context '未ログイン状態でアクセスした場合' do 22 | it 'フラッシュメッセージが表示される' do 23 | visit 'users/lists/3' 24 | expect(page).to have_content 'ログインもしくはアカウント登録してください。' 25 | end 26 | end 27 | 28 | context '自分のリストにアクセスした場合' do 29 | it 'アクセスできる' do 30 | list = create(:list) 31 | login(list.user) 32 | visit users_list_path list.id 33 | expect(page).to have_selector 'h1', text: 'セール通知リスト' 34 | end 35 | 36 | it '登録した書籍が表示される' do 37 | list_detail = create(:list_detail_one) 38 | login(list_detail.list.user) 39 | visit users_list_path list_detail.list.id 40 | expect(page).to have_content 'プロを目指す人のためのRuby入門' 41 | end 42 | 43 | it '削除ボタンが表示される' do 44 | list_detail = create(:list_detail_one) 45 | login(list_detail.list.user) 46 | visit users_list_path list_detail.list.id 47 | expect(page).to have_selector 'a.button.is-danger', text: '削除する' 48 | end 49 | end 50 | 51 | context '自分のリスト以外にアクセスした場合' do 52 | it 'rootにリダイレクトしてフラッシュメッセージが表示される' do 53 | list = create(:list) 54 | login(list.user) 55 | visit users_list_path list.id + 1 56 | expect(page).to have_content 'URLが不正です。' 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /db/fixtures/books.yml: -------------------------------------------------------------------------------- 1 | cherry: 2 | title: プロを目指す人のためのRuby入門[改訂2版] 言語仕様からテスト駆動開発・デバッグ技法まで 3 | author: 伊藤淳一 4 | image: https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/4373/9784297124373_1_5.jpg?_ex=120x120 5 | url: https://books.rakuten.co.jp/rb/16908719/ 6 | sales_date: 2017年12月 7 | isbn13: 9784297124373 8 | price: 3278 9 | 10 | fun_ruby: 11 | title: たのしいRuby 第6版 12 | author: 高橋 征義/後藤 裕蔵 13 | image: https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/9844/9784797399844.jpg?_ex=120x120 14 | url: https://hb.afl.rakuten.co.jp/hgc/g00q0727.m0lf90b6.g00q0727.m0lfa419/?pc=https%3A%2F%2Fbooks.rakuten.co.jp%2Frb%2F15822269%2F 15 | sales_date: 2019年03月20日頃 16 | isbn13: 9784797399844 17 | price: 2860 18 | 19 | perfect_rails: 20 | title: パーフェクト Ruby on Rails 【増補改訂版】 21 | author: すがわらまさのり/前島真一/橋立友宏/五十嵐邦明 22 | image: https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/4626/9784297114626.jpg?_ex=120x120 23 | url: https://hb.afl.rakuten.co.jp/hgc/g00q0727.m0lf90b6.g00q0727.m0lfa419/?pc=https%3A%2F%2Fbooks.rakuten.co.jp%2Frb%2F16352336%2F 24 | sales_date: 2020年07月25日頃 25 | isbn13: 9784297114626 26 | price: 3828 27 | 28 | cho_nyumon: 29 | title: ゼロからわかるRuby超入門 30 | author: 五十嵐邦明/松岡浩平 31 | image: https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/1237/9784297101237.jpg?_ex=120x120 32 | url: https://hb.afl.rakuten.co.jp/hgc/g00q0727.m0lf90b6.g00q0727.m0lfa419/?pc=https%3A%2F%2Fbooks.rakuten.co.jp%2Frb%2F15664673%2F 33 | sales_date: 2018年12月 34 | isbn13: 9784297101237 35 | price: 2728 36 | 37 | genba_rails: 38 | title: 現場で使える Ruby on Rails 5速習実践ガイド 39 | author: 大場寧子/松本拓也 40 | image: https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/2227/9784839962227.jpg?_ex=120x120 41 | url: https://hb.afl.rakuten.co.jp/hgc/g00q0727.m0lf90b6.g00q0727.m0lfa419/?pc=https%3A%2F%2Fbooks.rakuten.co.jp%2Frb%2F15628625%2F 42 | sales_date: 2018年10月19日頃 43 | isbn13: 9784839962227 44 | price: 3828 45 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | var validEnv = ['development', 'test', 'production'] 3 | var currentEnv = api.env() 4 | var isDevelopmentEnv = api.env('development') 5 | var isProductionEnv = api.env('production') 6 | var isTestEnv = api.env('test') 7 | 8 | if (!validEnv.includes(currentEnv)) { 9 | throw new Error( 10 | 'Please specify a valid `NODE_ENV` or ' + 11 | '`BABEL_ENV` environment variables. Valid values are "development", ' + 12 | '"test", and "production". Instead, received: ' + 13 | JSON.stringify(currentEnv) + 14 | '.' 15 | ) 16 | } 17 | 18 | return { 19 | presets: [ 20 | isTestEnv && [ 21 | '@babel/preset-env', 22 | { 23 | targets: { 24 | node: 'current' 25 | } 26 | } 27 | ], 28 | (isProductionEnv || isDevelopmentEnv) && [ 29 | '@babel/preset-env', 30 | { 31 | forceAllTransforms: true, 32 | useBuiltIns: 'entry', 33 | corejs: 3, 34 | modules: false, 35 | exclude: ['transform-typeof-symbol'] 36 | } 37 | ] 38 | ].filter(Boolean), 39 | plugins: [ 40 | 'babel-plugin-macros', 41 | '@babel/plugin-syntax-dynamic-import', 42 | isTestEnv && 'babel-plugin-dynamic-import-node', 43 | '@babel/plugin-transform-destructuring', 44 | [ 45 | '@babel/plugin-proposal-class-properties', 46 | { 47 | loose: true 48 | } 49 | ], 50 | [ 51 | '@babel/plugin-proposal-object-rest-spread', 52 | { 53 | useBuiltIns: true 54 | } 55 | ], 56 | [ 57 | '@babel/plugin-transform-runtime', 58 | { 59 | helpers: false 60 | } 61 | ], 62 | [ 63 | '@babel/plugin-transform-regenerator', 64 | { 65 | async: false 66 | } 67 | ] 68 | ].filter(Boolean) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/factories/users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :alice, class: 'User' do 5 | email { 'alice@example.com' } 6 | password { 'password' } 7 | password_confirmation { 'password' } 8 | confirmed_at { Time.current } 9 | uid { SecureRandom.uuid } 10 | end 11 | 12 | factory :google_oauth, class: 'User' do 13 | email { 'google@example.com' } 14 | password { 'password' } 15 | password_confirmation { 'password' } 16 | confirmed_at { Time.current } 17 | uid { SecureRandom.uuid } 18 | provider { 'google_oauth2' } 19 | end 20 | 21 | factory :rating_even, class: 'User' do 22 | email { 'even@example.com' } 23 | password { 'password' } 24 | password_confirmation { 'password' } 25 | confirmed_at { Time.current } 26 | discount_rating { :even } 27 | uid { SecureRandom.uuid } 28 | end 29 | 30 | factory :rating_over10, class: 'User' do 31 | email { 'over10@example.com' } 32 | password { 'password' } 33 | password_confirmation { 'password' } 34 | confirmed_at { Time.current } 35 | discount_rating { :over10 } 36 | uid { SecureRandom.uuid } 37 | end 38 | 39 | factory :rating_over20, class: 'User' do 40 | email { 'over20@example.com' } 41 | password { 'password' } 42 | password_confirmation { 'password' } 43 | confirmed_at { Time.current } 44 | discount_rating { :over20 } 45 | uid { SecureRandom.uuid } 46 | end 47 | 48 | factory :rating_over30, class: 'User' do 49 | email { 'over30@example.com' } 50 | password { 'password' } 51 | password_confirmation { 'password' } 52 | confirmed_at { Time.current } 53 | discount_rating { :over30 } 54 | uid { SecureRandom.uuid } 55 | end 56 | 57 | factory :rating_over50, class: 'User' do 58 | email { 'over50@example.com' } 59 | password { 'password' } 60 | password_confirmation { 'password' } 61 | confirmed_at { Time.current } 62 | discount_rating { :over50 } 63 | uid { SecureRandom.uuid } 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/system/application_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'application', type: :system do 6 | describe 'header menu' do 7 | context 'ログインしていない場合' do 8 | before do 9 | visit root_path 10 | end 11 | 12 | it 'ロゴ画像が表示される' do 13 | expect(page).to have_selector "img[src$='logo.png']" 14 | end 15 | 16 | it 'ナビゲーションメニューが表示されない' do 17 | expect(page).not_to have_selector '.navbar_item' 18 | end 19 | end 20 | 21 | context 'ログインしてリスト作成済みの場合' do 22 | before do 23 | list = create(:list) 24 | login(list.user) 25 | visit root_path 26 | end 27 | 28 | it 'ロゴ画像が表示される' do 29 | expect(page).to have_selector "img[src$='logo.png']" 30 | end 31 | 32 | it 'ユーザーアイコンが表示される' do 33 | expect(page).to have_selector '.material-icons.md-36', text: 'manage_accounts' 34 | end 35 | 36 | it 'リストアイコンが表示される' do 37 | expect(page).to have_selector '.material-icons.md-36', text: 'receipt_long' 38 | end 39 | end 40 | 41 | context 'ログインしてリスト未作成の場合' do 42 | before do 43 | visit_with_auth root_path, :alice 44 | end 45 | 46 | it 'ロゴ画像が表示される' do 47 | expect(page).to have_selector "img[src$='logo.png']" 48 | end 49 | 50 | it 'ユーザーアイコンが表示される' do 51 | expect(page).to have_selector '.material-icons.md-36', text: 'manage_accounts' 52 | end 53 | 54 | it 'リストアイコンが表示されない' do 55 | expect(page).not_to have_selector '.material-icons.md-36', text: 'receipt_long' 56 | end 57 | end 58 | end 59 | 60 | describe 'footer menu' do 61 | context 'ログイン済みの場合' do 62 | let(:user) { create(:alice) } 63 | 64 | it 'ログアウトリンクが表示されること' do 65 | login(user) 66 | visit root_path 67 | expect(page).to have_button 'ログアウト' 68 | end 69 | end 70 | 71 | context '未ログインの場合' do 72 | it 'ログアウトリンクが表示されないこと' do 73 | visit root_path 74 | expect(page).not_to have_button 'ログアウト' 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/crawler/seshop_crawler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'crawler' 4 | 5 | class SeshopCrawler < Crawler 6 | attr_reader :pdf_price, :paper_price, :pdf_url, :paper_url 7 | 8 | def initialize 9 | super 10 | @search_url = 'https://www.seshop.com/search?keyword=' 11 | @pdf_price = '' 12 | @paper_price = '' 13 | @paper_url = '' 14 | @logger = Logger.new('log/crawler.log') 15 | end 16 | 17 | def run(isbn) # rubocop:disable Metrics/MethodLength 18 | url = @search_url + isbn 19 | data = [] 20 | begin 21 | @logger << "-- SEShop Crawler started at: #{Time.current}\n" 22 | start_scraping url do 23 | return data << nil if find('.row.list').text == '該当の商品はありません。' 24 | 25 | if find('h1').text.include?('検索結果一覧') 26 | within('.row.list') { first('figure').click } 27 | data << SeshopCrawler.calc_price_and_get_url(self) 28 | 29 | visit url 30 | within('.row.list') { all('figure').last.click } 31 | data << SeshopCrawler.calc_price_and_get_url(self) 32 | else 33 | data << SeshopCrawler.calc_price_and_get_url(self) << nil 34 | end 35 | end 36 | rescue StandardError => e 37 | @logger << "#{'*' * 30}\n" 38 | @logger << "*****Error caused SEShop Crawler*****\n" 39 | @logger << "【Error Message isbn: #{isbn}】#{e.message}\n" 40 | @logger << "#{'*' * 30}\n" 41 | end 42 | 43 | data.flatten! 44 | 45 | @logger << "【SEShop isbn: #{isbn}】#{data}\n" 46 | @logger << "-- SEShop Crawler ended.\n" 47 | 48 | @paper_price, @paper_url, @pdf_price, @pdf_url = data 49 | end 50 | 51 | def self.calc_price_and_get_url(crawler) 52 | discount = crawler.first('p', text: 'ポイント') 53 | discount = discount.text 54 | .match('\d{1,3}(,\d{3})*')[0] 55 | .delete(',') 56 | .to_i 57 | 58 | price = crawler.find('.detail-price') 59 | .text 60 | .delete(',¥') 61 | .to_i 62 | price -= discount 63 | 64 | [price, crawler.current_url] 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Puma can serve each request in a thread from an internal thread pool. 4 | # The `threads` method setting takes two numbers: a minimum and maximum. 5 | # Any libraries that use thread pools should be configured to match 6 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 7 | # and maximum; this matches the default thread size of Active Record. 8 | # 9 | max_threads_count = ENV.fetch('RAILS_MAX_THREADS') { 5 } 10 | min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count } 11 | threads min_threads_count, max_threads_count 12 | 13 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 14 | # terminating a worker in development environments. 15 | # 16 | worker_timeout 3600 if ENV.fetch('RAILS_ENV', 'development') == 'development' 17 | 18 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 19 | # 20 | # port ENV.fetch('PORT', 3000) 21 | 22 | # Specifies the `environment` that Puma will run in. 23 | # 24 | environment ENV.fetch('RAILS_ENV') { 'development' } 25 | 26 | # Specifies the `pidfile` that Puma will use. 27 | pidfile ENV.fetch('PIDFILE') { 'tmp/pids/server.pid' } 28 | 29 | if Rails.env.production? 30 | stdout_redirect '/var/log/puma/puma.stdout.log', '/var/log/puma/puma.stderr.log', true 31 | end 32 | 33 | # Specifies the number of `workers` to boot in clustered mode. 34 | # Workers are forked web server processes. If using threads and workers together 35 | # the concurrency of the application would be max `threads` * `workers`. 36 | # Workers do not work on JRuby or Windows (both of which do not support 37 | # processes). 38 | # 39 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 40 | 41 | # Use the `preload_app!` method when specifying a `workers` number. 42 | # This directive tells Puma to first boot the application and load code 43 | # before forking the application. This takes advantage of Copy On Write 44 | # process behavior so workers use less memory. 45 | # 46 | # preload_app! 47 | 48 | # Allow puma to be restarted by `rails restart` command. 49 | plugin :tmp_restart 50 | 51 | bind "unix://#{Rails.root}/tmp/sockets/puma.sock" if ENV['RAILS_ENV'] == 'production' 52 | -------------------------------------------------------------------------------- /spec/models/rakuten_books_searcher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe RakutenBooksSearcher, type: :model do 6 | describe '#initialize' do 7 | context '13桁のISBNが指定された場合' do 8 | it 'クエリストリングにisbnがあること' do 9 | searcher = described_class.new('9784774193977', 1, 20) 10 | expect(searcher.instance_variable_get(:@url)).to include 'isbn' 11 | end 12 | end 13 | 14 | context 'isbnではない場合' do 15 | it 'クエリストリングにtitleがあること' do 16 | searcher = described_class.new('ruby', 1, 20) 17 | expect(searcher.instance_variable_get(:@url)).to include 'title' 18 | end 19 | end 20 | 21 | context 'ISBNが12桁の場合' do 22 | it 'クエリストリングにtitleがあること' do 23 | searcher = described_class.new('978477419397', 1, 20) 24 | expect(searcher.instance_variable_get(:@url)).to include 'title' 25 | end 26 | end 27 | 28 | context 'ISBNが14桁の場合' do 29 | it 'クエリストリングにtitleがあること' do 30 | searcher = described_class.new('97847741939771', 1, 20) 31 | expect(searcher.instance_variable_get(:@url)).to include 'title' 32 | end 33 | end 34 | end 35 | 36 | describe '#run' do 37 | context '正しいISBNの場合' do 38 | it 'プロを目指す人のためのRuby入門の結果が返ること' do 39 | searcher = described_class.new('9784297124373', 1, 20).run 40 | expect(searcher['Items'][0]['Item']['title']).to eq 'プロを目指す人のためのRuby入門[改訂2版] 言語仕様からテスト駆動開発・デバッグ技法まで' 41 | end 42 | end 43 | 44 | context '正しいタイトルの場合' do 45 | it 'プロを目指す人のためのRuby入門の結果が返ること' do 46 | searcher = described_class.new('プロを目指す人のためのRuby入門', 1, 20).run 47 | expect(searcher['Items'][0]['Item']['title']).to eq 'プロを目指す人のためのRuby入門[改訂2版] 言語仕様からテスト駆動開発・デバッグ技法まで' 48 | end 49 | end 50 | 51 | context '存在しないISBNの場合' do 52 | it 'Items配列が空で返ること' do 53 | searcher = described_class.new('9780123456789', 1, 20).run 54 | expect(searcher['Items']).to eq [] 55 | end 56 | end 57 | 58 | context '存在しないタイトルの場合' do 59 | it 'Items配列が空で返ること' do 60 | # APIのリクエスト制限に引っかかるのでいったん止める 61 | sleep 2 62 | searcher = described_class.new('プロを目指さない人のためのRuby入門', 1, 20).run 63 | expect(searcher['Items']).to eq [] 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /app/javascript/users_lists_book.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 78 | -------------------------------------------------------------------------------- /spec/requests/api/books_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Api::Books', type: :request do 6 | describe 'POST /create' do 7 | let(:book) { attributes_for(:cherry) } 8 | let(:user) { create(:alice) } 9 | 10 | context 'ログイン状態の場合' do 11 | context 'すでに登録済みの書籍の場合' do 12 | it '200が返ること' do 13 | sign_in user 14 | post api_books_path, params: { book: } 15 | post api_books_path, params: { book: } 16 | expect(response).to have_http_status(:ok) 17 | end 18 | 19 | it '登録がされないこと' do 20 | sign_in user 21 | post api_books_path, params: { book: } 22 | post api_books_path, params: { book: } 23 | expect { post api_books_path, params: { book: } }.not_to change(Book, :count) 24 | end 25 | end 26 | 27 | context '登録されていない書籍の場合' do 28 | context '正常な値の場合' do 29 | it '201が返ること' do 30 | sign_in user 31 | post api_books_path, params: { book: } 32 | expect(response).to have_http_status(:created) 33 | end 34 | 35 | it '登録が1件増えること' do 36 | sign_in user 37 | expect { post api_books_path, params: { book: } }.to change(Book, :count).by(1) 38 | end 39 | end 40 | 41 | context '正常でない値がある場合' do 42 | it '422が返ること' do 43 | sign_in user 44 | book['title'] = nil 45 | post api_books_path, params: { book: } 46 | expect(response).to have_http_status(:unprocessable_entity) 47 | end 48 | 49 | it '登録がされないこと' do 50 | sign_in user 51 | book['title'] = nil 52 | expect { post api_books_path, params: { book: } }.not_to change(Book, :count) 53 | end 54 | 55 | it '「登録に失敗しました。」とエラーメッセージが出ること' do 56 | sign_in user 57 | book['title'] = nil 58 | post api_books_path, params: { book: } 59 | expect(JSON.parse(response.body)['errorMessage']).to eq '登録に失敗しました。' 60 | end 61 | end 62 | end 63 | end 64 | 65 | context '未ログインの場合' do 66 | it '401が返ること' do 67 | post api_books_path, params: { book: } 68 | expect(response).to have_http_status(:unauthorized) 69 | end 70 | 71 | it '登録がされないこと' do 72 | expect { post api_books_path, params: { book: } }.not_to change(Book, :count) 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/models/user_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe User, type: :model do 6 | describe '正常系' do 7 | it '登録成功' do 8 | user = build(:alice) 9 | expect(user).to be_valid 10 | end 11 | end 12 | 13 | describe 'email' do 14 | context '空の場合' do 15 | it '登録失敗' do 16 | user = build(:alice, email: '') 17 | user.valid? 18 | expect(user.errors['email']).to include('を入力してください') 19 | end 20 | end 21 | 22 | context '@がない場合' do 23 | it '登録失敗' do 24 | user = build(:alice, email: 'testexample.com') 25 | user.valid? 26 | expect(user.errors['email']).to include('は不正な値です') 27 | end 28 | end 29 | 30 | context '登録済みの場合' do 31 | it '登録失敗' do 32 | create(:alice) 33 | user = build(:alice) 34 | user.valid? 35 | expect(user.errors['email']).to include('はすでに存在します') 36 | end 37 | end 38 | end 39 | 40 | describe 'discount_rating' do 41 | context '空の場合' do 42 | it '登録成功' do 43 | user = build(:alice) 44 | expect(user).to be_valid 45 | end 46 | 47 | it '1に設定される' do 48 | user = create(:alice) 49 | expect(user.discount_rating.raw).to eq 1 50 | end 51 | end 52 | 53 | context '存在しない値で更新した場合' do 54 | it '登録失敗' do 55 | user = create(:alice) 56 | user.update(discount_rating: :over100) 57 | expect(user).not_to be_valid 58 | end 59 | 60 | it 'エラーメッセージが表示される' do 61 | user = create(:alice) 62 | user.update(discount_rating: :over100) 63 | user.valid? 64 | expect(user.errors['discount_rating']).to include('は無効な値です。') 65 | end 66 | end 67 | end 68 | 69 | describe 'provider, uid' do 70 | context '空の場合' do 71 | it '登録成功' do 72 | user = build(:alice, provider: '', uid: '') 73 | expect(user).to be_valid 74 | end 75 | end 76 | 77 | context 'providerとuidの組み合わせが違う場合' do 78 | it '登録成功' do 79 | create(:alice, provider: '', uid: '') 80 | user = build(:google_oauth) 81 | expect(user).to be_valid 82 | end 83 | end 84 | 85 | context 'providerとuidの組み合わせが同じ場合' do 86 | it '登録失敗' do 87 | user = create(:google_oauth) 88 | other_user = build(:alice, provider: user.provider, uid: user.uid) 89 | other_user.valid? 90 | expect(other_user.errors['uid']).to include('はすでに存在します') 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /app/views/welcome/privacy_policy.html.slim: -------------------------------------------------------------------------------- 1 | - provide(:title, 'プライバシーポリシー') 2 | section.container.is-max-desktop.py-4 3 | .content 4 | h1.title.is-4.has-text-centered 5 | = yield(:title) 6 | 7 | h3 8 | | お客様から取得する情報 9 | p 10 | | 当サービスは、お客様から以下の情報を取得します。 11 | ul 12 | li 13 | | メールアドレス 14 | li 15 | | 外部サービスでお客様が利用するID、その他外部サービスのプライバシー設定によりお客様が連携先に開示を認めた情報 16 | li 17 | | Cookie(クッキー)を用いて生成された識別情報 18 | li 19 | | OSが生成するID、端末の種類、端末識別子等のお客様が利用するOSや端末に関する情報 20 | 21 | h3 22 | | お客様の情報を利用する目的 23 | p 24 | | 当サービスは、お客様から取得した情報を、以下の目的のために利用します。 25 | ul 26 | li 27 | | 当サービスに関する登録の受付、お客様の本人確認、認証のため 28 | li 29 | | お客様の当サービスの利用履歴を管理するため 30 | li 31 | | 当サービスに関するご案内をするため 32 | li 33 | | お客様からのお問い合わせに対応するため 34 | li 35 | | 当サービスの規約や法令に違反する行為に対応するため 36 | li 37 | | 当サービスの変更、提供中止、終了、契約解除をご連絡するため 38 | li 39 | | 当サービス規約の変更等を通知するため 40 | li 41 | | 以上の他、当サービスの提供、維持、保護及び改善のため 42 | 43 | h3 44 | | 第三者提供 45 | p 46 | | 当サービスは、お客様から取得する情報のうち、個人データ(個人情報保護法第2条第6項)に該当するものついては、 47 | | あらかじめお客様の同意を得ずに、第三者(日本国外にある者を含みます。)に提供しません。 48 | br 49 | | 但し、次の場合は除きます。 50 | ul 51 | li 52 | | 個人データの取扱いを外部に委託する場合 53 | li 54 | | 当サービスが買収された場合 55 | li 56 | | 事業パートナーと共同利用する場合(具体的な共同利用がある場合は、その内容を別途公表します。) 57 | li 58 | | その他、法律によって合法的に第三者提供が許されている場合 59 | 60 | h3 61 | | アクセス解析ツール 62 | p 63 | | 当サービスは、お客様のアクセス解析のために、「Googleアナリティクス」を利用しています。 64 | | Googleアナリティクスは、トラフィックデータの収集のためにCookieを使用しています。 65 | | トラフィックデータは匿名で収集されており、個人を特定するものではありません。 66 | | Cookieを無効にすれば、これらの情報の収集を拒否することができます。 67 | | 詳しくはお使いのブラウザの設定をご確認ください。 68 | | Googleアナリティクスについて、詳しくは以下からご確認ください。 69 | br 70 | = link_to 'Google アナリティクス利用規約', 'https://marketingplatform.google.com/about/analytics/terms/jp/' 71 | 72 | h3 73 | | プライバシーポリシーの変更 74 | p 75 | | 当サービスは、必要に応じて、このプライバシーポリシーの内容を変更します。 76 | | この場合、変更後のプライバシーポリシーの施行時期と内容を適切な方法により周知または通知します。 77 | 78 | h3 79 | | お問い合わせ 80 | p 81 | | お客様の情報の開示、情報の訂正、利用停止、削除をご希望の場合は、以下のメールアドレスにご連絡ください。 82 | p 83 | |e-mail 84 | br 85 | | support@serurepo.com 86 | p 87 | | この場合、必ず、運転免許証のご提示等当社が指定する方法により、ご本人からのご請求であることの確認をさせていただきます。 88 | | なお、情報の開示請求については、開示の有無に関わらず、ご申請時に一件あたり1,000円の事務手数料を申し受けます。 89 | 90 | h3 91 | | サービス提供者の名称 92 | p 93 | | ふーが 94 | 95 | p 96 | | 2021年09月11日 制定 97 | -------------------------------------------------------------------------------- /db/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | test: 2 | email: test@example.com 3 | encrypted_password: $2a$12$Jmy731kpwYe2xiZaYC2BxuZ1RDALaxBOSX9TdVFnkXpM5JXJXMyBu #password 4 | provider: '' 5 | uid: 2b410d44-c2fd-4095-8efe-92405bc79cf1 6 | discount_rating: 1 7 | confirmed_at: 2021-09-10 09:34:19 8 | created_at: 2021-09-10 09:34:19 9 | updated_at: 2021-09-10 09:59:46 10 | 11 | # google_omniauthで登録 12 | google_auth: 13 | email: google@example.com 14 | encrypted_password: $2a$12$Jmy731kpwYe2xiZaYC2BxuZ1RDALaxBOSX9TdVFnkXpM5JXJXMyBu #password 15 | provider: google_oauth2 16 | uid: 2b410d44-c2fd-4095-8efe-92405bc79cf7 17 | discount_rating: 1 18 | confirmed_at: 2021-09-10 09:34:19 19 | created_at: 2021-09-10 09:34:19 20 | updated_at: 2021-09-10 09:59:46 21 | 22 | # 通知設定:すべて通知 23 | rating_even: 24 | email: even@example.com 25 | encrypted_password: $2a$12$Jmy731kpwYe2xiZaYC2BxuZ1RDALaxBOSX9TdVFnkXpM5JXJXMyBu #password 26 | provider: '' 27 | uid: 2b410d44-c2fd-4095-8efe-92405bc79cf2 28 | discount_rating: 1 29 | confirmed_at: 2021-09-10 09:34:19 30 | created_at: 2021-09-10 09:34:19 31 | updated_at: 2021-09-10 09:59:46 32 | 33 | # 通知設定:10%以上 34 | rating_over10: 35 | email: over10@example.com 36 | encrypted_password: $2a$12$Jmy731kpwYe2xiZaYC2BxuZ1RDALaxBOSX9TdVFnkXpM5JXJXMyBu #password 37 | provider: '' 38 | uid: 2b410d44-c2fd-4095-8efe-92405bc79cf3 39 | discount_rating: 2 40 | confirmed_at: 2021-09-10 09:34:19 41 | created_at: 2021-09-10 09:34:19 42 | updated_at: 2021-09-10 09:59:46 43 | 44 | # 通知設定:20%以上 45 | rating_over20: 46 | email: over20@example.com 47 | encrypted_password: $2a$12$Jmy731kpwYe2xiZaYC2BxuZ1RDALaxBOSX9TdVFnkXpM5JXJXMyBu #password 48 | provider: '' 49 | uid: 2b410d44-c2fd-4095-8efe-92405bc79cf4 50 | discount_rating: 4 51 | confirmed_at: 2021-09-10 09:34:19 52 | created_at: 2021-09-10 09:34:19 53 | updated_at: 2021-09-10 09:59:46 54 | 55 | # 通知設定:30%以上 56 | rating_over30: 57 | email: over30@example.com 58 | encrypted_password: $2a$12$Jmy731kpwYe2xiZaYC2BxuZ1RDALaxBOSX9TdVFnkXpM5JXJXMyBu #password 59 | provider: '' 60 | uid: 2b410d44-c2fd-4095-8efe-92405bc79cf5 61 | discount_rating: 8 62 | confirmed_at: 2021-09-10 09:34:19 63 | created_at: 2021-09-10 09:34:19 64 | updated_at: 2021-09-10 09:59:46 65 | 66 | # 通知設定:50%以上 67 | rating_over50: 68 | email: over50@example.com 69 | encrypted_password: $2a$12$Jmy731kpwYe2xiZaYC2BxuZ1RDALaxBOSX9TdVFnkXpM5JXJXMyBu #password 70 | provider: '' 71 | uid: 2b410d44-c2fd-4095-8efe-92405bc79cf6 72 | discount_rating: 16 73 | confirmed_at: 2021-09-10 09:34:19 74 | created_at: 2021-09-10 09:34:19 75 | updated_at: 2021-09-10 09:59:46 76 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/integer/time' 4 | 5 | # The test environment is used exclusively to run your application's 6 | # test suite. You never need to work with it otherwise. Remember that 7 | # your test database is "scratch space" for the test suite and is wiped 8 | # and recreated between test runs. Don't rely on the data there! 9 | 10 | Rails.application.configure do 11 | # Settings specified here will take precedence over those in config/application.rb. 12 | 13 | config.cache_classes = false 14 | config.action_view.cache_template_loading = true 15 | 16 | # Do not eager load code on boot. This avoids loading your whole application 17 | # just for the purpose of running a single test. If you are using a tool that 18 | # preloads Rails for running tests, you may have to set it to true. 19 | config.eager_load = false 20 | 21 | # Configure public file server for tests with Cache-Control for performance. 22 | config.public_file_server.enabled = true 23 | config.public_file_server.headers = { 24 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 25 | } 26 | 27 | # Show full error reports and disable caching. 28 | config.consider_all_requests_local = true 29 | config.action_controller.perform_caching = false 30 | config.cache_store = :null_store 31 | 32 | # Raise exceptions instead of rendering exception templates. 33 | config.action_dispatch.show_exceptions = false 34 | 35 | # Disable request forgery protection in test environment. 36 | config.action_controller.allow_forgery_protection = false 37 | 38 | # Store uploaded files on the local file system in a temporary directory. 39 | config.active_storage.service = :test 40 | 41 | config.action_mailer.perform_caching = false 42 | 43 | # Tell Action Mailer not to deliver emails to the real world. 44 | # The :test delivery method accumulates sent emails in the 45 | # ActionMailer::Base.deliveries array. 46 | config.action_mailer.delivery_method = :test 47 | 48 | # Print deprecation notices to the stderr. 49 | config.active_support.deprecation = :stderr 50 | 51 | # Raise exceptions for disallowed deprecations. 52 | config.active_support.disallowed_deprecation = :raise 53 | 54 | # Tell Active Support which deprecation messages to disallow. 55 | config.active_support.disallowed_deprecation_warnings = [] 56 | 57 | # Raises error for missing translations. 58 | # config.i18n.raise_on_missing_translations = true 59 | 60 | # Annotate rendered view with file names. 61 | # config.action_view.annotate_rendered_view_with_filenames = true 62 | 63 | config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } 64 | end 65 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | ruby '3.1.2' 7 | 8 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails', branch: 'main' 9 | gem 'rails', '~> 7.0.3' 10 | # Use postgresql as the database for Active Record 11 | gem 'pg', '1.2.3' 12 | # Use Puma as the app server 13 | gem 'puma', '~> 5.0' 14 | # Use SCSS for stylesheets 15 | gem 'sass-rails', '>= 6' 16 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder 17 | gem 'jbuilder', '~> 2.7' 18 | # Use Redis adapter to run Action Cable in production 19 | # gem 'redis', '~> 4.0' 20 | # Use Active Model has_secure_password 21 | # gem 'bcrypt', '~> 3.1.7' 22 | 23 | # Use Active Storage variant 24 | # gem 'image_processing', '~> 1.2' 25 | 26 | # Reduces boot times through caching; required in config/boot.rb 27 | gem 'bootsnap', '>= 1.4.4', require: false 28 | 29 | gem 'active_flag' 30 | gem 'aws-sdk-rails' 31 | gem 'aws-ses' 32 | gem 'capybara', '>= 3.26' 33 | gem 'cssbundling-rails' 34 | gem 'devise' 35 | gem 'devise-i18n' 36 | gem 'httpclient' 37 | gem 'jsbundling-rails' 38 | gem 'meta-tags' 39 | gem 'net-imap' 40 | gem 'net-pop' 41 | gem 'net-smtp' 42 | gem 'omniauth-google-oauth2' 43 | gem 'omniauth-rails_csrf_protection' 44 | gem 'premailer-rails' 45 | gem 'selenium-webdriver' 46 | gem 'slim-rails' 47 | 48 | group :development, :test do 49 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 50 | gem 'byebug', platforms: %i[mri mingw x64_mingw] 51 | gem 'factory_bot_rails' 52 | gem 'rspec-rails' 53 | gem 'rubocop-rails', require: false 54 | gem 'rubocop-rspec', require: false 55 | end 56 | 57 | group :development do 58 | # Access an interactive console on exception pages or by calling 'console' anywhere in the code. 59 | gem 'web-console', '>= 4.1.0' 60 | # Display performance information such as SQL time and flame graphs for each request in your browser. 61 | # Can be configured to work on production as well see: https://github.com/MiniProfiler/rack-mini-profiler/blob/master/README.md 62 | gem 'listen', '~> 3.3' 63 | gem 'rack-mini-profiler', '~> 2.0' 64 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring 65 | gem 'bcrypt_pbkdf' 66 | gem 'ed25519' 67 | gem 'html2slim' 68 | gem 'letter_opener_web', '~> 1.0' 69 | gem 'slim_lint' 70 | gem 'spring' 71 | end 72 | 73 | group :test do 74 | # Adds support for Capybara system testing and selenium driver 75 | # Easy installation and use of web drivers to run system tests with browsers 76 | gem 'database_rewinder' 77 | gem 'simplecov', require: false 78 | end 79 | 80 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 81 | gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] 82 | -------------------------------------------------------------------------------- /app/views/devise/registrations/edit.html.slim: -------------------------------------------------------------------------------- 1 | - provide(:title, t('.title', resource: resource.model_name.human)) 2 | section.container.is-max-desktop.py-4 3 | h1.title.is-4.has-text-centered 4 | = yield(:title) 5 | 6 | = form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: 'block' }) do |f| 7 | = render 'devise/shared/error_messages', resource: resource 8 | 9 | .field 10 | .message.is-info.mb-4 11 | = f.label :discount_rating, class: 'label message-body py-2 px-4' 12 | .block.mb-2 13 | label.radio 14 | = f.radio_button :discount_rating, :even, class: 'radio', checked: current_user.discount_rating.even? 15 | | 1円でも安ければ通知する 16 | .block.mb-2 17 | label.radio 18 | = f.radio_button :discount_rating, :over10, class: 'radio', checked: current_user.discount_rating.over10? 19 | | 10%以上安ければ通知する 20 | .block.mb-2 21 | label.radio 22 | = f.radio_button :discount_rating, :over20, class: 'radio', checked: current_user.discount_rating.over20? 23 | | 20%以上安ければ通知する 24 | .block.mb-2 25 | label.radio 26 | = f.radio_button :discount_rating, :over30, class: 'radio', checked: current_user.discount_rating.over30? 27 | | 30%以上安ければ通知する 28 | .block.mb-2 29 | label.radio 30 | = f.radio_button :discount_rating, :over50, class: 'radio', checked: current_user.discount_rating.over50? 31 | | 50%以上安ければ通知する 32 | 33 | .field 34 | .message.is-info.mb-4 35 | .message-body.label.py-2.px-4 36 | | ユーザー情報 37 | 38 | .field 39 | = f.label :email, class: 'label' 40 | = f.email_field :email, autofocus: true, autocomplete: 'email', class: 'input' 41 | - if devise_mapping.confirmable? && resource.pending_reconfirmation? 42 | div 43 | = t('.currently_waiting_confirmation_for_email', email: resource.unconfirmed_email) 44 | 45 | - if current_user.provider.blank? 46 | .message.is-warning.mt-5.mb-4 47 | .message-body.p-3 48 | | パスワードを変更する場合のみ以下を入力してください。パスワード変更以外の場合は入力の必要はありません。 49 | 50 | .field 51 | = f.label :password, class: 'label' 52 | = f.password_field :password, autocomplete: 'new-password', class: 'input' 53 | 54 | .field 55 | = f.label :password_confirmation, class: 'label' 56 | = f.password_field :password_confirmation, autocomplete: 'new-password', class: 'input' 57 | 58 | .field 59 | = f.label :current_password, class: 'label' 60 | = f.password_field :current_password, autocomplete: 'current-password', class: 'input' 61 | 62 | .actions 63 | = f.submit t('.update'), class: 'button max-mobile quarter-tablet is-info' 64 | 65 | p.has-text-right 66 | / NOTE: Turbo を有効にできないので button_to でお茶を濁している 67 | = button_to t('.cancel_my_account'), registration_path(resource_name), 68 | form: { data: { confirm: t('.are_you_sure') } }, 69 | method: :delete, 70 | class: 'has-text-grey-light is-size-7 break' 71 | -------------------------------------------------------------------------------- /spec/system/books_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'books', type: :system do 6 | describe '#index' do 7 | context '未ログインでアクセスした場合' do 8 | it 'ログインを促すメッセージが表示される' do 9 | visit 'books/?query=ruby' 10 | expect(page).to have_content 'ログインもしくはアカウント登録してください。' 11 | end 12 | end 13 | 14 | context 'ISBNで検索した場合' do 15 | it 'プロを目指す人のためのRuby入門が表示される' do 16 | visit_with_auth root_path, :alice 17 | fill_in '検索ワード or ISBN', with: '9784297124373' 18 | click_button '検索する' 19 | expect(page).to have_selector '#book-title', text: 'プロを目指す人のためのRuby入門[改訂2版] 言語仕様からテスト駆動開発・デバッグ技法まで' 20 | end 21 | end 22 | 23 | context 'ワード検索した場合' do 24 | it '.mediaが2つ以上ある' do 25 | visit_with_auth root_path, :alice 26 | fill_in '検索ワード or ISBN', with: 'ruby' 27 | click_button '検索する' 28 | media = all('.block .media') 29 | expect(media.size).to be >= 2 30 | end 31 | end 32 | 33 | context '検索結果がなかった場合' do 34 | it '該当する書籍がありませんでしたと表示される' do 35 | visit_with_auth root_path, :alice 36 | fill_in '検索ワード or ISBN', with: '123456789' 37 | click_button '検索する' 38 | expect(page).to have_content '該当する書籍がありませんでした。' 39 | end 40 | end 41 | 42 | context '空で検索した場合' do 43 | it '検索ワードを入力してください。と表示される' do 44 | visit_with_auth root_path, :alice 45 | click_button '検索する' 46 | expect(page).to have_content '検索ワードを入力してください。' 47 | end 48 | end 49 | 50 | context 'ページ数に0が指定された場合' do 51 | it 'パラメーターが不正です。と表示される' do 52 | visit_with_auth '/books?page=0&query=ruby', :alice 53 | expect(page).to have_content 'パラメーターが不正です。' 54 | end 55 | end 56 | 57 | context '100ページより多いページ数が指定された場合' do 58 | it 'パラメーターが不正です。と表示される' do 59 | visit_with_auth '/books?page=101&query=ruby', :alice 60 | expect(page).to have_content 'パラメーターが不正です。' 61 | end 62 | end 63 | 64 | context 'ページ数に負の数値が指定された場合' do 65 | it 'パラメーターが不正です。と表示される' do 66 | visit_with_auth '/books?page=-1&query=ruby', :alice 67 | expect(page).to have_content 'パラメーターが不正です。' 68 | end 69 | end 70 | 71 | context 'ページ数に数値以外が指定された場合' do 72 | it 'パラメーターが不正です。と表示される' do 73 | visit_with_auth '/books?page=aaa&query=ruby', :alice 74 | expect(page).to have_content 'パラメーターが不正です。' 75 | end 76 | end 77 | 78 | context 'ISBNが1桁多い場合' do 79 | it '該当する書籍がありませんでした。と表示される' do 80 | visit_with_auth root_path, :alice 81 | fill_in '検索ワード or ISBN', with: '97847741939771' 82 | click_button '検索する' 83 | expect(page).to have_content '該当する書籍がありませんでした。' 84 | end 85 | end 86 | 87 | context 'ISBNが1桁少ない場合' do 88 | it '該当する書籍がありませんでした。と表示される' do 89 | visit_with_auth root_path, :alice 90 | fill_in '検索ワード or ISBN', with: '978477419397' 91 | click_button '検索する' 92 | expect(page).to have_content '該当する書籍がありませんでした。' 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/requests/api/users/lists_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Api::Users::Lists', type: :request do 6 | describe 'GET /show' do 7 | let(:list_detail) { create(:list_detail_one) } 8 | let(:list) { list_detail.list } 9 | let(:user) { list.user } 10 | 11 | context 'ログイン状態の場合' do 12 | before do 13 | sign_in user 14 | get api_users_list_path(list) 15 | end 16 | 17 | it '200が返ること' do 18 | expect(response).to have_http_status(:ok) 19 | end 20 | 21 | it 'json形式でリスト登録済みの書籍が返ること' do 22 | json = JSON.parse(response.body) 23 | expect(json.length).to eq 1 24 | end 25 | 26 | it 'list_detail_idが同値であること' do 27 | json = JSON.parse(response.body) 28 | expect(json['books'][0]['list_detail_id']).to eq list_detail.id 29 | end 30 | 31 | it '書籍のIDが格納されていること' do 32 | json = JSON.parse(response.body) 33 | expect(json['books'][0]['book']['id']).to eq list_detail.book.id 34 | end 35 | end 36 | 37 | context 'ログイン状態でない場合' do 38 | it '401が返ること' do 39 | get api_users_list_path(list) 40 | expect(response).to have_http_status('401') 41 | end 42 | end 43 | end 44 | 45 | describe 'POST /create' do 46 | context 'ログイン状態の場合' do 47 | context 'すでに登録済みの場合' do 48 | let(:list) { create(:list) } 49 | let(:user) { list.user } 50 | 51 | it '200が返ること' do 52 | sign_in user 53 | post api_users_lists_path 54 | expect(response).to have_http_status(:ok) 55 | end 56 | 57 | it '登録がされないこと' do 58 | sign_in user 59 | post api_users_lists_path 60 | expect { post api_users_lists_path }.not_to change(List, :count) 61 | end 62 | 63 | it 'リストIDが返ること' do 64 | sign_in user 65 | post api_users_lists_path 66 | expect(JSON.parse(response.body)['listId']).to eq user.list.id 67 | end 68 | end 69 | 70 | context '登録済みでない場合' do 71 | let(:user) { create(:alice) } 72 | 73 | it '201が返ること' do 74 | sign_in user 75 | post api_users_lists_path 76 | expect(response).to have_http_status(:created) 77 | end 78 | 79 | it '登録が1件増えること' do 80 | sign_in user 81 | expect { post api_users_lists_path }.to change(List, :count).by(1) 82 | end 83 | 84 | it 'リストIDが数値で返ること' do 85 | sign_in user 86 | post api_users_lists_path 87 | expect(JSON.parse(response.body)['listId']).to be_integer 88 | end 89 | end 90 | end 91 | 92 | context '未ログインの場合' do 93 | it '401が返ること' do 94 | post api_users_lists_path 95 | expect(response).to have_http_status(:unauthorized) 96 | end 97 | 98 | it '登録がされないこと' do 99 | expect { post api_users_lists_path }.not_to change(List, :count) 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/integer/time' 4 | 5 | Rails.application.configure do 6 | # Settings specified here will take precedence over those in config/application.rb. 7 | 8 | # In the development environment your application's code is reloaded any time 9 | # it changes. This slows down response time but is perfect for development 10 | # since you don't have to restart the web server when you make code changes. 11 | config.cache_classes = false 12 | 13 | # Do not eager load code on boot. 14 | config.eager_load = false 15 | 16 | # Show full error reports. 17 | config.consider_all_requests_local = true 18 | 19 | # Enable/disable caching. By default caching is disabled. 20 | # Run rails dev:cache to toggle caching. 21 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 22 | config.action_controller.perform_caching = true 23 | config.action_controller.enable_fragment_cache_logging = true 24 | 25 | config.cache_store = :memory_store 26 | config.public_file_server.headers = { 27 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 28 | } 29 | else 30 | config.action_controller.perform_caching = false 31 | 32 | config.cache_store = :null_store 33 | end 34 | 35 | # Store uploaded files on the local file system (see config/storage.yml for options). 36 | config.active_storage.service = :local 37 | 38 | # Don't care if the mailer can't send. 39 | config.action_mailer.raise_delivery_errors = false 40 | 41 | config.action_mailer.perform_caching = false 42 | 43 | # Print deprecation notices to the Rails logger. 44 | config.active_support.deprecation = :log 45 | 46 | # Raise exceptions for disallowed deprecations. 47 | config.active_support.disallowed_deprecation = :raise 48 | 49 | # Tell Active Support which deprecation messages to disallow. 50 | config.active_support.disallowed_deprecation_warnings = [] 51 | 52 | # Raise an error on page load if there are pending migrations. 53 | config.active_record.migration_error = :page_load 54 | 55 | # Highlight code that triggered database queries in logs. 56 | config.active_record.verbose_query_logs = true 57 | 58 | # Debug mode disables concatenation and preprocessing of assets. 59 | # This option may cause significant delays in view rendering with a large 60 | # number of complex assets. 61 | config.assets.debug = true 62 | 63 | # Suppress logger output for asset requests. 64 | config.assets.quiet = true 65 | 66 | # Raises error for missing translations. 67 | # config.i18n.raise_on_missing_translations = true 68 | 69 | # Annotate rendered view with file names. 70 | # config.action_view.annotate_rendered_view_with_filenames = true 71 | 72 | # Use an evented file watcher to asynchronously detect changes in source code, 73 | # routes, locales, etc. This feature depends on the listen gem. 74 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 75 | 76 | # Uncomment if you wish to allow Action Cable access from any origin. 77 | # config.action_cable.disable_request_forgery_protection = true 78 | config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } 79 | config.action_mailer.delivery_method = :letter_opener_web 80 | end 81 | -------------------------------------------------------------------------------- /spec/models/book_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Book, type: :model do 6 | describe '正常系' do 7 | it '登録成功' do 8 | book = build(:cherry) 9 | expect(book).to be_valid 10 | end 11 | end 12 | 13 | describe 'ISBN' do 14 | context '空の場合' do 15 | it '登録失敗' do 16 | book = build(:cherry, isbn13: '') 17 | book.valid? 18 | expect(book.errors[:isbn13]).to include('を入力してください') 19 | end 20 | end 21 | 22 | context '12桁の場合' do 23 | it '登録失敗' do 24 | book = build(:cherry, isbn13: '978123456789') 25 | book.valid? 26 | expect(book.errors[:isbn13]).to include('は13文字で入力してください') 27 | end 28 | end 29 | 30 | context '14桁の場合' do 31 | it '登録失敗' do 32 | book = build(:cherry, isbn13: '97812345678901') 33 | book.valid? 34 | expect(book.errors[:isbn13]).to include('は13文字で入力してください') 35 | end 36 | end 37 | end 38 | 39 | describe 'price' do 40 | context '空の場合' do 41 | it '登録失敗' do 42 | book = build(:cherry, price: '') 43 | book.valid? 44 | expect(book.errors[:price]).to include('を入力してください') 45 | end 46 | end 47 | 48 | context '文字列の場合' do 49 | it '登録失敗' do 50 | book = build(:cherry, price: '百円') 51 | book.valid? 52 | expect(book.errors[:price]).to include('は数値で入力してください') 53 | end 54 | end 55 | end 56 | 57 | describe 'title' do 58 | context '空の場合' do 59 | it '登録失敗' do 60 | book = build(:cherry, title: '') 61 | book.valid? 62 | expect(book.errors[:title]).to include('を入力してください') 63 | end 64 | end 65 | end 66 | 67 | describe 'author' do 68 | context '空の場合' do 69 | it '登録成功' do 70 | book = build(:cherry, author: '') 71 | expect(book).to be_valid 72 | end 73 | end 74 | end 75 | 76 | describe 'image' do 77 | context '空の場合' do 78 | it '登録失敗' do 79 | book = build(:cherry, image: '') 80 | book.valid? 81 | expect(book.errors[:image]).to include('を入力してください') 82 | end 83 | end 84 | end 85 | 86 | describe 'url' do 87 | context '空の場合' do 88 | it '登録失敗' do 89 | book = build(:cherry, url: '') 90 | book.valid? 91 | expect(book.errors[:url]).to include('を入力してください') 92 | end 93 | end 94 | end 95 | 96 | describe 'sales_date' do 97 | context '空の場合' do 98 | it '登録成功' do 99 | book = build(:cherry, sales_date: '') 100 | expect(book).to be_valid 101 | end 102 | end 103 | end 104 | 105 | describe '#not_in_list_details_destroy!' do 106 | subject { book.not_in_list_details_destroy! } 107 | 108 | let!(:book) { create(:cherry) } 109 | 110 | context '他のリストに登録されていない場合' do 111 | it 'DBから削除されること' do 112 | expect { subject }.to change(described_class, :count).by(-1) 113 | end 114 | end 115 | 116 | context '他のリストに登録されている場合' do 117 | before do 118 | create(:list_detail_one, book:) 119 | end 120 | 121 | it 'DBから削除されないこと' do 122 | expect { subject }.not_to change(described_class, :count) 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2021_11_03_043039) do 14 | 15 | # These are extensions that must be enabled in order to support this database 16 | enable_extension "plpgsql" 17 | 18 | create_table "books", force: :cascade do |t| 19 | t.string "isbn13" 20 | t.integer "price" 21 | t.datetime "created_at", precision: 6, null: false 22 | t.datetime "updated_at", precision: 6, null: false 23 | t.text "title" 24 | t.text "author" 25 | t.text "image" 26 | t.text "url" 27 | t.text "sales_date" 28 | end 29 | 30 | create_table "list_details", force: :cascade do |t| 31 | t.bigint "list_id" 32 | t.bigint "book_id" 33 | t.datetime "created_at", precision: 6, null: false 34 | t.datetime "updated_at", precision: 6, null: false 35 | t.index ["book_id"], name: "index_list_details_on_book_id" 36 | t.index ["list_id"], name: "index_list_details_on_list_id" 37 | end 38 | 39 | create_table "lists", force: :cascade do |t| 40 | t.bigint "user_id" 41 | t.string "name", default: "通知リスト" 42 | t.datetime "created_at", precision: 6, null: false 43 | t.datetime "updated_at", precision: 6, null: false 44 | t.index ["user_id"], name: "index_lists_on_user_id", unique: true 45 | end 46 | 47 | create_table "users", force: :cascade do |t| 48 | t.string "email", default: "", null: false 49 | t.string "encrypted_password", default: "", null: false 50 | t.string "reset_password_token" 51 | t.datetime "reset_password_sent_at" 52 | t.datetime "remember_created_at" 53 | t.integer "sign_in_count", default: 0, null: false 54 | t.datetime "current_sign_in_at" 55 | t.datetime "last_sign_in_at" 56 | t.string "current_sign_in_ip" 57 | t.string "last_sign_in_ip" 58 | t.string "confirmation_token" 59 | t.datetime "confirmed_at" 60 | t.datetime "confirmation_sent_at" 61 | t.string "unconfirmed_email" 62 | t.datetime "created_at", precision: 6, null: false 63 | t.datetime "updated_at", precision: 6, null: false 64 | t.string "provider", default: "", null: false 65 | t.string "uid", default: "", null: false 66 | t.bigint "discount_rating", default: 1, null: false 67 | t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true 68 | t.index ["email"], name: "index_users_on_email", unique: true 69 | t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true 70 | t.index ["uid", "provider"], name: "index_users_on_uid_and_provider", unique: true 71 | end 72 | 73 | add_foreign_key "list_details", "books" 74 | add_foreign_key "list_details", "lists" 75 | add_foreign_key "lists", "users" 76 | end 77 | -------------------------------------------------------------------------------- /lib/comparer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'crawler/amazon_crawler' 4 | require 'crawler/dmm_crawler' 5 | require 'crawler/rakuten_crawler' 6 | require 'crawler/seshop_crawler' 7 | require 'logger' 8 | 9 | module Comparer 10 | class Books 11 | def self.run 12 | logger = Logger.new('log/crawler.log') 13 | logger << "==#{Time.current} Books Compare start==================\n" 14 | 15 | data = [] 16 | Book.find_each do |book| 17 | amazon = AmazonCrawler.new 18 | dmm = DmmCrawler.new 19 | rakuten = RakutenCrawler.new 20 | seshop = SeshopCrawler.new 21 | 22 | amazon.run(book.isbn13) 23 | dmm.run(book.title) 24 | rakuten.run(book.isbn13) 25 | seshop.run(book.isbn13) 26 | 27 | data.push({ book_id: book.id, 28 | amazon: amazon_comparer(amazon, book), 29 | dmm: dmm_comparer(dmm, book), 30 | rakuten: rakuten_comparer(rakuten, book), 31 | seshop: seshop_comparer(seshop, book) }) 32 | end 33 | 34 | logger << "【All Results】\n#{data}\n" 35 | 36 | data 37 | end 38 | 39 | def self.amazon_comparer(amazon_data, book) 40 | kindle = amazon_data.kindle_price 41 | paper = amazon_data.paper_price 42 | 43 | return if kindle.blank? && paper.blank? 44 | 45 | single = kindle.presence || paper 46 | price = if kindle.present? && paper.present? 47 | compare_price(kindle, paper) 48 | else 49 | single 50 | end 51 | 52 | return if price >= book.price 53 | 54 | { price:, 55 | url: "https://www.amazon.co.jp/dp/#{amazon_data.asin}/ref=nosim?tag=#{Rails.application.credentials.amazon[:tag]}" } 56 | end 57 | 58 | def self.dmm_comparer(dmm_data, book) 59 | return if dmm_data.price.blank? || dmm_data.price >= book.price 60 | 61 | { price: dmm_data.price, url: dmm_data.book_url } 62 | end 63 | 64 | def self.rakuten_comparer(rakuten_data, book) 65 | e_book = rakuten_data.e_book_price 66 | paper = rakuten_data.paper_book_price 67 | single = rakuten_data.single_price 68 | 69 | return if single.blank? 70 | 71 | price = if e_book.present? && paper.present? 72 | compare_price(e_book, paper) 73 | else 74 | single 75 | end 76 | 77 | return if price >= book.price 78 | 79 | { price:, url: book.url } 80 | end 81 | 82 | def self.seshop_comparer(seshop_data, book) 83 | return if seshop_data.paper_price.blank? && seshop_data.pdf_price.blank? 84 | 85 | seshop_price = seshop_data.paper_price.to_i 86 | seshop_url = seshop_data.paper_url 87 | 88 | pdf_price = seshop_data.pdf_price.to_i 89 | if !pdf_price.zero? && pdf_price < seshop_price 90 | seshop_price = pdf_price 91 | seshop_url = seshop_data.pdf_url 92 | end 93 | 94 | return if book.price <= seshop_price 95 | 96 | { price: seshop_price, url: seshop_url } 97 | end 98 | 99 | def self.compare_price(e_book_price, paper_price) 100 | if e_book_price <= paper_price 101 | e_book_price 102 | elsif e_book_price > paper_price 103 | paper_price 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require 'rubygems' 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($PROGRAM_NAME) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV['BUNDLER_VERSION'] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless 'update'.start_with?(ARGV.first || ' ') # must be running `bundle update` 27 | 28 | bundler_version = nil 29 | update_index = nil 30 | ARGV.each_with_index do |a, i| 31 | bundler_version = a if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 32 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 33 | 34 | bundler_version = Regexp.last_match(1) 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV['BUNDLE_GEMFILE'] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path('../Gemfile', __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when 'gems.rb' then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | 59 | lockfile_contents = File.read(lockfile) 60 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 61 | 62 | Regexp.last_match(1) 63 | end 64 | 65 | def bundler_version 66 | @bundler_version ||= 67 | env_var_version || cli_arg_version || 68 | lockfile_version 69 | end 70 | 71 | def bundler_requirement 72 | return "#{Gem::Requirement.default}.a" unless bundler_version 73 | 74 | bundler_gem_version = Gem::Version.new(bundler_version) 75 | 76 | requirement = bundler_gem_version.approximate_recommendation 77 | 78 | return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new('2.7.0') 79 | 80 | requirement += '.a' if bundler_gem_version.prerelease? 81 | 82 | requirement 83 | end 84 | 85 | def load_bundler! 86 | ENV['BUNDLE_GEMFILE'] ||= gemfile 87 | 88 | activate_bundler 89 | end 90 | 91 | def activate_bundler 92 | gem_error = activation_error_handling do 93 | gem 'bundler', bundler_requirement 94 | end 95 | return if gem_error.nil? 96 | 97 | require_error = activation_error_handling do 98 | require 'bundler/version' 99 | end 100 | if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 101 | return 102 | end 103 | 104 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 105 | exit 42 106 | end 107 | 108 | def activation_error_handling 109 | yield 110 | nil 111 | rescue StandardError, LoadError => e 112 | e 113 | end 114 | end 115 | 116 | m.load_bundler! 117 | 118 | load Gem.bin_path('bundler', 'bundle') if m.invoked_as_script? 119 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # PostgreSQL. Versions 9.3 and up are supported. 2 | # 3 | # Install the pg driver: 4 | # gem install pg 5 | # On macOS with Homebrew: 6 | # gem install pg -- --with-pg-config=/usr/local/bin/pg_config 7 | # On macOS with MacPorts: 8 | # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config 9 | # On Windows: 10 | # gem install pg 11 | # Choose the win32 build. 12 | # Install PostgreSQL and put its /bin directory on your path. 13 | # 14 | # Configure Using Gemfile 15 | # gem 'pg' 16 | # 17 | default: &default 18 | adapter: postgresql 19 | encoding: utf8 20 | username: postgres 21 | password: postgres 22 | host: db 23 | # For details on connection pooling, see Rails configuration guide 24 | # https://guides.rubyonrails.org/configuring.html#database-pooling 25 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 26 | 27 | development: 28 | <<: *default 29 | database: serurepo_development 30 | 31 | # The specified database role being used to connect to postgres. 32 | # To create additional roles in postgres see `$ createuser --help`. 33 | # When left blank, postgres will use the default role. This is 34 | # the same name as the operating system user running Rails. 35 | #username: serurepo 36 | 37 | # The password associated with the postgres role (username). 38 | #password: 39 | 40 | # Connect on a TCP socket. Omitted by default since the client uses a 41 | # domain socket that doesn't need configuration. Windows does not have 42 | # domain sockets, so uncomment these lines. 43 | #host: localhost 44 | 45 | # The TCP port the server listens on. Defaults to 5432. 46 | # If your server runs on a different port number, change accordingly. 47 | #port: 5432 48 | 49 | # Schema search path. The server defaults to $user,public 50 | #schema_search_path: myapp,sharedapp,public 51 | 52 | # Minimum log levels, in increasing order: 53 | # debug5, debug4, debug3, debug2, debug1, 54 | # log, notice, warning, error, fatal, and panic 55 | # Defaults to warning. 56 | #min_messages: notice 57 | 58 | # Warning: The database defined as "test" will be erased and 59 | # re-generated from your development database when you run "rake". 60 | # Do not set this db to the same as development or production. 61 | test: 62 | <<: *default 63 | database: serurepo_test 64 | 65 | # As with config/credentials.yml, you never want to store sensitive information, 66 | # like your database password, in your source code. If your source code is 67 | # ever seen by anyone, they now have access to your database. 68 | # 69 | # Instead, provide the password or a full connection URL as an environment 70 | # variable when you boot the app. For example: 71 | # 72 | # DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" 73 | # 74 | # If the connection URL is provided in the special DATABASE_URL environment 75 | # variable, Rails will automatically merge its configuration values on top of 76 | # the values provided in this file. Alternatively, you can specify a connection 77 | # URL environment variable explicitly: 78 | # 79 | # production: 80 | # url: <%= ENV['MY_APP_DATABASE_URL'] %> 81 | # 82 | # Read https://guides.rubyonrails.org/configuring.html#configuring-a-database 83 | # for a full overview on how database connection configuration can be specified. 84 | # 85 | production: 86 | <<: *default 87 | database: serurepo_production 88 | username: <%= Rails.application.credentials.db[:user_name] %> 89 | password: <%= Rails.application.credentials.db[:password] %> 90 | host: <%= Rails.application.credentials.db[:endpoint] %> 91 | -------------------------------------------------------------------------------- /spec/factories/books.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :cherry, class: 'Book' do 5 | title { 'プロを目指す人のためのRuby入門[改訂2版] 言語仕様からテスト駆動開発・デバッグ技法まで' } 6 | author { '伊藤 淳一' } 7 | image { 'https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/4373/9784297124373_1_5.jpg?_ex=120x120' } 8 | url { 'https://books.rakuten.co.jp/rb/16908719/' } 9 | sales_date { '2017年12月' } 10 | isbn13 { '9784297124373' } 11 | price { 3278 } 12 | end 13 | 14 | factory :fun_ruby, class: 'Book' do 15 | title { 'たのしいRuby 第6版' } 16 | author { '高橋 征義/後藤 裕蔵' } 17 | image { 'https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/9844/9784797399844.jpg?_ex=120x120' } 18 | url { 'https://hb.afl.rakuten.co.jp/hgc/g00q0727.m0lf90b6.g00q0727.m0lfa419/?pc=https%3A%2F%2Fbooks.rakuten.co.jp%2Frb%2F15822269%2F' } 19 | sales_date { '2019年03月20日頃' } 20 | isbn13 { '9784797399844' } 21 | price { 2860 } 22 | end 23 | 24 | factory :perfect_rails, class: 'Book' do 25 | title { 'パーフェクト Ruby on Rails 【増補改訂版】' } 26 | author { 'すがわらまさのり/前島真一/橋立友宏/五十嵐邦明' } 27 | image { 'https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/4626/9784297114626.jpg?_ex=120x120' } 28 | url { 'https://hb.afl.rakuten.co.jp/hgc/g00q0727.m0lf90b6.g00q0727.m0lfa419/?pc=https%3A%2F%2Fbooks.rakuten.co.jp%2Frb%2F16352336%2F' } 29 | sales_date { '2020年07月25日頃' } 30 | isbn13 { '9784297114626' } 31 | price { 3828 } 32 | end 33 | 34 | factory :cho_nyumon, class: 'Book' do 35 | title { 'ゼロからわかるRuby超入門' } 36 | author { '五十嵐邦明/松岡浩平' } 37 | image { 'https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/1237/9784297101237.jpg?_ex=120x120' } 38 | url { 'https://hb.afl.rakuten.co.jp/hgc/g00q0727.m0lf90b6.g00q0727.m0lfa419/?pc=https%3A%2F%2Fbooks.rakuten.co.jp%2Frb%2F15664673%2F' } 39 | sales_date { '2018年12月' } 40 | isbn13 { '9784297101237' } 41 | price { 2728 } 42 | end 43 | 44 | factory :genba_rails, class: 'Book' do 45 | title { '現場で使える Ruby on Rails 5速習実践ガイド' } 46 | author { '大場寧子/松本拓也' } 47 | image { 'https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/2227/9784839962227.jpg?_ex=120x120' } 48 | url { 'https://hb.afl.rakuten.co.jp/hgc/g00q0727.m0lf90b6.g00q0727.m0lfa419/?pc=https%3A%2F%2Fbooks.rakuten.co.jp%2Frb%2F15628625%2F' } 49 | sales_date { '2018年10月19日頃' } 50 | isbn13 { '9784839962227' } 51 | price { 3828 } 52 | end 53 | 54 | factory :dokugaku, class: 'Book' do 55 | title { '独学大全' } 56 | author { '読書猿' } 57 | image { 'https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/8536/9784478108536.jpg?_ex=120x120' } 58 | url { 'https://hb.afl.rakuten.co.jp/hgc/g00q0727.m0lf90b6.g00q0727.m0lfa419/?pc=https%3A%2F%2Fbooks.rakuten.co.jp%2Frb%2F15628625%2F' } 59 | sales_date { '2018年10月19日頃' } 60 | isbn13 { '9784478108536' } 61 | price { 3080 } 62 | end 63 | 64 | factory :dokusyu_js, class: 'Book' do 65 | title { '独習JavaScript 新版' } 66 | author { 'CodeMafia 外村将大' } 67 | image { 'https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/8536/9784478108536.jpg?_ex=120x120' } 68 | url { 'https://hb.afl.rakuten.co.jp/hgc/g00q0727.m0lf90b6.g00q0727.m0lfa419/?pc=https%3A%2F%2Fbooks.rakuten.co.jp%2Frb%2F15628625%2F' } 69 | sales_date { '2021年11月15日' } 70 | isbn13 { '9784798160276' } 71 | price { 3278 } 72 | end 73 | 74 | factory :kumikomi_os, class: 'Book' do 75 | title { 'リアルタイム組込みOS基礎講座' } 76 | author { 'Carolone Yao' } 77 | image { 'https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/8536/9784478108536.jpg?_ex=120x120' } 78 | url { 'https://hb.afl.rakuten.co.jp/hgc/g00q0727.m0lf90b6.g00q0727.m0lfa419/?pc=https%3A%2F%2Fbooks.rakuten.co.jp%2Frb%2F15628625%2F' } 79 | sales_date { '2005年11月02日' } 80 | isbn13 { '4798110043' } 81 | price { 3740 } 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/lib/mail_sender_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | require_relative '../../lib/mail_sender' 5 | 6 | RSpec.describe MailSender, type: :module do 7 | let(:price_data) do 8 | { 9 | amazon: { 10 | price: 1000, 11 | url: 'https://amazon.com' 12 | }, 13 | dmm: { 14 | price: 2000, 15 | url: 'https://books.dmm.com' 16 | }, 17 | rakuten: { 18 | price: 3000, 19 | url: 'https://books.rakuten.com' 20 | }, 21 | seshop: { 22 | price: 3000, 23 | url: 'https://seshop.com' 24 | } 25 | } 26 | end 27 | 28 | # TODO: #sale_reportのテストを書く 29 | 30 | describe '#all_data_nil?' do 31 | context '全ての属性が存在する場合' do 32 | it 'falseが返ること' do 33 | expect(described_class).not_to be_all_data_nil(price_data) 34 | end 35 | end 36 | 37 | context 'いずれか1つの属性が存在しない場合' do 38 | it 'falseが返ること' do 39 | price_data[:amazon] = nil 40 | expect(described_class).not_to be_all_data_nil(price_data) 41 | end 42 | end 43 | 44 | context 'いずれか2つの属性が存在しない場合' do 45 | it 'falseが返ること' do 46 | price_data[:amazon] = nil 47 | price_data[:dmm] = nil 48 | expect(described_class).not_to be_all_data_nil(price_data) 49 | end 50 | end 51 | 52 | context '全ての属性が存在しない場合' do 53 | it 'trueが返ること' do 54 | price_data[:amazon] = nil 55 | price_data[:dmm] = nil 56 | price_data[:rakuten] = nil 57 | price_data[:seshop] = nil 58 | expect(described_class).to be_all_data_nil(price_data) 59 | end 60 | end 61 | end 62 | 63 | describe '#get_rating' do 64 | context 'evenのユーザーの場合' do 65 | it '1が返ること' do 66 | user = create(:rating_even) 67 | expect(described_class.get_rating(user)).to eq 1 68 | end 69 | end 70 | 71 | context 'over10のユーザーの場合' do 72 | it '0.9が返ること' do 73 | user = create(:rating_over10) 74 | expect(described_class.get_rating(user)).to eq 0.9 75 | end 76 | end 77 | 78 | context 'over20のユーザーの場合' do 79 | it '0.8が返ること' do 80 | user = create(:rating_over20) 81 | expect(described_class.get_rating(user)).to eq 0.8 82 | end 83 | end 84 | 85 | context 'over30のユーザーの場合' do 86 | it '0.7が返ること' do 87 | user = create(:rating_over30) 88 | expect(described_class.get_rating(user)).to eq 0.7 89 | end 90 | end 91 | 92 | context 'over50のユーザーの場合' do 93 | it '0.5が返ること' do 94 | user = create(:rating_over50) 95 | expect(described_class.get_rating(user)).to eq 0.5 96 | end 97 | end 98 | 99 | context '規定外の数値の場合' do 100 | it '1が返ること' do 101 | user = create(:rating_even) 102 | user.discount_rating = '' 103 | expect(described_class.get_rating(user)).to eq 1 104 | end 105 | end 106 | end 107 | 108 | describe '#higher_than_discounted_price?' do 109 | let(:discount_price) { 500 } 110 | 111 | context 'データが存在し、割引設定に準じた金額より高い場合' do 112 | it 'trueを返すこと' do 113 | expect(described_class).to be_higher_than_discounted_price(price_data[:amazon], discount_price) 114 | end 115 | end 116 | 117 | context 'データが存在し、割引設定に準じた金額より安い場合' do 118 | it 'falseを返すこと' do 119 | price_data[:amazon][:price] = 400 120 | expect(described_class).not_to be_higher_than_discounted_price(price_data[:amazon], discount_price) 121 | end 122 | end 123 | 124 | context 'データが存在せず、割引設定に準じた金額より高い場合' do 125 | it 'falseを返すこと' do 126 | price_data[:amazon] = nil 127 | expect(described_class).not_to be_higher_than_discounted_price(price_data[:amazon], discount_price) 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is copied to spec/ when you run 'rails generate rspec:install' 4 | require 'spec_helper' 5 | ENV['RAILS_ENV'] ||= 'test' 6 | require File.expand_path('../config/environment', __dir__) 7 | # Prevent database truncation if the environment is production 8 | abort('The Rails environment is running in production mode!') if Rails.env.production? 9 | require 'rspec/rails' 10 | # Add additional requires below this line. Rails is not loaded until this point! 11 | require 'capybara/rails' 12 | require 'capybara/rspec' 13 | 14 | # Requires supporting ruby files with custom matchers and macros, etc, in 15 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 16 | # run as spec files by default. This means that files in spec/support that end 17 | # in _spec.rb will both be required and run as specs, causing the specs to be 18 | # run twice. It is recommended that you do not name files matching this glob to 19 | # end with _spec.rb. You can configure this pattern with the --pattern 20 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 21 | # 22 | # The following line is provided for convenience purposes. It has the downside 23 | # of increasing the boot-up time by auto-requiring all files in the support 24 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 25 | # require only the support files necessary. 26 | # 27 | Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } 28 | 29 | # Checks for pending migrations and applies them before tests are run. 30 | # If you are not using ActiveRecord, you can remove these lines. 31 | begin 32 | ActiveRecord::Migration.maintain_test_schema! 33 | rescue ActiveRecord::PendingMigrationError => e 34 | puts e.to_s.strip 35 | exit 1 36 | end 37 | RSpec.configure do |config| 38 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 39 | config.fixture_path = "#{::Rails.root}/spec/fixtures" 40 | 41 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 42 | # examples within a transaction, remove the following line or assign false 43 | # instead of true. 44 | config.use_transactional_fixtures = false 45 | 46 | config.before(:suite) do 47 | DatabaseRewinder.clean_all 48 | end 49 | 50 | config.after do 51 | DatabaseRewinder.clean 52 | end 53 | 54 | config.before do |example| 55 | if example.metadata[:type] == :system 56 | Capybara.register_driver :remote_selenium do |app| 57 | caps = Selenium::WebDriver::Remote::Capabilities.chrome( 58 | 'goog:chromeOptions' => { args: %w[headless no-sandbox disable-dev-shm-usage] } 59 | ) 60 | 61 | Capybara::Selenium::Driver.new( 62 | app, 63 | browser: :chrome, 64 | capabilities: caps 65 | ) 66 | end 67 | driven_by :remote_selenium 68 | end 69 | end 70 | 71 | # You can uncomment this line to turn off ActiveRecord support entirely. 72 | # config.use_active_record = false 73 | 74 | # RSpec Rails can automatically mix in different behaviours to your tests 75 | # based on their file location, for example enabling you to call `get` and 76 | # `post` in specs under `spec/controllers`. 77 | # 78 | # You can disable this behaviour by removing the line below, and instead 79 | # explicitly tag your specs with their type, e.g.: 80 | # 81 | # RSpec.describe UsersController, type: :controller do 82 | # # ... 83 | # end 84 | # 85 | # The different available types are documented in the features, such as in 86 | # https://relishapp.com/rspec/rspec-rails/docs 87 | config.infer_spec_type_from_file_location! 88 | 89 | # Filter lines from Rails gems in backtraces. 90 | config.filter_rails_from_backtrace! 91 | # arbitrary gems may also be filtered via: 92 | # config.filter_gems_from_backtrace("gem name") 93 | config.include FactoryBot::Syntax::Methods 94 | config.include LoginModule 95 | 96 | config.include Devise::Test::ControllerHelpers, type: :controller 97 | config.include RequestSpecHelper, type: :request 98 | config.include ActiveSupport::Testing::TimeHelpers, type: :system 99 | end 100 | -------------------------------------------------------------------------------- /config/locales/devise.en.yml: -------------------------------------------------------------------------------- 1 | # Additional translations at https://github.com/heartcombo/devise/wiki/I18n 2 | 3 | en: 4 | devise: 5 | confirmations: 6 | confirmed: "Your email address has been successfully confirmed." 7 | send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." 8 | send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." 9 | failure: 10 | already_authenticated: "You are already signed in." 11 | inactive: "Your account is not activated yet." 12 | invalid: "Invalid %{authentication_keys} or password." 13 | locked: "Your account is locked." 14 | last_attempt: "You have one more attempt before your account is locked." 15 | not_found_in_database: "Invalid %{authentication_keys} or password." 16 | timeout: "Your session expired. Please sign in again to continue." 17 | unauthenticated: "You need to sign in or sign up before continuing." 18 | unconfirmed: "You have to confirm your email address before continuing." 19 | mailer: 20 | confirmation_instructions: 21 | subject: "Confirmation instructions" 22 | reset_password_instructions: 23 | subject: "Reset password instructions" 24 | unlock_instructions: 25 | subject: "Unlock instructions" 26 | email_changed: 27 | subject: "Email Changed" 28 | password_change: 29 | subject: "Password Changed" 30 | omniauth_callbacks: 31 | failure: "Could not authenticate you from %{kind} because \"%{reason}\"." 32 | success: "Successfully authenticated from %{kind} account." 33 | passwords: 34 | no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." 35 | send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." 36 | send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." 37 | updated: "Your password has been changed successfully. You are now signed in." 38 | updated_not_active: "Your password has been changed successfully." 39 | registrations: 40 | destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." 41 | signed_up: "Welcome! You have signed up successfully." 42 | signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." 43 | signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." 44 | signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." 45 | update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." 46 | updated: "Your account has been updated successfully." 47 | updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again." 48 | sessions: 49 | signed_in: "Signed in successfully." 50 | signed_out: "Signed out successfully." 51 | already_signed_out: "Signed out successfully." 52 | unlocks: 53 | send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." 54 | send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." 55 | unlocked: "Your account has been unlocked successfully. Please sign in to continue." 56 | errors: 57 | messages: 58 | already_confirmed: "was already confirmed, please try signing in" 59 | confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" 60 | expired: "has expired, please request a new one" 61 | not_found: "not found" 62 | not_locked: "was not locked" 63 | not_saved: 64 | one: "1 error prohibited this %{resource} from being saved:" 65 | other: "%{count} errors prohibited this %{resource} from being saved:" 66 | -------------------------------------------------------------------------------- /spec/requests/api/list_details_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Api::ListDetails', type: :request do 6 | describe 'POST /create' do 7 | let(:list) { create(:list) } 8 | let(:book) { create(:cherry) } 9 | let(:list_detail) { { list_id: list.id, book_id: book.id } } 10 | let(:user) { list.user } 11 | 12 | context 'ログイン状態の場合' do 13 | context 'すでに登録済みの場合' do 14 | it '400が返ること' do 15 | sign_in user 16 | post api_list_details_path, params: { list_detail: } 17 | post api_list_details_path, params: { list_detail: } 18 | expect(response).to have_http_status(:bad_request) 19 | end 20 | 21 | it '登録がされないこと' do 22 | sign_in user 23 | post api_list_details_path, params: { list_detail: } 24 | post api_list_details_path, params: { list_detail: } 25 | expect { post api_list_details_path, params: { list_detail: } }.not_to change(ListDetail, :count) 26 | end 27 | 28 | it '「すでに登録済みです。」とエラーメッセージが出ること' do 29 | sign_in user 30 | post api_list_details_path, params: { list_detail: } 31 | post api_list_details_path, params: { list_detail: } 32 | expect(JSON.parse(response.body)['errorMessage']).to eq 'すでに登録済みです。' 33 | end 34 | end 35 | 36 | context '登録されていないリスト詳細の場合' do 37 | context '正常な値の場合' do 38 | it '201が返ること' do 39 | sign_in user 40 | post api_list_details_path, params: { list_detail: } 41 | expect(response).to have_http_status(:created) 42 | end 43 | 44 | it '登録が1件増えること' do 45 | sign_in user 46 | expect do 47 | post api_list_details_path, params: { list_detail: } 48 | end.to change(ListDetail, :count).by(1) 49 | end 50 | 51 | it '「リストに追加しました!」とメッセージが出ること' do 52 | sign_in user 53 | post api_list_details_path, params: { list_detail: } 54 | expect(JSON.parse(response.body)['message']).to eq 'リストに追加しました!' 55 | end 56 | end 57 | 58 | context '正常でない値がある場合' do 59 | it '422が返ること' do 60 | sign_in user 61 | list_detail['list_id'] = nil 62 | post api_list_details_path, params: { list_detail: } 63 | expect(response).to have_http_status(:unprocessable_entity) 64 | end 65 | 66 | it '登録がされないこと' do 67 | sign_in user 68 | list_detail['list_id'] = nil 69 | post api_list_details_path, params: { list_detail: } 70 | expect do 71 | post api_list_details_path, params: { list_detail: } 72 | end.not_to change(ListDetail, :count) 73 | end 74 | end 75 | end 76 | end 77 | 78 | context '未ログインの場合' do 79 | it '401が返ること' do 80 | post api_list_details_path, params: { list_detail: } 81 | expect(response).to have_http_status(:unauthorized) 82 | end 83 | 84 | it '登録がされないこと' do 85 | expect { post api_list_details_path, params: { list_detail: } }.not_to change(ListDetail, :count) 86 | end 87 | end 88 | end 89 | 90 | describe 'DELETE /destroy' do 91 | let!(:list_detail) { create(:list_detail_one) } 92 | let(:user) { list_detail.list.user } 93 | 94 | context 'ログイン状態の場合' do 95 | context '正常な値の場合' do 96 | it '200が返ること' do 97 | sign_in user 98 | delete api_list_detail_path(list_detail.id) 99 | expect(response).to have_http_status(:ok) 100 | end 101 | 102 | it 'リスト詳細が1件減ること' do 103 | sign_in user 104 | expect { delete api_list_detail_path(list_detail.id) }.to change(ListDetail, :count).by(-1) 105 | end 106 | 107 | it '「削除しました。」とメッセージが表示されること' do 108 | sign_in user 109 | delete api_list_detail_path(list_detail.id) 110 | expect(JSON.parse(response.body)['successMessage']).to eq '削除しました。' 111 | end 112 | end 113 | end 114 | 115 | context '未ログインの場合' do 116 | it '401が返ること' do 117 | delete api_list_detail_path(list_detail.id) 118 | expect(response).to have_http_status(:unauthorized) 119 | end 120 | 121 | it '削除がされないこと' do 122 | expect { delete api_list_detail_path(list_detail.id) }.not_to change(ListDetail, :count) 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /app/views/welcome/tos.html.slim: -------------------------------------------------------------------------------- 1 | - provide(:title, '利用規約') 2 | section.container.is-max-desktop.py-4 3 | .content 4 | h1.title.is-4.has-text-centered 5 | = yield(:title) 6 | p 7 | | この利用規約(以下「本規約」といいます。)は、このウェブサイト上で提供するサービス(以下「本サービス」といいます。)の利用条件を定めるものです。登録ユーザーの皆さま(以下「ユーザー」といいます。)には、本規約に従って、本サービスをご利用いただきます。 8 | 9 | h3 10 | | 第1条(適用) 11 | ol 12 | li 本規約は、ユーザーの本サービスの利用に関わる一切の関係に適用されるものとします。 13 | li 本サービスに関し、本規約のほか、ご利用にあたってのルール等、各種の定め(以下「個別規定」といいます。)をすることがあります。これら個別規定はその名称のいかんに関わらず、本規約の一部を構成するものとします。 14 | li 本規約の規定が前条の個別規定の規定と矛盾する場合には、個別規定において特段の定めなき限り、個別規定の規定が優先されるものとします。 15 | 16 | h3 17 | | 第2条(利用登録) 18 | ol 19 | li 本サービスにおいては、登録希望者が本規約に同意の上、本サービスの定める方法によって利用登録を申請し、本サービスがこれを承認することによって、利用登録が完了するものとします。 20 | li 本サービスは、利用登録の申請者に以下の事由があると判断した場合、利用登録の申請を承認しないことがあり、その理由については一切の開示義務を負わないものとします。 21 | li 22 | ol 23 | li 利用登録の申請に際して虚偽の事項を届け出た場合 24 | li 本規約に違反したことがある者からの申請である場合 25 | li その他、本サービスが利用登録を相当でないと判断した場合 26 | 27 | h3 28 | | 第3条(ユーザーIDおよびパスワードの管理) 29 | ol 30 | li ユーザーは、自己の責任において、本サービスのユーザーIDおよびパスワードを適切に管理するものとします。 31 | li 32 | | ユーザーは、いかなる場合にも、ユーザーIDおよびパスワードを第三者に譲渡または貸与し、もしくは第三者と共用することはできません。 33 | | 本サービスは、ユーザーIDとパスワードの組み合わせが登録情報と一致してログインされた場合には、そのユーザーIDを登録しているユーザー自身による利用とみなします。 34 | li ユーザーID及びパスワードが第三者によって使用されたことによって生じた損害は、本サービスに故意又は重大な過失がある場合を除き、本サービスは一切の責任を負わないものとします。 35 | 36 | h3 37 | | 第4条(禁止事項) 38 | p 39 | | ユーザーは、本サービスの利用にあたり、以下の行為をしてはなりません。 40 | ol 41 | li 法令または公序良俗に違反する行為 42 | li 犯罪行為に関連する行為 43 | li 本サービスの内容等、本サービスに含まれる著作権、商標権ほか知的財産権を侵害する行為 44 | li 本サービス、ほかのユーザー、またはその他第三者のサーバーまたはネットワークの機能を破壊したり、妨害したりする行為 45 | li 本サービスによって得られた情報を商業的に利用する行為 46 | li 本サービスのサービスの運営を妨害するおそれのある行為 47 | li 不正アクセスをし、またはこれを試みる行為 48 | li 他のユーザーに関する個人情報等を収集または蓄積する行為 49 | li 不正な目的を持って本サービスを利用する行為 50 | li 本サービスの他のユーザーまたはその他の第三者に不利益、損害、不快感を与える行為 51 | li 他のユーザーに成りすます行為 52 | li 本サービスが許諾しない本サービス上での宣伝、広告、勧誘、または営業行為 53 | li 面識のない異性との出会いを目的とした行為 54 | li 本サービスのサービスに関連して、反社会的勢力に対して直接または間接に利益を供与する行為 55 | li その他、本サービスが不適切と判断する行為 56 | 57 | h3 58 | | 第5条(本サービスの提供の停止等) 59 | ol 60 | li 以下のいずれかの事由があると判断した場合、ユーザーに事前に通知することなく本サービスの全部または一部の提供を停止または中断することができるものとします。 61 | li 62 | ol 63 | li 本サービスにかかるコンピュータシステムの保守点検または更新を行う場合 64 | li 地震、落雷、火災、停電または天災などの不可抗力により、本サービスの提供が困難となった場合 65 | li コンピュータまたは通信回線等が事故により停止した場合 66 | li その他、本サービスの提供が困難と判断した場合 67 | li 本サービスの提供の停止または中断により、ユーザーまたは第三者が被ったいかなる不利益または損害についても、一切の責任を負わないものとします。 68 | 69 | h3 70 | | 第6条(利用制限および登録抹消) 71 | ol 72 | li 本サービスは、ユーザーが以下のいずれかに該当する場合には、事前の通知なく、ユーザーに対して、本サービスの全部もしくは一部の利用を制限し、またはユーザーとしての登録を抹消することができるものとします。 73 | li 74 | ol 75 | li 本規約のいずれかの条項に違反した場合 76 | li 登録事項に虚偽の事実があることが判明した場合 77 | li 料金等の支払債務の不履行があった場合 78 | li 本サービスからの連絡に対し、一定期間返答がない場合 79 | li 本サービスについて、最終の利用から一定期間利用がない場合 80 | li その他、本サービスが本サービスの利用を適当でないと判断した場合 81 | li 本条に基づき本サービスが行った行為によりユーザーに生じた損害について、一切の責任を負いません。 82 | 83 | h3 84 | | 第7条(退会) 85 | p 86 | | ユーザーは、本サービスの定める退会手続により、本サービスから退会できるものとします。 87 | 88 | h3 89 | | 第8条(保証の否認および免責事項) 90 | ol 91 | li 本サービスに事実上または法律上の瑕疵(安全性、信頼性、正確性、完全性、有効性、特定の目的への適合性、セキュリティなどに関する欠陥、エラーやバグ、権利侵害などを含みます。)がないことを明示的にも黙示的にも保証しておりません。 92 | li 本サービスに起因してユーザーに生じたあらゆる損害について一切の責任を負いません。ただし、本サービスに関するユーザーとの間の契約(本規約を含みます。)が消費者契約法に定める消費者契約となる場合、この免責規定は適用されません。 93 | li 94 | | 前項ただし書に定める場合であっても、本サービスは、本サービスの過失(重過失を除きます。)による債務不履行または不法行為により 95 | | ユーザーに生じた損害のうち特別な事情から生じた損害(本サービスまたはユーザーが損害発生につき予見し、または予見し得た場合を含みます。)について一切の責任を負いません。 96 | li 本サービスに関して、ユーザーと他のユーザーまたは第三者との間において生じた取引、連絡または紛争等について一切責任を負いません。 97 | 98 | h3 99 | | 第9条(サービス内容の変更等) 100 | p 101 | | 本サービスは、ユーザーに通知することなく、本サービスの内容を変更しまたは本サービスの提供を中止することができるものとし、これによってユーザーに生じた損害について一切の責任を負いません。 102 | 103 | h3 104 | | 第10条(利用規約の変更) 105 | p 106 | | 本サービスは、必要と判断した場合には、ユーザーに通知することなくいつでも本規約を変更することができるものとします。なお、本規約の変更後、本サービスの利用を開始した場合には、当該ユーザーは変更後の規約に同意したものとみなします。 107 | 108 | h3 109 | | 第11条(個人情報の取扱い) 110 | p 111 | | 本サービスは、本サービスの利用によって取得する個人情報については、本サービス「 112 | = link_to 'プライバシーポリシー', privacy_policy_path 113 | | 」に従い適切に取り扱うものとします。 114 | 115 | h3 116 | | 第12条(通知または連絡) 117 | p 118 | | ユーザーと本サービスとの間の通知または連絡は、本サービスの定める方法によって行うものとします。 119 | | 本サービスはユーザーから本サービスが別途定める方式に従った変更届け出がない限り、現在登録されている連絡先が有効なものとみなして当該連絡先へ通知または連絡を行い、これらは、発信時にユーザーへ到達したものとみなします。 120 | 121 | h3 122 | | 第13条(権利義務の譲渡の禁止) 123 | p 124 | | ユーザーは、本サービスの書面による事前の承諾なく、利用契約上の地位または本規約に基づく権利もしくは義務を第三者に譲渡し、または担保に供することはできません。 125 | 126 | h3 127 | | 第14条(準拠法・裁判管轄) 128 | ol 129 | li 本規約の解釈にあたっては、日本法を準拠法とします。 130 | li 本サービスに関して紛争が生じた場合には、本サービスの本店所在地を管轄する裁判所を専属的合意管轄とします。 131 | 132 | p 133 | | 2021年9月11日制 134 | -------------------------------------------------------------------------------- /config/locales/ja.yml: -------------------------------------------------------------------------------- 1 | ja: 2 | activerecord: 3 | errors: 4 | messages: 5 | record_invalid: 'バリデーションに失敗しました: %{errors}' 6 | restrict_dependent_destroy: 7 | has_one: "%{record}が存在しているので削除できません" 8 | has_many: "%{record}が存在しているので削除できません" 9 | date: 10 | abbr_day_names: 11 | - 日 12 | - 月 13 | - 火 14 | - 水 15 | - 木 16 | - 金 17 | - 土 18 | abbr_month_names: 19 | - 20 | - 1月 21 | - 2月 22 | - 3月 23 | - 4月 24 | - 5月 25 | - 6月 26 | - 7月 27 | - 8月 28 | - 9月 29 | - 10月 30 | - 11月 31 | - 12月 32 | day_names: 33 | - 日曜日 34 | - 月曜日 35 | - 火曜日 36 | - 水曜日 37 | - 木曜日 38 | - 金曜日 39 | - 土曜日 40 | formats: 41 | default: "%Y/%m/%d" 42 | long: "%Y年%m月%d日(%a)" 43 | short: "%m/%d" 44 | month_names: 45 | - 46 | - 1月 47 | - 2月 48 | - 3月 49 | - 4月 50 | - 5月 51 | - 6月 52 | - 7月 53 | - 8月 54 | - 9月 55 | - 10月 56 | - 11月 57 | - 12月 58 | order: 59 | - :year 60 | - :month 61 | - :day 62 | datetime: 63 | distance_in_words: 64 | about_x_hours: 65 | one: 約1時間 66 | other: 約%{count}時間 67 | about_x_months: 68 | one: 約1ヶ月 69 | other: 約%{count}ヶ月 70 | about_x_years: 71 | one: 約1年 72 | other: 約%{count}年 73 | almost_x_years: 74 | one: 1年弱 75 | other: "%{count}年弱" 76 | half_a_minute: 30秒前後 77 | less_than_x_seconds: 78 | one: 1秒以内 79 | other: "%{count}秒未満" 80 | less_than_x_minutes: 81 | one: 1分以内 82 | other: "%{count}分未満" 83 | over_x_years: 84 | one: 1年以上 85 | other: "%{count}年以上" 86 | x_seconds: 87 | one: 1秒 88 | other: "%{count}秒" 89 | x_minutes: 90 | one: 1分 91 | other: "%{count}分" 92 | x_days: 93 | one: 1日 94 | other: "%{count}日" 95 | x_months: 96 | one: 1ヶ月 97 | other: "%{count}ヶ月" 98 | x_years: 99 | one: 1年 100 | other: "%{count}年" 101 | prompts: 102 | second: 秒 103 | minute: 分 104 | hour: 時 105 | day: 日 106 | month: 月 107 | year: 年 108 | errors: 109 | format: "%{attribute}%{message}" 110 | messages: 111 | accepted: を受諾してください 112 | blank: を入力してください 113 | confirmation: と%{attribute}の入力が一致しません 114 | empty: を入力してください 115 | equal_to: は%{count}にしてください 116 | even: は偶数にしてください 117 | exclusion: は予約されています 118 | greater_than: は%{count}より大きい値にしてください 119 | greater_than_or_equal_to: は%{count}以上の値にしてください 120 | inclusion: は一覧にありません 121 | invalid: は不正な値です 122 | less_than: は%{count}より小さい値にしてください 123 | less_than_or_equal_to: は%{count}以下の値にしてください 124 | model_invalid: 'バリデーションに失敗しました: %{errors}' 125 | not_a_number: は数値で入力してください 126 | not_an_integer: は整数で入力してください 127 | odd: は奇数にしてください 128 | other_than: は%{count}以外の値にしてください 129 | present: は入力しないでください 130 | required: を入力してください 131 | taken: はすでに存在します 132 | too_long: は%{count}文字以内で入力してください 133 | too_short: は%{count}文字以上で入力してください 134 | wrong_length: は%{count}文字で入力してください 135 | template: 136 | body: 次の項目を確認してください 137 | header: 138 | one: "%{model}にエラーが発生しました" 139 | other: "%{model}に%{count}個のエラーが発生しました" 140 | helpers: 141 | select: 142 | prompt: 選択してください 143 | submit: 144 | create: 登録する 145 | submit: 保存する 146 | update: 更新する 147 | number: 148 | currency: 149 | format: 150 | delimiter: "," 151 | format: "%n%u" 152 | precision: 0 153 | separator: "." 154 | significant: false 155 | strip_insignificant_zeros: false 156 | unit: 円 157 | format: 158 | delimiter: "," 159 | precision: 3 160 | separator: "." 161 | significant: false 162 | strip_insignificant_zeros: false 163 | human: 164 | decimal_units: 165 | format: "%n %u" 166 | units: 167 | billion: 十億 168 | million: 百万 169 | quadrillion: 千兆 170 | thousand: 千 171 | trillion: 兆 172 | unit: '' 173 | format: 174 | delimiter: '' 175 | precision: 3 176 | significant: true 177 | strip_insignificant_zeros: true 178 | storage_units: 179 | format: "%n%u" 180 | units: 181 | byte: バイト 182 | eb: EB 183 | gb: GB 184 | kb: KB 185 | mb: MB 186 | pb: PB 187 | tb: TB 188 | percentage: 189 | format: 190 | delimiter: '' 191 | format: "%n%" 192 | precision: 193 | format: 194 | delimiter: '' 195 | support: 196 | array: 197 | last_word_connector: "、" 198 | two_words_connector: "、" 199 | words_connector: "、" 200 | time: 201 | am: 午前 202 | formats: 203 | default: "%Y年%m月%d日(%a) %H時%M分%S秒 %z" 204 | long: "%Y/%m/%d %H:%M" 205 | short: "%m/%d %H:%M" 206 | pm: 午後 207 | --------------------------------------------------------------------------------