at_coder_friends 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +5 -2
  3. data/.rubocop.yml +1 -0
  4. data/.rubocop_todo.yml +4 -1
  5. data/Gemfile.lock +16 -14
  6. data/Rakefile +2 -0
  7. data/at_coder_friends.gemspec +1 -0
  8. data/lib/at_coder_friends.rb +13 -5
  9. data/lib/at_coder_friends/cli.rb +1 -2
  10. data/lib/at_coder_friends/config_loader.rb +2 -2
  11. data/lib/at_coder_friends/context.rb +3 -3
  12. data/lib/at_coder_friends/cxx_generator.rb +7 -13
  13. data/lib/at_coder_friends/emitter.rb +0 -4
  14. data/lib/at_coder_friends/parser/constraints_parser.rb +26 -0
  15. data/lib/at_coder_friends/parser/format_parser.rb +154 -0
  16. data/lib/at_coder_friends/parser/main.rb +16 -0
  17. data/lib/at_coder_friends/parser/page_parser.rb +119 -0
  18. data/lib/at_coder_friends/path_util.rb +10 -0
  19. data/lib/at_coder_friends/problem.rb +11 -14
  20. data/lib/at_coder_friends/scraping/agent.rb +77 -0
  21. data/lib/at_coder_friends/scraping/authentication.rb +71 -0
  22. data/lib/at_coder_friends/scraping/custom_test.rb +53 -0
  23. data/lib/at_coder_friends/scraping/session.rb +26 -0
  24. data/lib/at_coder_friends/scraping/submission.rb +31 -0
  25. data/lib/at_coder_friends/scraping/tasks.rb +39 -0
  26. data/lib/at_coder_friends/test_runner/base.rb +123 -0
  27. data/lib/at_coder_friends/test_runner/judge.rb +44 -0
  28. data/lib/at_coder_friends/test_runner/sample.rb +35 -0
  29. data/lib/at_coder_friends/verifier.rb +4 -2
  30. data/lib/at_coder_friends/version.rb +1 -1
  31. data/tasks/regression.rake +163 -0
  32. metadata +30 -7
  33. data/lib/at_coder_friends/format_parser.rb +0 -151
  34. data/lib/at_coder_friends/judge_test_runner.rb +0 -34
  35. data/lib/at_coder_friends/sample_test_runner.rb +0 -31
  36. data/lib/at_coder_friends/scraping_agent.rb +0 -265
  37. data/lib/at_coder_friends/test_runner.rb +0 -104
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtCoderFriends
4
+ module Parser
5
+ # parses problem page and collect problem information
6
+ module PageParser
7
+ module_function
8
+
9
+ SECTION_TYPES = [
10
+ {
11
+ key: 'constraints',
12
+ patterns: [
13
+ '^制約$',
14
+ '^Constraints$'
15
+ ]
16
+ },
17
+ {
18
+ key: 'input format',
19
+ patterns: [
20
+ '^入出?力(形式)?$',
21
+ '^Inputs?\s*(,|and)?\s*(Outputs?)?\s*(Format)?$'
22
+ ]
23
+ },
24
+ {
25
+ key: 'sample input %<no>s',
26
+ patterns: [
27
+ '^入力例\s*(?<no>\d+)?$',
28
+ '^入力\s*(?<no>\d+)$',
29
+ '^Sample\s*Input\s*(?<no>\d+)?$',
30
+ '^Input\s*Example\s*(?<no>\d+)?$',
31
+ '^Input\s*(?<no>\d+)$'
32
+ ]
33
+ },
34
+ {
35
+ key: 'sample output %<no>s',
36
+ patterns: [
37
+ '^出力例\s*(?<no>\d+)?$',
38
+ '^出力\s*(?<no>\d+)$',
39
+ '^入力例\s*(?<no>\d+)?\s*に対する出力例$',
40
+ '^Sample\s*Output\s*(?<no>\d+)?$',
41
+ '^Output\s*Example\s*(?<no>\d+)?$',
42
+ '^Output\s*(?<no>\d+)$',
43
+ '^Output\s*for\s*(the)?\s*Sample\s*Input\s*(?<no>\d+)?$'
44
+ ]
45
+ }
46
+ ].freeze
47
+
48
+ def process(pbm)
49
+ sections = collect_sections(pbm.page)
50
+ apply_sections(pbm, sections)
51
+ end
52
+
53
+ def collect_sections(page)
54
+ sections = {}
55
+ %w[h2 h3].each do |tag|
56
+ page
57
+ .search(tag)
58
+ .each do |h|
59
+ key = find_key(h)
60
+ key && sections[key] ||= parse_section(h)
61
+ end
62
+ end
63
+ sections
64
+ end
65
+
66
+ def find_key(h)
67
+ title = normalize(h.content)
68
+ SECTION_TYPES.each do |grp|
69
+ grp[:patterns].each do |pat|
70
+ m = title.match(/#{pat}/i)
71
+ next unless m
72
+
73
+ no = m.names.include?('no') && m['no'] || '1'
74
+ return format(grp[:key], no: no)
75
+ end
76
+ end
77
+ nil
78
+ end
79
+
80
+ def parse_section(h)
81
+ text = ''
82
+ pre = nil
83
+ nx = h.next
84
+ while nx && nx.name != h.name
85
+ text += nx.content.gsub("\r\n", "\n")
86
+ %w[pre blockquote].each do |tag|
87
+ pre ||= (nx.name == tag ? nx : nx.search(tag)[0])
88
+ end
89
+ nx = nx.next
90
+ end
91
+ code = (pre&.text || '').lstrip.gsub("\r\n", "\n")
92
+ [text, code]
93
+ end
94
+
95
+ def apply_sections(pbm, sections)
96
+ sections.each do |key, (text, code)|
97
+ case key
98
+ when 'constraints'
99
+ pbm.desc += text
100
+ when 'input format'
101
+ pbm.desc += text
102
+ pbm.fmt = code
103
+ when /^sample input (?<no>\d+)$/
104
+ pbm.add_smp($LAST_MATCH_INFO[:no], :in, code)
105
+ when /^sample output (?<no>\d+)$/
106
+ pbm.add_smp($LAST_MATCH_INFO[:no], :exp, code)
107
+ end
108
+ end
109
+ end
110
+
111
+ def normalize(s)
112
+ s
113
+ .tr(' 0-9A-Za-z', ' 0-9A-Za-z')
114
+ .gsub(/[^一-龠_ぁ-ん_ァ-ヶーa-zA-Z0-9 ]/, '')
115
+ .strip
116
+ end
117
+ end
118
+ end
119
+ end
@@ -7,6 +7,7 @@ module AtCoderFriends
7
7
 
8
8
  SMP_DIR = 'data'
9
9
  CASES_DIR = 'cases'
10
+ TMP_DIR = '.tmp'
10
11
 
11
12
  def contest_name(path)
12
13
  dir = File.file?(path) ? File.dirname(path) : path
@@ -27,5 +28,14 @@ module AtCoderFriends
27
28
  def cases_dir(dir)
28
29
  File.join(dir, CASES_DIR)
29
30
  end
31
+
32
+ def tmp_dir(path)
33
+ dir = File.dirname(path)
34
+ File.join(dir, '.tmp')
35
+ end
36
+
37
+ def makedirs_unless(dir)
38
+ FileUtils.makedirs(dir) unless Dir.exist?(dir)
39
+ end
30
40
  end
31
41
  end
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtCoderFriends
4
- DataSample = Struct.new(:no, :ext, :txt) do
4
+ SampleData = Struct.new(:no, :ext, :txt) do
5
5
  def initialize(no, ext, txt)
6
- no = no.tr('0-9', '0-9').to_i
7
- txt = txt.lstrip.gsub("\r\n", "\n")
8
- super(no, ext, txt)
6
+ super(no.to_i, ext, txt)
9
7
  end
10
8
  end
11
9
 
@@ -15,34 +13,33 @@ module AtCoderFriends
15
13
  end
16
14
  end
17
15
 
18
- SourceSample = Struct.new(:ext, :txt)
16
+ Constraint = Struct.new(:name, :type, :value)
17
+
18
+ SourceCode = Struct.new(:ext, :txt)
19
19
 
20
20
  # holds problem information
21
21
  class Problem
22
- attr_reader :q, :fmt, :smps, :srcs
23
- attr_accessor :html, :desc, :defs
22
+ attr_reader :q, :smps, :srcs
23
+ attr_accessor :page, :desc, :fmt, :defs, :constraints
24
24
 
25
25
  def initialize(q)
26
26
  @q = q
27
- @html = ''
27
+ @page = nil
28
28
  @desc = ''
29
29
  @fmt = ''
30
30
  @smps = []
31
31
  @defs = []
32
+ @constraints = []
32
33
  @srcs = []
33
34
  yield self if block_given?
34
35
  end
35
36
 
36
- def fmt=(f)
37
- @fmt = f.lstrip.gsub("\r\n", "\n")
38
- end
39
-
40
37
  def add_smp(no, ext, txt)
41
- @smps << DataSample.new(no, ext, txt)
38
+ @smps << SampleData.new(no, ext, txt)
42
39
  end
43
40
 
44
41
  def add_src(ext, txt)
45
- @srcs << SourceSample.new(ext, txt)
42
+ @srcs << SourceCode.new(ext, txt)
46
43
  end
47
44
  end
48
45
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mechanize'
4
+ require 'logger'
5
+
6
+ module AtCoderFriends
7
+ module Scraping
8
+ # common functions for scraping
9
+ class Agent
10
+ include AtCoderFriends::PathUtil
11
+ include Session
12
+ include Authentication
13
+ include Tasks
14
+ include CustomTest
15
+ include Submission
16
+
17
+ BASE_URL = 'https://atcoder.jp/'
18
+
19
+ attr_reader :ctx, :agent
20
+
21
+ def initialize(ctx)
22
+ @ctx = ctx
23
+ @agent = Mechanize.new
24
+ agent.pre_connect_hooks << proc { sleep 0.1 }
25
+ agent.log = Logger.new(STDERR) if ctx.options[:debug]
26
+ load_session
27
+ end
28
+
29
+ def contest
30
+ @contest ||= contest_name(ctx.path)
31
+ end
32
+
33
+ def config
34
+ ctx.config
35
+ end
36
+
37
+ def common_url(path)
38
+ File.join(BASE_URL, path)
39
+ end
40
+
41
+ def contest_url(path = '')
42
+ File.join(BASE_URL, 'contests', contest, path)
43
+ end
44
+
45
+ def lang_id(ext)
46
+ config.dig('ext_settings', ext, 'submit_lang') || (
47
+ msg = <<~MSG
48
+ submit_lang for .#{ext} is not specified.
49
+ Available languages:
50
+ #{lang_list_txt || '(failed to fetch)'}
51
+ MSG
52
+ raise AppError, msg
53
+ )
54
+ end
55
+
56
+ def lang_list_txt
57
+ lang_list
58
+ &.map { |opt| "#{opt[:v]} - #{opt[:t]}" }
59
+ &.join("\n")
60
+ end
61
+
62
+ def lang_list
63
+ @lang_list ||= begin
64
+ page = fetch_with_auth(contest_url('custom_test'))
65
+ form = page.forms[1]
66
+ sel = form.field_with(name: 'data.LanguageId')
67
+ sel && sel
68
+ .options
69
+ .reject { |opt| opt.value.empty? }
70
+ .map do |opt|
71
+ { v: opt.value, t: opt.text }
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'io/console'
5
+
6
+ module AtCoderFriends
7
+ module Scraping
8
+ # fetch pages and
9
+ # authenticates user at login page if needed
10
+ module Authentication
11
+ XPATH_USERNAME = '//*[@id="navbar-collapse"]/ul[2]/li[2]/a'
12
+
13
+ def fetch_with_auth(url)
14
+ page = fetch_raw(url)
15
+ page.uri.path == '/login' && page = post_login(page)
16
+ page.uri.path == '/login' && (raise AppError, 'Authentication failed.')
17
+ show_username(page)
18
+ page
19
+ end
20
+
21
+ def fetch_raw(url)
22
+ begin
23
+ return agent.get(url)
24
+ rescue Mechanize::ResponseCodeError => e
25
+ raise e unless e.response_code == '404'
26
+ raise e if username_link(e.page)
27
+ end
28
+
29
+ agent.get(common_url('login') + '?continue=' + CGI.escape(url))
30
+ end
31
+
32
+ def post_login(page)
33
+ user, pass = read_auth
34
+ form = page.forms[1]
35
+ form.field_with(name: 'username').value = user
36
+ form.field_with(name: 'password').value = pass
37
+ form.submit
38
+ end
39
+
40
+ def read_auth
41
+ user = config['user'].to_s
42
+ if user.empty?
43
+ print('Enter username:')
44
+ user = STDIN.gets.chomp
45
+ end
46
+
47
+ pass = config['password'].to_s
48
+ if pass.empty?
49
+ print("Enter password for #{user}:")
50
+ pass = STDIN.noecho(&:gets).chomp
51
+ puts
52
+ end
53
+ [user, pass]
54
+ end
55
+
56
+ def show_username(page)
57
+ username_bak = @username
58
+ link = username_link(page)
59
+ @username = (link ? link.text.strip : '-')
60
+ return if @username == username_bak || @username == '-'
61
+
62
+ puts "Logged in as #{@username}"
63
+ end
64
+
65
+ def username_link(page)
66
+ link = page.search(XPATH_USERNAME)[0]
67
+ link && link[:href] == '#' && link
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module AtCoderFriends
6
+ module Scraping
7
+ # run tests on custom_test page
8
+ module CustomTest
9
+ include AtCoderFriends::PathUtil
10
+
11
+ def code_test(infile)
12
+ path, _dir, _prg, _base, ext, _q = split_prg_path(ctx.path)
13
+ lang = lang_id(ext)
14
+ src = File.read(path, encoding: Encoding::UTF_8)
15
+ data = File.read(infile)
16
+
17
+ post_custom_test(lang, src, data)
18
+ check_custom_test
19
+ end
20
+
21
+ def post_custom_test(lang, src, data)
22
+ page = fetch_with_auth(contest_url('custom_test'))
23
+ script = page.search('script').text
24
+ csrf_token = script.scan(/var csrfToken = "(.*)"/)[0][0]
25
+
26
+ page = agent.post(
27
+ contest_url('custom_test/submit/json'),
28
+ 'data.LanguageId' => lang,
29
+ 'sourceCode' => src,
30
+ 'input' => data,
31
+ 'csrf_token' => csrf_token
32
+ )
33
+
34
+ msg = page.body
35
+ raise AppError, msg unless msg.empty?
36
+ end
37
+
38
+ def check_custom_test
39
+ 100.times do
40
+ page = agent.get(contest_url('custom_test/json?reload=true'))
41
+ data = JSON.parse(page.body)
42
+ return nil unless data.is_a?(Hash) && data['Result']
43
+ return data if data.dig('Result', 'Status') == 3
44
+ return data unless data['Interval']
45
+
46
+ sleep 1.0 * data['Interval'] / 1000
47
+ end
48
+
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtCoderFriends
4
+ module Scraping
5
+ # session management for scraping
6
+ module Session
7
+ SESSION_STORE_FMT = File.join(
8
+ Dir.home, '.at_coder_friends', '%<user>s_session.yml'
9
+ )
10
+
11
+ def load_session
12
+ agent.cookie_jar.load(session_store) if File.exist?(session_store)
13
+ end
14
+
15
+ def save_session
16
+ dir = File.dirname(session_store)
17
+ Dir.mkdir(dir) unless Dir.exist?(dir)
18
+ agent.cookie_jar.save_as(session_store)
19
+ end
20
+
21
+ def session_store
22
+ @session_store ||= format(SESSION_STORE_FMT, user: config['user'])
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtCoderFriends
4
+ module Scraping
5
+ # submit sources on submit page
6
+ module Submission
7
+ include AtCoderFriends::PathUtil
8
+
9
+ def submit
10
+ path, _dir, prg, _base, ext, q = split_prg_path(ctx.path)
11
+ puts "***** submit #{prg} *****"
12
+ lang = lang_id(ext)
13
+ src = File.read(path, encoding: Encoding::UTF_8)
14
+
15
+ post_submit(q, lang, src)
16
+ end
17
+
18
+ def post_submit(q, lang, src)
19
+ page = fetch_with_auth(contest_url('submit'))
20
+ form = page.forms[1]
21
+ form.field_with(name: 'data.TaskScreenName') do |sel|
22
+ option = sel.options.find { |op| op.text.start_with?(q) }
23
+ option&.select || (raise AppError, "unknown problem:#{q}.")
24
+ end
25
+ form.add_field!('data.LanguageId', lang)
26
+ form.field_with(name: 'sourceCode').value = src
27
+ form.submit
28
+ end
29
+ end
30
+ end
31
+ end