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.
- checksums.yaml +4 -4
- data/.gitignore +5 -2
- data/.rubocop.yml +1 -0
- data/.rubocop_todo.yml +4 -1
- data/Gemfile.lock +16 -14
- data/Rakefile +2 -0
- data/at_coder_friends.gemspec +1 -0
- data/lib/at_coder_friends.rb +13 -5
- data/lib/at_coder_friends/cli.rb +1 -2
- data/lib/at_coder_friends/config_loader.rb +2 -2
- data/lib/at_coder_friends/context.rb +3 -3
- data/lib/at_coder_friends/cxx_generator.rb +7 -13
- data/lib/at_coder_friends/emitter.rb +0 -4
- data/lib/at_coder_friends/parser/constraints_parser.rb +26 -0
- data/lib/at_coder_friends/parser/format_parser.rb +154 -0
- data/lib/at_coder_friends/parser/main.rb +16 -0
- data/lib/at_coder_friends/parser/page_parser.rb +119 -0
- data/lib/at_coder_friends/path_util.rb +10 -0
- data/lib/at_coder_friends/problem.rb +11 -14
- data/lib/at_coder_friends/scraping/agent.rb +77 -0
- data/lib/at_coder_friends/scraping/authentication.rb +71 -0
- data/lib/at_coder_friends/scraping/custom_test.rb +53 -0
- data/lib/at_coder_friends/scraping/session.rb +26 -0
- data/lib/at_coder_friends/scraping/submission.rb +31 -0
- data/lib/at_coder_friends/scraping/tasks.rb +39 -0
- data/lib/at_coder_friends/test_runner/base.rb +123 -0
- data/lib/at_coder_friends/test_runner/judge.rb +44 -0
- data/lib/at_coder_friends/test_runner/sample.rb +35 -0
- data/lib/at_coder_friends/verifier.rb +4 -2
- data/lib/at_coder_friends/version.rb +1 -1
- data/tasks/regression.rake +163 -0
- metadata +30 -7
- data/lib/at_coder_friends/format_parser.rb +0 -151
- data/lib/at_coder_friends/judge_test_runner.rb +0 -34
- data/lib/at_coder_friends/sample_test_runner.rb +0 -31
- data/lib/at_coder_friends/scraping_agent.rb +0 -265
- 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
|
-
|
4
|
+
SampleData = Struct.new(:no, :ext, :txt) do
|
5
5
|
def initialize(no, ext, txt)
|
6
|
-
no
|
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
|
-
|
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, :
|
23
|
-
attr_accessor :
|
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
|
-
@
|
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 <<
|
38
|
+
@smps << SampleData.new(no, ext, txt)
|
42
39
|
end
|
43
40
|
|
44
41
|
def add_src(ext, txt)
|
45
|
-
@srcs <<
|
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
|