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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Kanji Details Inspector.
|
|
5
|
+
#
|
|
6
|
+
# Provides stroke count, radical, On'yomi, Kun'yomi, English meanings, and JLPT levels for Kanji.
|
|
7
|
+
#
|
|
8
|
+
module KanjiDetails
|
|
9
|
+
KANJI_DATA = {
|
|
10
|
+
'私' => { character: '私', strokes: 7, radical: '禾', onyomi: %w[シ], kunyomi: %w[わたし], level: :n5 },
|
|
11
|
+
'学' => { character: '学', strokes: 8, radical: '子', onyomi: %w[ガク], kunyomi: %w[まな.ぶ], level: :n5 },
|
|
12
|
+
'校' => { character: '校', strokes: 10, radical: '木', onyomi: %w[コウ], kunyomi: [], level: :n5 },
|
|
13
|
+
'日' => { character: '日', strokes: 4, radical: '日', onyomi: %w[ニチ], kunyomi: %w[ひ], level: :n5 },
|
|
14
|
+
'本' => { character: '本', strokes: 5, radical: '木', onyomi: %w[ホン], kunyomi: %w[もと], level: :n5 },
|
|
15
|
+
'語' => { character: '語', strokes: 14, radical: '言', onyomi: %w[ゴ], kunyomi: %w[かた.る], level: :n5 },
|
|
16
|
+
'食' => { character: '食', strokes: 9, radical: '食', onyomi: %w[ショク], kunyomi: %w[た.べる], level: :n5 },
|
|
17
|
+
'飲' => { character: '飲', strokes: 12, radical: '食', onyomi: %w[イン], kunyomi: %w[の.む], level: :n5 },
|
|
18
|
+
'行' => { character: '行', strokes: 6, radical: '行', onyomi: %w[コウ], kunyomi: %w[い.く], level: :n5 },
|
|
19
|
+
'来' => { character: '来', strokes: 7, radical: '木', onyomi: %w[ライ], kunyomi: %w[く.る], level: :n5 }
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Lookup Kanji details for a single character
|
|
24
|
+
#
|
|
25
|
+
# @param char [String] Kanji character
|
|
26
|
+
# @return [Hash, nil] kanji details hash or default details
|
|
27
|
+
def lookup(char)
|
|
28
|
+
return nil if char.nil? || char.to_s.strip.empty?
|
|
29
|
+
|
|
30
|
+
kanji_char = char.to_s.strip[0]
|
|
31
|
+
return nil unless kanji_char.match?(/[\u4E00-\u9FFF]/)
|
|
32
|
+
|
|
33
|
+
level = Dictionary.kanji_level(kanji_char) || :out_of_jlpt
|
|
34
|
+
KANJI_DATA[kanji_char] || default_kanji_details(kanji_char, level)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get details for all unique kanji in text
|
|
38
|
+
#
|
|
39
|
+
# @param text [String] Japanese text
|
|
40
|
+
# @return [Array<Hash>] array of kanji detail hashes
|
|
41
|
+
def details_for_text(text)
|
|
42
|
+
kanji_list = Kanji.extract(text)
|
|
43
|
+
kanji_list.filter_map { |k| lookup(k) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def default_kanji_details(char, level)
|
|
49
|
+
{
|
|
50
|
+
character: char,
|
|
51
|
+
strokes: 8,
|
|
52
|
+
radical: 'undefined',
|
|
53
|
+
onyomi: [],
|
|
54
|
+
kunyomi: [],
|
|
55
|
+
level: level
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Text Preprocessor and Unicode Normalizer.
|
|
5
|
+
#
|
|
6
|
+
# Cleans input text by stripping HTML, Markdown, URLs, and code blocks,
|
|
7
|
+
# normalizes Unicode NFKC characters (Half-width/Full-width Kana),
|
|
8
|
+
# and segments Japanese text into sentences.
|
|
9
|
+
#
|
|
10
|
+
module Preprocessor
|
|
11
|
+
HTML_TAG_REGEX = /<[^>]*>/.freeze
|
|
12
|
+
MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\([^)]+\)/.freeze
|
|
13
|
+
MARKDOWN_HEADER_CODE_REGEX = /^#+\s*|`[^`]+`|```[\s\S]*?```/.freeze
|
|
14
|
+
URL_REGEX = %r{https?://\S+}.freeze
|
|
15
|
+
SENTENCE_SCAN_REGEX = /[^。!?!?\n]+[。!?!?\n]?/.freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Clean text by removing HTML, Markdown, and URLs
|
|
19
|
+
#
|
|
20
|
+
# @param text [String] Japanese raw text
|
|
21
|
+
# @return [String] cleaned and sanitized text
|
|
22
|
+
def clean(text)
|
|
23
|
+
return '' if text.nil? || text.to_s.strip.empty?
|
|
24
|
+
|
|
25
|
+
str = text.to_s.dup
|
|
26
|
+
str.gsub!(HTML_TAG_REGEX, '')
|
|
27
|
+
str.gsub!(URL_REGEX, '')
|
|
28
|
+
str.gsub!(MARKDOWN_LINK_REGEX, '\1')
|
|
29
|
+
str.gsub!(MARKDOWN_HEADER_CODE_REGEX, '')
|
|
30
|
+
str.unicode_normalize(:nfkc).strip
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Segment Japanese text into clean sentence arrays
|
|
34
|
+
#
|
|
35
|
+
# @param text [String] Japanese text
|
|
36
|
+
# @return [Array<String>] array of non-empty sentences
|
|
37
|
+
def sentences(text)
|
|
38
|
+
cleaned = clean(text)
|
|
39
|
+
return [] if cleaned.empty?
|
|
40
|
+
|
|
41
|
+
matches = cleaned.scan(SENTENCE_SCAN_REGEX)
|
|
42
|
+
result = matches.map(&:strip).reject(&:empty?)
|
|
43
|
+
|
|
44
|
+
result.empty? ? [cleaned] : result
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Tokenizer and Morphological Analyzer.
|
|
5
|
+
#
|
|
6
|
+
# Processes raw text through Preprocessor and Engine to extract structured
|
|
7
|
+
# tokens with lemmata, Part-Of-Speech (POS) categories, and dictionary forms.
|
|
8
|
+
#
|
|
9
|
+
module Tokenizer
|
|
10
|
+
POS_MAP = {
|
|
11
|
+
'名詞' => :noun,
|
|
12
|
+
'動詞' => :verb,
|
|
13
|
+
'形容詞' => :i_adjective,
|
|
14
|
+
'形状詞' => :na_adjective,
|
|
15
|
+
'副詞' => :adverb,
|
|
16
|
+
'助詞' => :particle,
|
|
17
|
+
'助動詞' => :auxiliary_verb,
|
|
18
|
+
'連体詞' => :pre_noun_adjective,
|
|
19
|
+
'感動詞' => :interjection,
|
|
20
|
+
'接続詞' => :conjunction,
|
|
21
|
+
'記号' => :symbol
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Tokenize text into structured token objects
|
|
26
|
+
#
|
|
27
|
+
# @param text [String] Japanese input text
|
|
28
|
+
# @return [Array<Hash>] array of token hashes
|
|
29
|
+
def tokenize(text)
|
|
30
|
+
cleaned = Preprocessor.clean(text)
|
|
31
|
+
return [] if cleaned.empty?
|
|
32
|
+
|
|
33
|
+
raw_nodes = Engine.parse(cleaned)
|
|
34
|
+
raw_nodes.map { |node| format_token(node) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Extract dictionary forms (lemmata) of words
|
|
38
|
+
#
|
|
39
|
+
# @param text [String] Japanese text
|
|
40
|
+
# @param filter_pos [Array<Symbol>] optional POS filter (e.g. [:noun, :verb])
|
|
41
|
+
# @return [Array<String>] array of dictionary forms
|
|
42
|
+
def lemmata(text, filter_pos: nil)
|
|
43
|
+
tokens = tokenize(text)
|
|
44
|
+
tokens = tokens.select { |t| filter_pos.include?(t[:pos_category]) } if filter_pos && !filter_pos.empty?
|
|
45
|
+
tokens.map { |t| t[:dictionary_form] }.compact.reject(&:empty?)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def format_token(node)
|
|
51
|
+
pos_cat = POS_MAP[node[:pos]] || :other
|
|
52
|
+
{
|
|
53
|
+
surface: node[:surface],
|
|
54
|
+
dictionary_form: node[:dictionary_form] || node[:surface],
|
|
55
|
+
reading: node[:reading] || node[:surface],
|
|
56
|
+
pos_raw: node[:pos],
|
|
57
|
+
pos_category: pos_cat,
|
|
58
|
+
pos_sub1: node[:pos_sub1],
|
|
59
|
+
inflection_form: node[:inflection_form]
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Vocabulary Classifier for JLPT Levels.
|
|
5
|
+
#
|
|
6
|
+
# Analyzes tokens in Japanese text and categorizes vocabulary into JLPT levels
|
|
7
|
+
# (N5 to N1) or Non-JLPT, computing distribution statistics and percentages.
|
|
8
|
+
#
|
|
9
|
+
module VocabClassifier
|
|
10
|
+
class << self
|
|
11
|
+
# Classify all vocabulary tokens in text by JLPT level
|
|
12
|
+
#
|
|
13
|
+
# @param text [String] Japanese text
|
|
14
|
+
# @return [Hash{Symbol => Array<String>}] level to vocabulary lemmata mapping
|
|
15
|
+
def classify(text)
|
|
16
|
+
result = { n5: [], n4: [], n3: [], n2: [], n1: [], non_jlpt: [] }
|
|
17
|
+
Tokenizer.lemmata(text).each do |lemma|
|
|
18
|
+
lvl = Dictionary.vocab_level(lemma)
|
|
19
|
+
result.key?(lvl) ? result[lvl] << lemma : result[:non_jlpt] << lemma
|
|
20
|
+
end
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Calculate percentage distribution of vocabulary across JLPT levels
|
|
25
|
+
#
|
|
26
|
+
# @param text [String] Japanese text
|
|
27
|
+
# @return [Hash{Symbol => Float}] level to percentage mapping
|
|
28
|
+
def distribution(text)
|
|
29
|
+
classified = classify(text)
|
|
30
|
+
total_words = classified.values.sum(&:length)
|
|
31
|
+
return empty_distribution if total_words.zero?
|
|
32
|
+
|
|
33
|
+
dist = {}
|
|
34
|
+
classified.each do |lvl, words|
|
|
35
|
+
pct = (words.length.to_f / total_words * 100).round(2)
|
|
36
|
+
dist[lvl] = pct
|
|
37
|
+
end
|
|
38
|
+
dist
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def empty_distribution
|
|
44
|
+
{ n5: 0.0, n4: 0.0, n3: 0.0, n2: 0.0, n1: 0.0, non_jlpt: 0.0 }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|