at_coder_friends 0.5.0 → 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
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