jlpt 1.0.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.ja.md +246 -0
- data/README.md +224 -0
- data/assets/logo.png +0 -0
- data/bin/jlpt +7 -0
- data/lib/jlpt/active_model/validator.rb +43 -0
- data/lib/jlpt/analyzers/comparer.rb +44 -0
- data/lib/jlpt/analyzers/conjugation.rb +60 -0
- data/lib/jlpt/analyzers/corpus_stats.rb +38 -0
- data/lib/jlpt/analyzers/dokkai.rb +53 -0
- data/lib/jlpt/analyzers/exam_readiness.rb +52 -0
- data/lib/jlpt/analyzers/grammar.rb +50 -0
- data/lib/jlpt/analyzers/jukugo.rb +46 -0
- data/lib/jlpt/analyzers/loanwords.rb +54 -0
- data/lib/jlpt/analyzers/onomatopeia.rb +29 -0
- data/lib/jlpt/analyzers/pitch_accent.rb +51 -0
- data/lib/jlpt/analyzers/pos_profiler.rb +47 -0
- data/lib/jlpt/analyzers/simplifier.rb +48 -0
- data/lib/jlpt/analyzers/style_profiler.rb +62 -0
- data/lib/jlpt/analyzers/syntax.rb +43 -0
- data/lib/jlpt/cli.rb +121 -0
- data/lib/jlpt/core/analysis_result.rb +45 -0
- data/lib/jlpt/core/analyzer.rb +58 -0
- data/lib/jlpt/core/cache_manager.rb +90 -0
- data/lib/jlpt/core/config.rb +45 -0
- data/lib/jlpt/core/dictionary.rb +113 -0
- data/lib/jlpt/core/engine.rb +142 -0
- data/lib/jlpt/core/kanji.rb +54 -0
- data/lib/jlpt/core/kanji_details.rb +60 -0
- data/lib/jlpt/core/preprocessor.rb +48 -0
- data/lib/jlpt/core/tokenizer.rb +64 -0
- data/lib/jlpt/core/vocab_classifier.rb +48 -0
- data/lib/jlpt/data/kanji.json +2213 -0
- data/lib/jlpt/data/vocab.json +50298 -0
- data/lib/jlpt/formatters/batch_processor.rb +41 -0
- data/lib/jlpt/formatters/exporter.rb +55 -0
- data/lib/jlpt/formatters/furigana.rb +69 -0
- data/lib/jlpt/formatters/reporter.rb +72 -0
- data/lib/jlpt/version.rb +5 -0
- data/lib/jlpt.rb +50 -0
- metadata +184 -0
data/lib/jlpt/cli.rb
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module JLPT
|
|
7
|
+
# Command Line Interface runner for JLPT gem.
|
|
8
|
+
module CLI
|
|
9
|
+
VALID_COMMANDS = %w[analyze furigana export batch kanji conjugate simplify stats].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def run(args = ARGV)
|
|
13
|
+
options = { format: :text, target_level: :n4 }
|
|
14
|
+
parser = build_option_parser(options)
|
|
15
|
+
parser.parse!(args)
|
|
16
|
+
return 0 if options[:exit_early]
|
|
17
|
+
|
|
18
|
+
command = args.shift || 'analyze'
|
|
19
|
+
return warn_unknown(command) unless VALID_COMMANDS.include?(command.to_s.downcase)
|
|
20
|
+
|
|
21
|
+
execute_command(command, read_input(args), options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def build_option_parser(opts_hash)
|
|
27
|
+
OptionParser.new do |opts|
|
|
28
|
+
opts.banner = 'Usage: jlpt [command] [options] <text or filepath>'
|
|
29
|
+
opts.on('-f', '--format FMT', %i[text json html markdown]) { |f| opts_hash[:format] = f }
|
|
30
|
+
opts.on('-t', '--target LVL', %i[n5 n4 n3 n2 n1]) { |l| opts_hash[:target_level] = l }
|
|
31
|
+
add_meta_flags(opts, opts_hash)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def add_meta_flags(opts, opts_hash)
|
|
36
|
+
opts.on('-v', '--version') do
|
|
37
|
+
puts "jlpt version #{JLPT::VERSION}"
|
|
38
|
+
opts_hash[:exit_early] = true
|
|
39
|
+
end
|
|
40
|
+
opts.on('-h', '--help') do
|
|
41
|
+
puts opts
|
|
42
|
+
opts_hash[:exit_early] = true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def warn_unknown(command)
|
|
47
|
+
warn "Unknown command: #{command}"
|
|
48
|
+
1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def execute_command(command, input, options)
|
|
52
|
+
cmd = command.to_s.downcase
|
|
53
|
+
if %w[analyze furigana export].include?(cmd)
|
|
54
|
+
execute_core_command(cmd, input, options)
|
|
55
|
+
else
|
|
56
|
+
execute_module_command(cmd, input, options)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def execute_core_command(cmd, input, options)
|
|
61
|
+
case cmd
|
|
62
|
+
when 'analyze' then output_analysis(JLPT.analyze(input), options[:format])
|
|
63
|
+
when 'furigana' then puts Furigana.render(input, format: options[:format] == :html ? :html : :markdown)
|
|
64
|
+
when 'export' then puts Exporter.to_anki_tsv(input, target_level: options[:target_level])
|
|
65
|
+
end
|
|
66
|
+
0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def execute_module_command(cmd, input, options)
|
|
70
|
+
data = parse_module_data(cmd, input)
|
|
71
|
+
options[:format] == :json ? puts(JSON.pretty_generate(data)) : puts(data.inspect)
|
|
72
|
+
0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_module_data(cmd, input)
|
|
76
|
+
case cmd
|
|
77
|
+
when 'batch' then parse_batch_input(input)
|
|
78
|
+
when 'kanji' then KanjiDetails.details_for_text(input)
|
|
79
|
+
when 'conjugate' then Conjugation.analyze(input)
|
|
80
|
+
when 'simplify' then Simplifier.simplify(input)
|
|
81
|
+
when 'stats' then CorpusStats.analyze(input)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_batch_input(input)
|
|
86
|
+
parsed = begin
|
|
87
|
+
JSON.parse(input)
|
|
88
|
+
rescue StandardError
|
|
89
|
+
input
|
|
90
|
+
end
|
|
91
|
+
BatchProcessor.process(parsed)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def read_input(args)
|
|
95
|
+
return '' if args.empty? && $stdin.tty?
|
|
96
|
+
return $stdin.read if args.empty?
|
|
97
|
+
|
|
98
|
+
File.exist?(args.first) ? File.read(args.first) : args.join(' ')
|
|
99
|
+
rescue StandardError
|
|
100
|
+
''
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def output_analysis(result, format)
|
|
104
|
+
case format
|
|
105
|
+
when :json then puts JSON.pretty_generate(result.to_h)
|
|
106
|
+
when :html then puts Reporter.render_html(result)
|
|
107
|
+
when :markdown then puts Reporter.render_markdown(result)
|
|
108
|
+
else print_analysis_summary(result)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def print_analysis_summary(result)
|
|
113
|
+
puts "Recommended Level: #{result.recommended_level.to_s.upcase}"
|
|
114
|
+
puts "Difficulty Score: #{result.score} / 100"
|
|
115
|
+
puts "Word Count: #{result.word_count}"
|
|
116
|
+
puts "Sentence Count: #{result.sentence_count}"
|
|
117
|
+
puts "Kanji Density: #{(result.kanji_density * 100).round(1)}%"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Value Object encapsulating JLPT text analysis results.
|
|
5
|
+
#
|
|
6
|
+
# Supports both method dot-notation and Hash subscripting access.
|
|
7
|
+
#
|
|
8
|
+
class AnalysisResult
|
|
9
|
+
attr_reader :text, :recommended_level, :score, :kanji_density,
|
|
10
|
+
:vocab_distribution, :kanji_breakdown,
|
|
11
|
+
:sentence_count, :word_count
|
|
12
|
+
|
|
13
|
+
def initialize(attrs = {})
|
|
14
|
+
@text = attrs.fetch(:text, '')
|
|
15
|
+
@recommended_level = attrs.fetch(:recommended_level, :n5)
|
|
16
|
+
@score = attrs.fetch(:score, 0.0)
|
|
17
|
+
@kanji_density = attrs.fetch(:kanji_density, 0.0)
|
|
18
|
+
@vocab_distribution = attrs.fetch(:vocab_distribution, {})
|
|
19
|
+
@kanji_breakdown = attrs.fetch(:kanji_breakdown, {})
|
|
20
|
+
@sentence_count = attrs.fetch(:sentence_count, 0)
|
|
21
|
+
@word_count = attrs.fetch(:word_count, 0)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def [](key)
|
|
25
|
+
public_send(key) if respond_to?(key)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
{
|
|
30
|
+
text: @text,
|
|
31
|
+
recommended_level: @recommended_level,
|
|
32
|
+
score: @score,
|
|
33
|
+
kanji_density: @kanji_density,
|
|
34
|
+
vocab_distribution: @vocab_distribution,
|
|
35
|
+
kanji_breakdown: @kanji_breakdown,
|
|
36
|
+
sentence_count: @sentence_count,
|
|
37
|
+
word_count: @word_count
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def inspect
|
|
42
|
+
"#<JLPT::AnalysisResult level=#{@recommended_level.to_s.upcase} score=#{@score}>"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Core Analyzer Engine computing overall text difficulty and JLPT level recommendation.
|
|
5
|
+
#
|
|
6
|
+
module Analyzer
|
|
7
|
+
LEVEL_WEIGHTS = { n5: 1.0, n4: 2.0, n3: 3.0, n2: 4.0, n1: 5.0, non_jlpt: 2.5, out_of_jlpt: 3.5 }.freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
# Analyze Japanese text and return structured AnalysisResult
|
|
11
|
+
#
|
|
12
|
+
# @param text [String] Japanese text
|
|
13
|
+
# @return [JLPT::AnalysisResult] result object
|
|
14
|
+
def analyze(text)
|
|
15
|
+
cleaned = Preprocessor.clean(text)
|
|
16
|
+
return AnalysisResult.new(text: text.to_s) if cleaned.empty?
|
|
17
|
+
|
|
18
|
+
CacheManager.fetch(cleaned) { build_analysis_result(cleaned) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def build_analysis_result(cleaned)
|
|
24
|
+
sents = Preprocessor.sentences(cleaned)
|
|
25
|
+
tokens = Tokenizer.tokenize(cleaned)
|
|
26
|
+
vocab_dist = VocabClassifier.distribution(cleaned)
|
|
27
|
+
kanji_bd = Kanji.breakdown(cleaned)
|
|
28
|
+
score = calculate_score(vocab_dist, kanji_bd)
|
|
29
|
+
|
|
30
|
+
AnalysisResult.new(text: cleaned, recommended_level: score_to_level(score), score: score,
|
|
31
|
+
kanji_density: Kanji.density(cleaned), vocab_distribution: vocab_dist,
|
|
32
|
+
kanji_breakdown: kanji_bd, sentence_count: sents.length, word_count: tokens.length)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def calculate_score(v_dist, k_bd)
|
|
36
|
+
v_score = v_dist.sum { |l, p| (p / 100.0) * (LEVEL_WEIGHTS[l] || 1.0) }
|
|
37
|
+
k_score = k_bd.values.sum(&:length).positive? ? compute_kanji_score(k_bd) : 1.0
|
|
38
|
+
|
|
39
|
+
((((v_score * 0.6) + (k_score * 0.4)) - 1.0) / 4.0 * 100.0).clamp(0.0, 100.0).round(2)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def compute_kanji_score(kanji_bd)
|
|
43
|
+
total = kanji_bd.values.sum(&:length)
|
|
44
|
+
kanji_bd.sum { |l, c| (c.length.to_f / total) * (LEVEL_WEIGHTS[l] || 1.0) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def score_to_level(score)
|
|
48
|
+
case score
|
|
49
|
+
when 0.0..25.0 then :n5
|
|
50
|
+
when 25.01..35.0 then :n4
|
|
51
|
+
when 35.01..41.0 then :n3
|
|
52
|
+
when 41.01..46.0 then :n2
|
|
53
|
+
else :n1
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Thread-safe LRU Cache Manager for JLPT Text Analysis.
|
|
5
|
+
#
|
|
6
|
+
# Caches analysis results to prevent redundant processing for identical texts.
|
|
7
|
+
#
|
|
8
|
+
module CacheManager
|
|
9
|
+
MUTEX = Mutex.new
|
|
10
|
+
@enabled = true
|
|
11
|
+
DEFAULT_CAPACITY = 1000
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Fetch cached result or compute via block
|
|
15
|
+
#
|
|
16
|
+
# @param key [String] cache key (text)
|
|
17
|
+
# @yield Compute result if not cached
|
|
18
|
+
# @return [Object] cached or computed value
|
|
19
|
+
def fetch(key, &block)
|
|
20
|
+
return yield unless enabled? && key.is_a?(String) && !key.empty?
|
|
21
|
+
|
|
22
|
+
MUTEX.synchronize { read_or_write_cache(key, &block) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Clear the cache store
|
|
26
|
+
def clear
|
|
27
|
+
MUTEX.synchronize { store.clear }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Current cache size
|
|
31
|
+
#
|
|
32
|
+
# @return [Integer]
|
|
33
|
+
def size
|
|
34
|
+
MUTEX.synchronize { store.length }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Check if cache is enabled
|
|
38
|
+
#
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def enabled?
|
|
41
|
+
@enabled = true if @enabled.nil?
|
|
42
|
+
@enabled
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Enable caching
|
|
46
|
+
def enable!
|
|
47
|
+
@enabled = true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Disable caching
|
|
51
|
+
def disable!
|
|
52
|
+
@enabled = false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Maximum capacity
|
|
56
|
+
#
|
|
57
|
+
# @return [Integer]
|
|
58
|
+
def capacity
|
|
59
|
+
@capacity ||= DEFAULT_CAPACITY
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Set capacity
|
|
63
|
+
def capacity=(cap)
|
|
64
|
+
MUTEX.synchronize do
|
|
65
|
+
@capacity = cap
|
|
66
|
+
store.shift while store.length > cap
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def store
|
|
73
|
+
@store ||= {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read_or_write_cache(key)
|
|
77
|
+
if store.key?(key)
|
|
78
|
+
val = store.delete(key)
|
|
79
|
+
store[key] = val
|
|
80
|
+
return val
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
val = yield
|
|
84
|
+
store[key] = val
|
|
85
|
+
store.shift if store.length > capacity
|
|
86
|
+
val
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Main entry point for the JLPT Japanese text analysis library.
|
|
4
|
+
module JLPT
|
|
5
|
+
# Global Configuration Engine.
|
|
6
|
+
#
|
|
7
|
+
# Manages default options such as furigana format, cache capacity,
|
|
8
|
+
# and fallback behaviors.
|
|
9
|
+
#
|
|
10
|
+
class Config
|
|
11
|
+
attr_accessor :default_furigana_format, :cache_capacity, :mecab_fallback
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@default_furigana_format = :html
|
|
15
|
+
@cache_capacity = 1000
|
|
16
|
+
@mecab_fallback = true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Reset configuration to default values
|
|
20
|
+
def reset!
|
|
21
|
+
initialize
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Return global config instance
|
|
27
|
+
#
|
|
28
|
+
# @return [JLPT::Config]
|
|
29
|
+
def config
|
|
30
|
+
@config ||= Config.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Configure global JLPT library settings
|
|
34
|
+
#
|
|
35
|
+
# @yield [JLPT::Config]
|
|
36
|
+
def configure
|
|
37
|
+
yield(config) if block_given?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Reset configuration to defaults
|
|
41
|
+
def reset_config!
|
|
42
|
+
config.reset!
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module JLPT
|
|
6
|
+
# Thread-safe In-Memory Dictionary Manager for JLPT Vocabulary and Kanji Datasets.
|
|
7
|
+
#
|
|
8
|
+
# Loaded from Bluskyo JLPT_Vocabulary datasets (N5 to N1).
|
|
9
|
+
#
|
|
10
|
+
module Dictionary
|
|
11
|
+
DATA_DIR = File.expand_path('../data', __dir__)
|
|
12
|
+
MUTEX = Mutex.new
|
|
13
|
+
@loaded = false
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Look up the JLPT level of a vocabulary word (dictionary form or lemma)
|
|
17
|
+
#
|
|
18
|
+
# @param word [String] vocabulary word
|
|
19
|
+
# @return [Symbol, nil] :n5, :n4, :n3, :n2, :n1 or nil if unknown
|
|
20
|
+
def vocab_level(word)
|
|
21
|
+
ensure_loaded!
|
|
22
|
+
return nil if word.nil? || word.to_s.strip.empty?
|
|
23
|
+
|
|
24
|
+
@vocab_index[word.to_s.strip]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Look up reading (furigana) for a vocabulary word
|
|
28
|
+
#
|
|
29
|
+
# @param word [String]
|
|
30
|
+
# @return [String, nil]
|
|
31
|
+
def reading_for(word)
|
|
32
|
+
ensure_loaded!
|
|
33
|
+
return nil if word.nil? || word.to_s.strip.empty?
|
|
34
|
+
|
|
35
|
+
@readings_index[word.to_s.strip]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Look up the JLPT level of a single Kanji character
|
|
39
|
+
#
|
|
40
|
+
# @param char [String] single Kanji character
|
|
41
|
+
# @return [Symbol, nil] :n5, :n4, :n3, :n2, :n1 or nil if non-kanji / unknown
|
|
42
|
+
def kanji_level(char)
|
|
43
|
+
ensure_loaded!
|
|
44
|
+
return nil if char.nil? || char.to_s.strip.empty?
|
|
45
|
+
|
|
46
|
+
kanji_char = char.to_s.strip[0]
|
|
47
|
+
@kanji_index[kanji_char]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if a word exists in the JLPT vocabulary dataset
|
|
51
|
+
#
|
|
52
|
+
# @param word [String]
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def vocab_exists?(word)
|
|
55
|
+
!vocab_level(word).nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if a kanji exists in the JLPT kanji dataset
|
|
59
|
+
#
|
|
60
|
+
# @param char [String]
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def kanji_exists?(char)
|
|
63
|
+
!kanji_level(char).nil?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def ensure_loaded!
|
|
69
|
+
return if @loaded
|
|
70
|
+
|
|
71
|
+
MUTEX.synchronize do
|
|
72
|
+
return if @loaded
|
|
73
|
+
|
|
74
|
+
load_vocab_dataset!
|
|
75
|
+
load_kanji_dataset!
|
|
76
|
+
@loaded = true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def load_vocab_dataset!
|
|
81
|
+
@vocab_index = {}
|
|
82
|
+
@readings_index = {}
|
|
83
|
+
vocab_path = File.join(DATA_DIR, 'vocab.json')
|
|
84
|
+
return unless File.exist?(vocab_path)
|
|
85
|
+
|
|
86
|
+
data = JSON.parse(File.read(vocab_path))
|
|
87
|
+
data.each { |word, entries| index_vocab_entry(word, entries) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def index_vocab_entry(word, entries)
|
|
91
|
+
return unless entries.is_a?(Array) && (best = select_best_entry(entries))
|
|
92
|
+
|
|
93
|
+
@vocab_index[word] = :"n#{best['level'].to_i}"
|
|
94
|
+
@readings_index[word] = best['reading'] if best['reading']
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def select_best_entry(entries)
|
|
98
|
+
return nil if entries.empty?
|
|
99
|
+
|
|
100
|
+
entries.find { |e| e['level'].to_i == 5 } || entries.min_by { |e| e['level'].to_i }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def load_kanji_dataset!
|
|
104
|
+
@kanji_index = {}
|
|
105
|
+
kanji_path = File.join(DATA_DIR, 'kanji.json')
|
|
106
|
+
return unless File.exist?(kanji_path)
|
|
107
|
+
|
|
108
|
+
data = JSON.parse(File.read(kanji_path))
|
|
109
|
+
data.each { |char, lvl_num| @kanji_index[char] = :"n#{lvl_num}" }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Morphological Analysis Engine Wrapper for JLPT.
|
|
5
|
+
#
|
|
6
|
+
# Uses MeCab (`natto` gem) if installed on system, with automatic fallback
|
|
7
|
+
# to a pure-Ruby morphological segmentation engine.
|
|
8
|
+
#
|
|
9
|
+
module Engine
|
|
10
|
+
@mecab_available = nil
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Check if MeCab is available on host system
|
|
14
|
+
#
|
|
15
|
+
# @return [Boolean]
|
|
16
|
+
def mecab_available?
|
|
17
|
+
return @mecab_available unless @mecab_available.nil?
|
|
18
|
+
|
|
19
|
+
@mecab_available = begin
|
|
20
|
+
require 'natto'
|
|
21
|
+
!Natto::MeCab.new.nil?
|
|
22
|
+
rescue LoadError, StandardError
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Parse Japanese text into structured token node hashes
|
|
28
|
+
#
|
|
29
|
+
# @param text [String] Japanese input text
|
|
30
|
+
# @return [Array<Hash>] token nodes
|
|
31
|
+
def parse(text)
|
|
32
|
+
return [] if text.nil? || text.to_s.strip.empty?
|
|
33
|
+
|
|
34
|
+
cleaned = Preprocessor.clean(text)
|
|
35
|
+
return [] if cleaned.empty?
|
|
36
|
+
|
|
37
|
+
if mecab_available?
|
|
38
|
+
parse_with_mecab(Natto::MeCab.new, cleaned)
|
|
39
|
+
else
|
|
40
|
+
parse_with_fallback(cleaned)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def parse_with_mecab(mecab, text)
|
|
47
|
+
nodes = []
|
|
48
|
+
mecab.parse(text) do |n|
|
|
49
|
+
next if n.surface.empty? || n.feature.nil?
|
|
50
|
+
|
|
51
|
+
nodes << build_mecab_node(n)
|
|
52
|
+
end
|
|
53
|
+
nodes
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_mecab_node(node)
|
|
57
|
+
f = (node.feature || '').split(',')
|
|
58
|
+
s = node.surface
|
|
59
|
+
{ surface: s }.merge(fetch_pos_features(f), fetch_form_features(f, s))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def fetch_pos_features(feat)
|
|
63
|
+
{ pos: feat[0] || '*', pos_sub1: feat[1] || '*', pos_sub2: feat[2] || '*', pos_sub3: feat[3] || '*' }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fetch_form_features(feat, surface)
|
|
67
|
+
reading = feat[7] || surface
|
|
68
|
+
{ inflection_type: feat[4] || '*', inflection_form: feat[5] || '*',
|
|
69
|
+
dictionary_form: feat[6] || surface, reading: reading, pronunciation: feat[8] || reading }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_with_fallback(text)
|
|
73
|
+
PureRubyFallback.parse(text)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Pure Ruby Fallback Engine for environments without MeCab installed
|
|
78
|
+
module PureRubyFallback
|
|
79
|
+
KANJI_KATAKANA_REGEX = /[\u4E00-\u9FFF\u30A0-\u30FF]/.freeze
|
|
80
|
+
HIRAGANA_REGEX = /[\u3040-\u309F]/.freeze
|
|
81
|
+
WORD_SCAN_REGEX = /[\u4E00-\u9FFF]+|[\u3040-\u309F]+|[\u30A0-\u30FF]+|[a-zA-Z0-9]+|\S/.freeze
|
|
82
|
+
|
|
83
|
+
class << self
|
|
84
|
+
def parse(text)
|
|
85
|
+
return [] if text.nil? || text.to_s.strip.empty?
|
|
86
|
+
|
|
87
|
+
text.scan(WORD_SCAN_REGEX).flat_map { |token| process_fallback_token(token) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def process_fallback_token(token)
|
|
93
|
+
if token.match?(/^[\u4E00-\u9FFF]{4,}$/) && !Dictionary.vocab_exists?(token)
|
|
94
|
+
split_kanji_compound(token).map { |sub| build_fallback_node(sub) }
|
|
95
|
+
else
|
|
96
|
+
[build_fallback_node(token)]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def split_kanji_compound(token)
|
|
101
|
+
chunks = []
|
|
102
|
+
i = 0
|
|
103
|
+
while i < token.length
|
|
104
|
+
chunk, step = find_next_chunk(token, i)
|
|
105
|
+
chunks << chunk
|
|
106
|
+
i += step
|
|
107
|
+
end
|
|
108
|
+
chunks
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def find_next_chunk(token, idx)
|
|
112
|
+
s3 = token[idx, 3]
|
|
113
|
+
return [s3, 3] if s3.length == 3 && Dictionary.vocab_exists?(s3)
|
|
114
|
+
|
|
115
|
+
s2 = token[idx, 2]
|
|
116
|
+
return [s2, 2] if s2.length == 2 && Dictionary.vocab_exists?(s2)
|
|
117
|
+
|
|
118
|
+
len = s2.length >= 2 ? 2 : 1
|
|
119
|
+
[token[idx, len], len]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_fallback_node(token)
|
|
123
|
+
{
|
|
124
|
+
surface: token, pos: detect_pos(token), pos_sub1: '*', pos_sub2: '*',
|
|
125
|
+
pos_sub3: '*', inflection_type: '*', inflection_form: '*',
|
|
126
|
+
dictionary_form: token, reading: token, pronunciation: token
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def detect_pos(token)
|
|
131
|
+
if token.match?(KANJI_KATAKANA_REGEX)
|
|
132
|
+
'名詞'
|
|
133
|
+
elsif token.match?(HIRAGANA_REGEX)
|
|
134
|
+
'助詞'
|
|
135
|
+
else
|
|
136
|
+
'記号'
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Dedicated Kanji Character Analyzer and Extractor.
|
|
5
|
+
#
|
|
6
|
+
# Identifies Kanji characters in Japanese text, maps them to JLPT levels (N5-N1),
|
|
7
|
+
# calculates kanji density, and extracts unique kanji filtered by level.
|
|
8
|
+
#
|
|
9
|
+
module Kanji
|
|
10
|
+
KANJI_REGEX = /[\u4E00-\u9FFF]/.freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Extract all Kanji characters from text
|
|
14
|
+
#
|
|
15
|
+
# @param text [String] Japanese text
|
|
16
|
+
# @param level [Symbol, String, nil] optional JLPT level filter (:n5, :n4, :n3, :n2, :n1)
|
|
17
|
+
# @return [Array<String>] array of unique Kanji characters
|
|
18
|
+
def extract(text, level: nil)
|
|
19
|
+
return [] if text.nil? || text.to_s.strip.empty?
|
|
20
|
+
|
|
21
|
+
kanjis = Preprocessor.clean(text).scan(KANJI_REGEX).uniq
|
|
22
|
+
return kanjis if level.nil?
|
|
23
|
+
|
|
24
|
+
target_level = level.to_s.downcase.to_sym
|
|
25
|
+
kanjis.select { |k| Dictionary.kanji_level(k) == target_level }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Calculate Kanji density ratio (Kanji characters / Total characters)
|
|
29
|
+
#
|
|
30
|
+
# @param text [String] Japanese text
|
|
31
|
+
# @return [Float] density ratio between 0.0 and 1.0
|
|
32
|
+
def density(text)
|
|
33
|
+
cleaned = Preprocessor.clean(text)
|
|
34
|
+
return 0.0 if cleaned.empty?
|
|
35
|
+
|
|
36
|
+
kanji_count = cleaned.scan(KANJI_REGEX).length
|
|
37
|
+
(kanji_count.to_f / cleaned.length).round(4)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Detailed breakdown of Kanji by JLPT level
|
|
41
|
+
#
|
|
42
|
+
# @param text [String] Japanese text
|
|
43
|
+
# @return [Hash{Symbol => Array<String>}] breakdown hash mapping levels to kanji arrays
|
|
44
|
+
def breakdown(text)
|
|
45
|
+
res = { n5: [], n4: [], n3: [], n2: [], n1: [], out_of_jlpt: [] }
|
|
46
|
+
extract(text).each do |k|
|
|
47
|
+
lvl = Dictionary.kanji_level(k)
|
|
48
|
+
res.key?(lvl) ? res[lvl] << k : res[:out_of_jlpt] << k
|
|
49
|
+
end
|
|
50
|
+
res
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|