kobot 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5a9d8a789566c49cffabd4006d13502a6222a211d9193cdf58f0523c6dd89937
4
+ data.tar.gz: 993fed11502b5149ece85da3d46d69f74169cf75c479221e56becf6b195d6d97
5
+ SHA512:
6
+ metadata.gz: 3fe31ae275852246a0ba1656f829352ef204278d55f4578bc160d1917b8c07a8586b621eee42ed80e1c7535c0650de0cd8a7edcbb5b7da9f7359f2fa42a8a435
7
+ data.tar.gz: e904e08fe88b5c616e5c7287e77a6de4d0e7b85dba1306e4c5dd8ab9ff24938437cd69dcfed4e2da0c6904ddbfb561a96a4aea92f0d61a268b2460591d950efe
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ /Gemfile.lock
10
+ /*.gem
@@ -0,0 +1,6 @@
1
+ ---
2
+ language: ruby
3
+ cache: bundler
4
+ rvm:
5
+ - 2.6.6
6
+ before_install: gem install bundler -v 2.1.4
@@ -0,0 +1,2 @@
1
+ ### v1.0.0
2
+ - Initial release
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at andy.jiang@appier.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [https://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: https://contributor-covenant.org
74
+ [version]: https://contributor-covenant.org/version/1/4/
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in kobot.gemspec
6
+ gemspec
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Andy Jiang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,121 @@
1
+ # Kobot
2
+
3
+ Kobot is a simple tool to automate the clock in or clock out operation on the web service
4
+ provided by [KING OF TIME](kingtime.jp) by leveraging [Selenium WebDriver](selenium.dev),
5
+ and with Google Gmail service email notification can also be sent to notify the results.
6
+
7
+ It is meant for use only by one working under the discretionary labor system or flexible
8
+ hours where the daily record is still required regardless of the actual start or end time
9
+ of working, for example, software programmers or IT related engineers.
10
+
11
+ By being run in a periodic schedule system such as crontab, it eases the mental burden to
12
+ one for trying not to forget the clock action, as well as reduces the application process
13
+ for making up for the records when it was forgotten.
14
+
15
+ ## Installation
16
+
17
+ #### Environment
18
+
19
+ Tested on macOS Catelina and runs on Heroku platform so it works on unix-like systems.
20
+
21
+ #### Configuration
22
+
23
+ By default it uses `~/.kobot` file locally to persist credentials for reuse, but all credentials
24
+ can be overridden by setting environment variables, which is the recommended way of running the
25
+ job on platforms like Heroku. When running for the first time, if none of the configuration file
26
+ and ENV satisfies all required credentials, an interactive prompt will be displayed for setting,
27
+ so there is no need to manually prepare the file beforehand. The content looks something like:
28
+ ```property
29
+ kot_id=xxx
30
+ kot_password=xxx
31
+ ```
32
+ Gmail account and password (or `app` password if MFA is on) are asked when notification is needed:
33
+ ```property
34
+ gmail_id=xxx
35
+ gmail_password=xxx
36
+ ```
37
+
38
+ #### Google Chrome browser
39
+
40
+ Make sure the latest version of Google Chrome is installed in system. On platforms like Heroku,
41
+ the [heroku-buildpack-google-chrome](https://elements.heroku.com/buildpacks/heroku/heroku-buildpack-google-chrome)
42
+ makes it extremely easy to get the browser ready.
43
+
44
+ #### Install the command line tool
45
+
46
+ Make sure a modern version of Ruby is available and run command below to install:
47
+ ```bash
48
+ $ gem install kobot
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ Get help doc:
54
+ ```bash
55
+ $ kobot -h
56
+ Usage: kobot [options]
57
+ -c, --clock CLOCK The clock action: in, out
58
+ -l, --loglevel [LEVEL] Specify log level: debug, info, warn, error. Default is info
59
+ -s, --skip [D1,D2,D3] Specify dates to skip clock in/out with date format YYYY-MM-DD and
60
+ multiple values separated by comma, such as: 2020-05-01,2020-12-31
61
+ -t, --to [TO] Email address to send notification to. By default it is sent to
62
+ the same self email account used in SMTP config as the sender
63
+ -n, --notify Enable email notification
64
+ -d, --dryrun Run the process without actual clock in/out
65
+ -x, --headless Start browser in headless mode
66
+ -g, --geolocation Allow browser to use geolocation
67
+ -h, --help Show this help message
68
+ -v, --version Show current version
69
+ ```
70
+
71
+ Dryrun to try out:
72
+ ```bash
73
+ $ kobot --clock in --dryrun
74
+ ```
75
+
76
+ Clock in/out with email notification
77
+ ```bash
78
+ $ kobot --clock in --notify
79
+ $ kobot --clock out --notify
80
+ ```
81
+
82
+ Run the task with crontab
83
+ ```cron
84
+ 30 09 * * * user kobot --clock in --notify
85
+ 30 18 * * * user kobot --clock out --notify
86
+ ```
87
+ On platforms like Heroku, an add-on called [Heroku Scheduler](https://elements.heroku.com/addons/scheduler) makes
88
+ running scheduled tasks much easier. Tips: either clock in or clock out task can be scheduled multiple times in
89
+ case Scheduler misses to run due to Heroku system failures (which might occur very rarely in reality).
90
+
91
+ ## Dependency
92
+
93
+ Kobot is an opinionated tool for which the only purpose is to get the tedious process automated, and therefore
94
+ Google Chrome is used as the supported browser and Google Gmail is chosen to be the email notification service
95
+ because both simply just work to get the job done. The [webdrivers](https://github.com/titusfortner/webdrivers)
96
+ gem is the only direct dependency and it is by intention to minimize the runtime gem dependency.
97
+
98
+ ## Development
99
+
100
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
101
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
102
+
103
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the
104
+ version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version,
105
+ push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
106
+
107
+ ## Contributing
108
+
109
+ Bug reports and pull requests are welcome on GitHub at https://github.com/yuan-jiang/kobot.
110
+ This project is intended tobe a safe, welcoming space for collaboration, and contributors are
111
+ expected to adhere to the [code of conduct](https://github.com/yuan-jiang/kobot/blob/master/CODE_OF_CONDUCT.md).
112
+
113
+
114
+ ## License
115
+
116
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
117
+
118
+ ## Code of Conduct
119
+
120
+ Everyone interacting in the Kobot project's codebases, issue trackers, chat rooms and mailing lists is expected to
121
+ follow the [code of conduct](https://github.com/yuan-jiang/kobot/blob/master/CODE_OF_CONDUCT.md).
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ task default: :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'kobot'
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require 'irb'
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'kobot'
5
+
6
+ Kobot.run
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/kobot/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'kobot'
7
+ spec.version = Kobot::VERSION
8
+ spec.authors = ['Andy Jiang']
9
+ spec.email = ['yuanjiang@outlook.com']
10
+
11
+ spec.summary = 'Kobot automates the clock in/out of KING OF TIME.'
12
+ spec.description = <<-DESC
13
+ Kobot is a simple tool to automate the clock in or clock out operation on the web service
14
+ provided by [KING OF TIME](kingtime.jp) by leveraging [Selenium WebDriver](selenium.dev),
15
+ and with Google Gmail service email notification can also be sent to notify the results.
16
+ DESC
17
+
18
+ spec.homepage = 'https://github.com/yuan-jiang/kobot'
19
+ spec.license = 'MIT'
20
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
21
+
22
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org/'
23
+ spec.metadata['homepage_uri'] = spec.homepage
24
+ spec.metadata['source_code_uri'] = 'https://github.com/yuan-jiang/kobot'
25
+ spec.metadata['changelog_uri'] = 'https://github.com/yuan-jiang/kobot/blob/master/CHANGELOG.md'
26
+
27
+ # Specify which files should be added to the gem when it is released.
28
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
29
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
30
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
31
+ end
32
+ spec.bindir = 'exe'
33
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
34
+ spec.require_paths = ['lib']
35
+
36
+ spec.add_runtime_dependency 'webdrivers', '~> 4.0'
37
+ spec.add_development_dependency 'rake', '~> 12.0'
38
+ spec.add_development_dependency 'rspec', '~> 3.0'
39
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kobot/version'
4
+ require 'kobot/exception'
5
+ require 'kobot/option'
6
+ require 'kobot/config'
7
+ require 'kobot/credential'
8
+ require 'kobot/logger'
9
+ require 'kobot/mailer'
10
+ require 'kobot/engine'
11
+
12
+ # Kobot is a simple tool to automate the clock in or clock out operation on the web service
13
+ # provided by [KING OF TIME](kingtime.jp) by leveraging [Selenium WebDriver](selenium.dev),
14
+ # and with Google Gmail service email notification can also be sent to notify the results.
15
+ module Kobot
16
+ class << self
17
+
18
+ # The entrance to run Kobot.
19
+ def run
20
+ configure
21
+ Engine.new.start
22
+ rescue StandardError => e
23
+ logger.error e.message
24
+ logger.error e.backtrace
25
+ Mailer.send e.message
26
+ end
27
+
28
+ # Parses command line options, configures Kobot, and finally ensures
29
+ # required credentials are loaded properly and ready to be in use.
30
+ def configure
31
+ options = Option.parse!
32
+ Config.configure do |config|
33
+ config.clock = options[:clock].to_sym
34
+ config.loglevel = options[:loglevel]&.to_sym || :info
35
+ config.dryrun = options[:dryrun]
36
+ config.skip = options[:skip] || []
37
+
38
+ config.kot_url = 'https://s2.kingtime.jp/independent/recorder/personal/'
39
+ config.kot_timezone_offset = '+09:00'
40
+ config.kot_date_format = '%m/%d'
41
+
42
+ config.gmail_notify_enabled = options[:notify]
43
+ config.gmail_notify_to = options[:to]
44
+ config.gmail_notify_subject = "[#{Module.nesting.last}] Notification"
45
+ config.gmail_smtp_address = 'smtp.gmail.com'
46
+ config.gmail_smtp_port = 587
47
+
48
+ config.browser_headless = options[:headless]
49
+ config.browser_geolocation = options[:geolocation]
50
+ config.browser_wait_timeout = 10
51
+
52
+ config.credentials_file = File.join(Dir.home, ".#{File.basename(__FILE__, File.extname(__FILE__))}")
53
+ end
54
+ Credential.load!
55
+ end
56
+
57
+ def logger
58
+ @logger ||= Logger.new
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobot
4
+
5
+ # Configuration definition includes static ones hardcoded and
6
+ # dynamic ones that can be specified by command line options.
7
+ class Config
8
+ class << self
9
+ attr_accessor :clock,
10
+ :loglevel,
11
+ :skip,
12
+ :dryrun
13
+
14
+ attr_accessor :kot_url,
15
+ :kot_timezone_offset,
16
+ :kot_date_format
17
+
18
+ attr_accessor :gmail_notify_enabled,
19
+ :gmail_notify_subject,
20
+ :gmail_notify_to,
21
+ :gmail_smtp_address,
22
+ :gmail_smtp_port
23
+
24
+ attr_accessor :browser_headless,
25
+ :browser_geolocation,
26
+ :browser_wait_timeout
27
+
28
+ attr_accessor :credentials_file
29
+
30
+ def configure
31
+ yield self
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobot
4
+ # Credentials include id and password to login to KOT and
5
+ # Gmail SMTP id and password to send email notifications.
6
+ class Credential
7
+
8
+ class << self
9
+ attr_accessor :kot_id,
10
+ :kot_password,
11
+ :gmail_id,
12
+ :gmail_password
13
+
14
+ # Make sure credentials are loaded by first checking
15
+ # and reading from #{Config.credentials_file} if it
16
+ # exists and then overriding any credentials if they
17
+ # are also supplied as environment variables in ENV.
18
+ #
19
+ # If neither #{Config.credentials_file} nor ENV has
20
+ # all the required credentials a command line prompt
21
+ # will be displayed for users to input credentials
22
+ # which will be saved to #{Config.credentials.file}
23
+ # for later use.
24
+ #
25
+ # KOT id and password are required by default and
26
+ # Gmail SMTP id and password are required only when
27
+ # #{Config.gmail_notify_enabled} is true.
28
+ def load!
29
+ prompt_for_credentials until credentials_loaded
30
+ @credentials.each do |attr, value|
31
+ send("#{attr}=".to_sym, value)
32
+ end
33
+ Kobot.logger.info('Credentials load successful')
34
+ Kobot.logger.debug(@credentials)
35
+ end
36
+
37
+ private
38
+
39
+ def credentials_loaded
40
+ @credentials ||= {}
41
+ if File.exist? Config.credentials_file
42
+ File.open(Config.credentials_file) do |file|
43
+ file.each do |line|
44
+ attr, value = line.chomp.split('=')
45
+ @credentials[attr] = value
46
+ end
47
+ end
48
+ end
49
+ @credentials['kot_id'] = ENV['kot_id'] if ENV['kot_id']
50
+ @credentials['kot_password'] = ENV['kot_password'] if ENV['kot_password']
51
+ @credentials['gmail_id'] = ENV['gmail_id'] if ENV['gmail_id']
52
+ @credentials['gmail_password'] = ENV['gmail_password'] if ENV['gmail_password']
53
+
54
+ required_credentials = %w[kot_id kot_password]
55
+ required_credentials.concat %w[gmail_id gmail_password] if Config.gmail_notify_enabled
56
+ required_credentials.none? do |attr|
57
+ credential = @credentials[attr]
58
+ !credential || credential.strip.empty?
59
+ end
60
+ end
61
+
62
+ def prompt_for_credentials
63
+ puts 'Required credentials missing, please enter:'
64
+ print 'kot_id: '
65
+ kot_id_input = gets.chomp
66
+ print 'kot_password: '
67
+ kot_password_input = gets.chomp
68
+ if Config.gmail_notify_enabled
69
+ print 'gmail_id: '
70
+ gmail_id_input = gets.chomp
71
+ print 'gmail_password: '
72
+ gmail_password_input = gets.chomp
73
+ end
74
+ File.open(Config.credentials_file, 'w+') do |file|
75
+ file.puts "kot_id=#{kot_id_input}"
76
+ file.puts "kot_password=#{kot_password_input}"
77
+ if Config.gmail_notify_enabled
78
+ file.puts "gmail_id=#{gmail_id_input}"
79
+ file.puts "gmail_password=#{gmail_password_input}"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'webdrivers/chromedriver'
4
+
5
+ module Kobot
6
+ # The core class that launches browser, logins to KOT, reads today
7
+ # record, and conducts clock in or clock out action based on config.
8
+ class Engine
9
+
10
+ def initialize
11
+ @now = Time.now.getlocal(Config.kot_timezone_offset)
12
+ @today = @now.strftime(Config.kot_date_format)
13
+ @top_url = Config.kot_url
14
+ end
15
+
16
+ # The entrance where the whole flow starts.
17
+ #
18
+ # It exits early if today is weekend or treated as holiday by
19
+ # the #{Config.skip} specified from command line option --skip.
20
+ #
21
+ # Unexpected behavior such as record appearing as holiday on
22
+ # the web or failure of clock in/out action is handled within
23
+ # the method by logging and/or email notifications if enabled.
24
+ #
25
+ # System errors or any unknown exceptions occurred if any are
26
+ # to be popped up and should be handled by the outside caller.
27
+ def start
28
+ if weekend?
29
+ Kobot.logger.info("Today=#{@today} is weekend.")
30
+ return
31
+ end
32
+ if holiday?
33
+ Kobot.logger.info("Today=#{@today} is holiday.")
34
+ return
35
+ end
36
+ unless %i[in out].include? Config.clock
37
+ Kobot.logger.warn("Invalid clock operation: #{Config.clock}")
38
+ return
39
+ end
40
+ launch_browser
41
+ login
42
+ read_today_record
43
+ verify_today_record!
44
+ if Config.clock == :in
45
+ clock_in!
46
+ else
47
+ clock_out!
48
+ end
49
+ logout
50
+ rescue KotRecordError => e
51
+ Kobot.logger.warn(e.message)
52
+ Mailer.send(clock_notify_message(status: e.message))
53
+ logout
54
+ rescue KotClockInError => e
55
+ Kobot.logger.warn e.message
56
+ Mailer.send(clock_notify_message(clock: :in, status: e.message))
57
+ logout
58
+ rescue KotClockOutError => e
59
+ Kobot.logger.warn e.message
60
+ Mailer.send(clock_notify_message(clock: :out, status: e.message))
61
+ logout
62
+ rescue StandardError => e
63
+ Kobot.logger.error(e.message)
64
+ Kobot.logger.error(e.backtrace)
65
+ Mailer.send(clock_notify_message(status: e.message))
66
+ logout
67
+ ensure
68
+ @browser&.quit
69
+ end
70
+
71
+ private
72
+
73
+ def launch_browser
74
+ prefs = {
75
+ profile: {
76
+ default_content_settings: {
77
+ geolocation: Config.browser_geolocation ? 1 : 2
78
+ }
79
+ }
80
+ }
81
+ options = Selenium::WebDriver::Chrome::Options.new(prefs: prefs)
82
+ options.headless! if Config.browser_headless
83
+ @browser = Selenium::WebDriver.for(:chrome, options: options)
84
+ @wait = Selenium::WebDriver::Wait.new(timeout: Config.browser_wait_timeout)
85
+ Kobot.logger.info('Launch browser successful')
86
+ end
87
+
88
+ def login
89
+ @browser.get @top_url
90
+ Kobot.logger.info("Navigate to: #{@top_url}")
91
+ Kobot.logger.debug do
92
+ "Login with id=#{Credential.kot_id} and password=#{Credential.kot_password}"
93
+ end
94
+ @browser.find_element(id: 'id').send_keys Credential.kot_id
95
+ @browser.find_element(id: 'password').send_keys Credential.kot_password
96
+ @browser.find_element(css: 'div.btn-control-message').click
97
+
98
+ Kobot.logger.info 'Login successful'
99
+ @wait.until { @browser.find_element(id: 'notification_content').text.include?('データを取得しました') }
100
+ if Config.browser_geolocation
101
+ begin
102
+ @wait.until { @browser.find_element(id: 'location_area').text.include?('位置情報取得済み') }
103
+ rescue StandardError => e
104
+ Kobot.logger.warn "Get geolocation failed: #{e.message}"
105
+ end
106
+ end
107
+ Kobot.logger.info @browser.title
108
+ end
109
+
110
+ def logout
111
+ if @browser.current_url.include? 'admin'
112
+ @browser.find_element(css: 'div.htBlock-header_logoutButton').click
113
+ else
114
+ @wait.until { @browser.find_element(id: 'menu_icon') }.click
115
+ @wait.until { @browser.find_element(link: 'ログアウト') }.click
116
+ @browser.switch_to.alert.accept
117
+ end
118
+ Kobot.logger.info 'Logout successful'
119
+ end
120
+
121
+ def read_today_record
122
+ @wait.until { @browser.find_element(id: 'menu_icon') }.click
123
+ @wait.until { @browser.find_element(link: 'タイムカード') }.click
124
+
125
+ time_table = @wait.until { @browser.find_element(css: 'div.htBlock-adjastableTableF_inner > table') }
126
+ time_table.find_elements(css: 'tbody > tr').each do |tr|
127
+ date_cell = tr.find_element(css: 'td.htBlock-scrollTable_day')
128
+ next unless date_cell.text.include? @today
129
+
130
+ Kobot.logger.info('Reading today record')
131
+ @kot_today = date_cell.text
132
+ @kot_today_css_class = date_cell.attribute('class')
133
+ @kot_today_type = tr.find_element(css: 'td.work_day_type').text
134
+ @kot_today_clock_in = tr.find_element(css: 'td.start_end_timerecord[data-ht-sort-index="START_TIMERECORD"]').text
135
+ @kot_today_clock_out = tr.find_element(css: 'td.start_end_timerecord[data-ht-sort-index="END_TIMERECORD"]').text
136
+ Kobot.logger.debug do
137
+ {
138
+ kot_toay: @kot_today,
139
+ kot_today_css_class: @kot_today_css_class,
140
+ kot_today_type: @kot_today_type,
141
+ kot_today_clock_in: @kot_today_clock_in,
142
+ kot_today_clock_out: @kot_today_clock_out
143
+ }
144
+ end
145
+ break
146
+ end
147
+ end
148
+
149
+ def verify_today_record!
150
+ raise KotRecordError, "Today=#{@today} is not found on kot." if @kot_today.strip.empty?
151
+ raise KotRecordError, "Today=#{@today} is marked as weekend on kot: #{@kot_today}" if kot_weekend?
152
+ raise KotRecordError, "Today=#{@today} is marked as public holiday on kot: #{@kot_today}" if kot_public_holiday?
153
+ end
154
+
155
+ def clock_in!
156
+ Kobot.logger.warn("Clock in during the afternoon: #{@now}") if @now.hour > 12
157
+ if @kot_today_clock_in.strip.empty?
158
+ click_clock_in_button
159
+ return if Config.dryrun
160
+
161
+ read_today_record
162
+ raise KotClockInError, 'Clock in operation seems to have failed' if @kot_today_clock_in.strip.empty?
163
+
164
+ Kobot.logger.info("Clock in successful: #{@kot_today_clock_in}")
165
+ Mailer.send(clock_notify_message(clock: :in))
166
+ else
167
+ Kobot.logger.warn("Clock in done already: #{@kot_today_clock_in}")
168
+ end
169
+ end
170
+
171
+ def clock_out!
172
+ Kobot.logger.warn("Clock out during the morning: #{@now}") if @now.hour <= 12
173
+ unless Config.dryrun
174
+ if @kot_today_clock_in.strip.empty?
175
+ raise KotClockOutError,
176
+ "!!!No clock in record for today=#{@kot_today}!!!"
177
+ end
178
+ end
179
+
180
+ if @kot_today_clock_out.strip.empty?
181
+ click_clock_out_button
182
+ return if Config.dryrun
183
+
184
+ read_today_record
185
+ raise KotClockOutError, 'Clock out operation seems to have failed' if @kot_today_clock_out.strip.empty?
186
+
187
+ Kobot.logger.info("Clock out successful: #{@kot_today_clock_out}")
188
+ Mailer.send(clock_notify_message(clock: :out))
189
+ else
190
+ Kobot.logger.warn("Clock out done already: #{@kot_today_clock_out}")
191
+ end
192
+ end
193
+
194
+ def click_clock_in_button
195
+ @browser.get @top_url
196
+ clock_in_button = @wait.until { @browser.find_element(css: 'div.record-clock-in') }
197
+ if Config.dryrun
198
+ Kobot.logger.info('[Dryrun] clock in button (出勤) would have been clicked')
199
+ else
200
+ clock_in_button.click
201
+ end
202
+ end
203
+
204
+ def click_clock_out_button
205
+ @browser.get @top_url
206
+ clock_out_button = @wait.until { @browser.find_element(css: 'div.record-clock-out') }
207
+ if Config.dryrun
208
+ Kobot.logger.info('[Dryrun] clock out button (退勤) would have been clicked')
209
+ else
210
+ clock_out_button.click
211
+ end
212
+ end
213
+
214
+ def weekend?
215
+ @now.saturday? || @now.sunday?
216
+ end
217
+
218
+ def holiday?
219
+ return false unless Config.skip
220
+ return false unless Config.skip.respond_to? :include?
221
+
222
+ Config.skip.include? @now.strftime('%F')
223
+ end
224
+
225
+ def kot_weekend?
226
+ %w[土 日].any? { |kanji| @kot_today&.include? kanji }
227
+ end
228
+
229
+ def kot_public_holiday?
230
+ return true if @kot_today_type&.include? '休日'
231
+
232
+ kot_today_highlighted = %w[sunday saturday].any? do |css|
233
+ @kot_today_css_class&.include? css
234
+ end
235
+ if kot_today_highlighted
236
+ Kobot.logger.warn(
237
+ "Today=#{@kot_today} is highlighted (holiday) but not marked as 休日"
238
+ )
239
+ end
240
+ kot_today_highlighted
241
+ end
242
+
243
+ def clock_notify_message(clock: nil, status: :success)
244
+ color = status == :success ? 'green' : 'red'
245
+ message = [
246
+ "<b>Date:</b> #{@today}",
247
+ "<b>Status:</b> <span style='color:#{color}'>#{status}</span>"
248
+ ]
249
+ message << "<b>Clock_in:</b> #{@kot_today_clock_in}" if clock
250
+ message << "<b>Clock_out:</b> #{@kot_today_clock_out}" if clock == :out
251
+ message.join('<br>')
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobot
4
+
5
+ class KotRecordError < StandardError
6
+ end
7
+
8
+ class KotClockInError < StandardError
9
+ end
10
+
11
+ class KotClockOutError < StandardError
12
+ end
13
+
14
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'logger'
5
+
6
+ # Code adapted conveniently from webdrivers for consistency
7
+ # https://github.com/titusfortner/webdrivers/blob/master/lib/webdrivers/logger.rb
8
+
9
+ module Kobot
10
+ #
11
+ # @example Enable full logging
12
+ # Kobot.logger.level = :debug
13
+ #
14
+ # @example Log to file
15
+ # Kobot.logger.output = 'kobot.log'
16
+ #
17
+ # @example Use logger manually
18
+ # Kobot.logger.info('This is info message')
19
+ # Kobot.logger.warn('This is warning message')
20
+ #
21
+ class Logger
22
+ extend Forwardable
23
+ include ::Logger::Severity
24
+
25
+ def_delegators :@logger, :debug, :debug?,
26
+ :info, :info?,
27
+ :warn, :warn?,
28
+ :error, :error?,
29
+ :fatal, :fatal?,
30
+ :level
31
+
32
+ def initialize
33
+ @logger = create_logger($stdout)
34
+ end
35
+
36
+ def output=(io)
37
+ # `Logger#reopen` was added in Ruby 2.3
38
+ if @logger.respond_to?(:reopen)
39
+ @logger.reopen(io)
40
+ else
41
+ @logger = create_logger(io)
42
+ end
43
+ end
44
+
45
+ #
46
+ # For Ruby < 2.3 compatibility
47
+ # Based on https://github.com/ruby/ruby/blob/ruby_2_3/lib/logger.rb#L250
48
+ #
49
+
50
+ def level=(severity)
51
+ if severity.is_a?(Integer)
52
+ @logger.level = severity
53
+ else
54
+ case severity.to_s.downcase
55
+ when 'debug'
56
+ @logger.level = DEBUG
57
+ when 'info'
58
+ @logger.level = INFO
59
+ when 'warn'
60
+ @logger.level = WARN
61
+ when 'error'
62
+ @logger.level = ERROR
63
+ when 'fatal'
64
+ @logger.level = FATAL
65
+ when 'unknown'
66
+ @logger.level = UNKNOWN
67
+ else
68
+ raise ArgumentError, "invalid log level: #{severity}"
69
+ end
70
+ end
71
+ end
72
+
73
+ #
74
+ # Returns IO object used by logger internally.
75
+ #
76
+ # Normally, we would have never needed it, but we want to
77
+ # use it as IO object for all child processes to ensure their
78
+ # output is redirected there.
79
+ #
80
+ # It is only used in debug level, in other cases output is suppressed.
81
+ #
82
+ # @api private
83
+ #
84
+ def io
85
+ @logger.instance_variable_get(:@logdev).instance_variable_get(:@dev)
86
+ end
87
+
88
+ #
89
+ # Marks code as deprecated with replacement.
90
+ #
91
+ # @param [String] old
92
+ # @param [String] new
93
+ #
94
+ def deprecate(old, new)
95
+ warn "[DEPRECATION] #{old} is deprecated. Use #{new} instead."
96
+ end
97
+
98
+ private
99
+
100
+ def create_logger(output)
101
+ logger = ::Logger.new(output)
102
+ logger.progname = Module.nesting.last.to_s
103
+ logger.level = ($DEBUG ? DEBUG : Config.loglevel)
104
+ logger.formatter = proc do |severity, time, progname, msg|
105
+ "#{time.strftime('%F %T')} #{severity} #{progname} #{msg}\n"
106
+ end
107
+
108
+ logger
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/smtp'
4
+
5
+ module Kobot
6
+
7
+ # Responsible for sending email notifications in SMTP with Gmail
8
+ class Mailer
9
+ class << self
10
+
11
+ # Sends email in preconfigured Gmail SMTP credential and to the recipient
12
+ # configured by #{Config.gmail_notify_to} or self if not configured, with
13
+ # email subject set by #{Config.gmail_notify_subject}.
14
+ #
15
+ # Whether the email is actually sent or not is dependent on the value of
16
+ # #{Config.gmail_notify_enabled}, and when it is set to false, the email
17
+ # message will be printed in logging instead.
18
+ #
19
+ # @param body The email message body to send
20
+ def send(body)
21
+ from = Credential.gmail_id
22
+ to = Config.gmail_notify_to || from
23
+ subject = Config.gmail_notify_subject
24
+ message = compose(from, to, subject, body)
25
+ unless Config.gmail_notify_enabled
26
+ Kobot.logger.info "This email notification would have been sent:\n#{message}"
27
+ return
28
+ end
29
+ smtp = Net::SMTP.new(
30
+ Config.gmail_smtp_address,
31
+ Config.gmail_smtp_port
32
+ )
33
+ smtp.enable_starttls_auto
34
+ smtp.start(
35
+ 'localhost',
36
+ Credential.gmail_id,
37
+ Credential.gmail_password,
38
+ :plain
39
+ ) do
40
+ smtp.send_message message, from, to
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def compose(from, to, subject, body)
47
+ <<~END_OF_MESSAGE
48
+ From: <#{from}>
49
+ To: <#{to}>
50
+ MIME-Version: 1.0
51
+ Content-type: text/html
52
+ Subject: #{subject}
53
+ Date: #{Time.now.getlocal(Config.kot_timezone_offset)}
54
+
55
+ #{body}
56
+ END_OF_MESSAGE
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Kobot
6
+
7
+ # Responsible for parsing the command line options for custom execution.
8
+ class Option
9
+ class << self
10
+
11
+ # Parses command line options and returns a hash containing the options.
12
+ def parse!
13
+ options = {}
14
+ opt_parser = OptionParser.new do |opt|
15
+ opt.banner = "Usage: #{$PROGRAM_NAME} [options]"
16
+
17
+ opt.on('-c', '--clock CLOCK', 'The clock action: in, out') do |clock|
18
+ options[:clock] = clock
19
+ end
20
+
21
+ opt.on('-l', '--loglevel [LEVEL]', 'Specify log level: debug, info, warn, error. Default is info') do |level|
22
+ options[:loglevel] = level
23
+ end
24
+
25
+ opt.on('-s', '--skip [D1,D2,D3]', Array,
26
+ 'Specify dates to skip clock in/out with date format YYYY-MM-DD and',
27
+ 'multiple values separated by comma, such as: 2020-05-01,2020-12-31') do |skip|
28
+ options[:skip] = skip
29
+ end
30
+
31
+ opt.on('-t', '--to [TO]',
32
+ 'Email address to send notification to. By default it is sent to',
33
+ 'the same self email account used in SMTP config as the sender') do |to|
34
+ options[:to] = to
35
+ end
36
+
37
+ opt.on('-n', '--notify', 'Enable email notification') do |notify|
38
+ options[:notify] = notify
39
+ end
40
+
41
+ opt.on('-d', '--dryrun', 'Run the process without actual clock in/out') do |dryrun|
42
+ options[:dryrun] = dryrun
43
+ end
44
+
45
+ opt.on('-x', '--headless', 'Start browser in headless mode') do |headless|
46
+ options[:headless] = headless
47
+ end
48
+
49
+ opt.on('-g', '--geolocation', 'Allow browser to use geolocation') do |geolocation|
50
+ options[:geolocation] = geolocation
51
+ end
52
+
53
+ opt.on_tail('-h', '--help', 'Show this help message') do
54
+ puts opt
55
+ exit 0
56
+ end
57
+
58
+ opt.on_tail('-v', '--version', 'Show current version') do
59
+ puts VERSION
60
+ exit 0
61
+ end
62
+ end
63
+ opt_parser.parse! ARGV
64
+ raise OptionParser::MissingArgument, 'The clock option is required' if options[:clock].nil?
65
+ raise OptionParser::InvalidArgument, 'The clock option must be either: in, out' unless %w[in out].include? options[:clock]
66
+
67
+ options
68
+ rescue OptionParser::MissingArgument, OptionParser::InvalidArgument => e
69
+ puts e
70
+ puts opt_parser.help
71
+ exit 1
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobot
4
+ VERSION = '1.0.0'
5
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kobot
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Andy Jiang
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2020-08-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: webdrivers
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '12.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '12.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ description: |2
56
+ Kobot is a simple tool to automate the clock in or clock out operation on the web service
57
+ provided by [KING OF TIME](kingtime.jp) by leveraging [Selenium WebDriver](selenium.dev),
58
+ and with Google Gmail service email notification can also be sent to notify the results.
59
+ email:
60
+ - yuanjiang@outlook.com
61
+ executables:
62
+ - kobot
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - ".gitignore"
67
+ - ".travis.yml"
68
+ - CHANGELOG.md
69
+ - CODE_OF_CONDUCT.md
70
+ - Gemfile
71
+ - LICENSE.txt
72
+ - README.md
73
+ - Rakefile
74
+ - bin/console
75
+ - bin/setup
76
+ - exe/kobot
77
+ - kobot.gemspec
78
+ - lib/kobot.rb
79
+ - lib/kobot/config.rb
80
+ - lib/kobot/credential.rb
81
+ - lib/kobot/engine.rb
82
+ - lib/kobot/exception.rb
83
+ - lib/kobot/logger.rb
84
+ - lib/kobot/mailer.rb
85
+ - lib/kobot/option.rb
86
+ - lib/kobot/version.rb
87
+ homepage: https://github.com/yuan-jiang/kobot
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ allowed_push_host: https://rubygems.org/
92
+ homepage_uri: https://github.com/yuan-jiang/kobot
93
+ source_code_uri: https://github.com/yuan-jiang/kobot
94
+ changelog_uri: https://github.com/yuan-jiang/kobot/blob/master/CHANGELOG.md
95
+ post_install_message:
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: 2.3.0
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubygems_version: 3.0.3
111
+ signing_key:
112
+ specification_version: 4
113
+ summary: Kobot automates the clock in/out of KING OF TIME.
114
+ test_files: []