openphar 0.1.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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/README.adoc +221 -0
  3. data/lib/openphar/cli/main.rb +137 -0
  4. data/lib/openphar/cli.rb +11 -0
  5. data/lib/openphar/core/slug_generator.rb +107 -0
  6. data/lib/openphar/core.rb +11 -0
  7. data/lib/openphar/errors/configuration_error.rb +8 -0
  8. data/lib/openphar/errors/monograph_not_found_error.rb +15 -0
  9. data/lib/openphar/errors/parse_error.rb +18 -0
  10. data/lib/openphar/errors/unknown_format_error.rb +8 -0
  11. data/lib/openphar/errors/unknown_publisher_error.rb +8 -0
  12. data/lib/openphar/errors/unknown_type_error.rb +8 -0
  13. data/lib/openphar/errors/validation_error.rb +15 -0
  14. data/lib/openphar/errors.rb +17 -0
  15. data/lib/openphar/exporters/coverage_validator.rb +265 -0
  16. data/lib/openphar/exporters/neo4j/model_registry.rb +130 -0
  17. data/lib/openphar/exporters/neo4j/node_builder.rb +84 -0
  18. data/lib/openphar/exporters/neo4j/property_mapper.rb +154 -0
  19. data/lib/openphar/exporters/neo4j/relationship_builder.rb +87 -0
  20. data/lib/openphar/exporters/neo4j.rb +16 -0
  21. data/lib/openphar/exporters/neo4j_exporter.rb +199 -0
  22. data/lib/openphar/exporters.rb +15 -0
  23. data/lib/openphar/linkers/chp/cross_edition_linker.rb +101 -0
  24. data/lib/openphar/linkers/chp.rb +11 -0
  25. data/lib/openphar/linkers/cross_publisher_linker.rb +295 -0
  26. data/lib/openphar/linkers/herbapedia_linker.rb +125 -0
  27. data/lib/openphar/linkers.rb +13 -0
  28. data/lib/openphar/migrators/chp/classifier.rb +86 -0
  29. data/lib/openphar/migrators/chp.rb +15 -0
  30. data/lib/openphar/migrators/chp_migrator.rb +130 -0
  31. data/lib/openphar/migrators/jp_migrator.rb +345 -0
  32. data/lib/openphar/migrators/phint_migrator.rb +426 -0
  33. data/lib/openphar/migrators.rb +15 -0
  34. data/lib/openphar/models/ahp.rb +25 -0
  35. data/lib/openphar/models/amino_acid_monograph.rb +28 -0
  36. data/lib/openphar/models/api.rb +29 -0
  37. data/lib/openphar/models/assay_specification.rb +19 -0
  38. data/lib/openphar/models/base_entity.rb +221 -0
  39. data/lib/openphar/models/biological_substance_monograph.rb +31 -0
  40. data/lib/openphar/models/chemical_drug_monograph.rb +39 -0
  41. data/lib/openphar/models/chp/biologic.rb +28 -0
  42. data/lib/openphar/models/chp/chemical_preparation.rb +28 -0
  43. data/lib/openphar/models/chp/chemical_substance.rb +28 -0
  44. data/lib/openphar/models/chp/general_chapter.rb +30 -0
  45. data/lib/openphar/models/chp/monograph.rb +116 -0
  46. data/lib/openphar/models/chp/section.rb +37 -0
  47. data/lib/openphar/models/chp/tcm_crude_drug.rb +28 -0
  48. data/lib/openphar/models/chp/tcm_extract.rb +29 -0
  49. data/lib/openphar/models/chp/tcm_formulation.rb +28 -0
  50. data/lib/openphar/models/chp.rb +33 -0
  51. data/lib/openphar/models/crude_drug_monograph.rb +31 -0
  52. data/lib/openphar/models/edition.rb +41 -0
  53. data/lib/openphar/models/formulation_monograph.rb +31 -0
  54. data/lib/openphar/models/hkcmms.rb +26 -0
  55. data/lib/openphar/models/identification_specification.rb +19 -0
  56. data/lib/openphar/models/jp/kampo_formula.rb +94 -0
  57. data/lib/openphar/models/jp.rb +21 -0
  58. data/lib/openphar/models/limit.rb +54 -0
  59. data/lib/openphar/models/mineral_substance_monograph.rb +28 -0
  60. data/lib/openphar/models/monograph.rb +58 -0
  61. data/lib/openphar/models/ph_int/buffer_solution.rb +38 -0
  62. data/lib/openphar/models/ph_int/dosage_form_monograph.rb +43 -0
  63. data/lib/openphar/models/ph_int/radiopharmaceutical_monograph.rb +52 -0
  64. data/lib/openphar/models/ph_int/reagent.rb +50 -0
  65. data/lib/openphar/models/ph_int/reference_substance.rb +50 -0
  66. data/lib/openphar/models/ph_int/test_method.rb +50 -0
  67. data/lib/openphar/models/ph_int/test_solution.rb +39 -0
  68. data/lib/openphar/models/ph_int/volumetric_solution.rb +38 -0
  69. data/lib/openphar/models/ph_int.rb +37 -0
  70. data/lib/openphar/models/physical_specification.rb +17 -0
  71. data/lib/openphar/models/publisher.rb +98 -0
  72. data/lib/openphar/models/purity_specification.rb +16 -0
  73. data/lib/openphar/models/supplement.rb +31 -0
  74. data/lib/openphar/models/test_specification.rb +29 -0
  75. data/lib/openphar/models/thp.rb +24 -0
  76. data/lib/openphar/models/vitamin_monograph.rb +28 -0
  77. data/lib/openphar/models.rb +58 -0
  78. data/lib/openphar/monograph_merger.rb +374 -0
  79. data/lib/openphar/parsers/base_monograph_parser.rb +108 -0
  80. data/lib/openphar/parsers/jp_html_parser.rb +92 -0
  81. data/lib/openphar/parsers/jp_html_parser_base.rb +113 -0
  82. data/lib/openphar/parsers/jp_ja_html_parser.rb +220 -0
  83. data/lib/openphar/parsers/phint_json_parser.rb +332 -0
  84. data/lib/openphar/parsers.rb +21 -0
  85. data/lib/openphar/registry/publisher_registry.rb +46 -0
  86. data/lib/openphar/registry/type_registry.rb +147 -0
  87. data/lib/openphar/registry.rb +24 -0
  88. data/lib/openphar/repositories/monograph_repository.rb +350 -0
  89. data/lib/openphar/repositories.rb +11 -0
  90. data/lib/openphar/transformers/jsonld_transformer.rb +106 -0
  91. data/lib/openphar/transformers.rb +11 -0
  92. data/lib/openphar/version.rb +5 -0
  93. data/lib/openphar.rb +119 -0
  94. data/ontology/bibliographic/edition.ttl +267 -0
  95. data/ontology/context/chp.jsonld +69 -0
  96. data/ontology/context/crude-drugs.jsonld +56 -0
  97. data/ontology/context/pharmacopoeia.jsonld +631 -0
  98. data/ontology/core/identification.ttl +456 -0
  99. data/ontology/core/pharmacopoeia.ttl +3427 -0
  100. data/ontology/core/substance-form.ttl +302 -0
  101. data/ontology/core/unit.ttl +493 -0
  102. data/ontology/publisher/chp/extensions.ttl +186 -0
  103. data/ontology/publisher/phint/extensions.ttl +327 -0
  104. data/ontology/quality/test-method.ttl +610 -0
  105. data/shapes/chp/monograph.ttl +153 -0
  106. data/shapes/edition-shapes.ttl +230 -0
  107. data/shapes/monograph-shapes.ttl +1324 -0
  108. metadata +319 -0
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Openphar
6
+ # Merges Japanese content into existing English monograph JSON-LD files
7
+ #
8
+ # This class:
9
+ # - Loads existing English JSON-LD monograph files
10
+ # - Parses Japanese HTML using JpJaHtmlParser
11
+ # - Matches monographs by Latin name
12
+ # - Merges Japanese content into the existing JSON-LD structure
13
+ # - Preserves language tags for all properties
14
+ class MonographMerger
15
+ # Mapping of Japanese section types to JSON-LD property names
16
+ SECTION_PROPERTY_MAP = {
17
+ definition: "definition",
18
+ macroscopic_description: "macroscopicDescription",
19
+ microscopic_description: "microscopicDescription",
20
+ identification: "identification",
21
+ purity: "purity",
22
+ foreign_matter: "foreignMatter",
23
+ loss_on_drying: "lossOnDrying",
24
+ total_ash: "totalAsh",
25
+ acid_insoluble_ash: "acidInsolubleAsh",
26
+ extractive: "extractive",
27
+ assay: "assay",
28
+ storage: "storageConditions",
29
+ container: "storageContainer"
30
+ }.freeze
31
+
32
+ attr_reader :existing_monographs, :japanese_monographs
33
+
34
+ # Initialize the merger
35
+ #
36
+ # @param existing_dir [String] Path to directory containing existing JSON-LD files
37
+ # @param japanese_dir [String] Path to Japanese HTML files (optional)
38
+ def initialize(existing_dir: nil, japanese_dir: nil)
39
+ @existing_dir = existing_dir
40
+ @japanese_dir = japanese_dir
41
+ @existing_monographs = {}
42
+ @japanese_monographs = []
43
+ end
44
+
45
+ # Load existing monographs from JSON-LD files
46
+ #
47
+ # @param directory [String] Path to directory containing JSON-LD files
48
+ # @return [Hash] Hash of normalized Latin name => monograph data
49
+ def load_existing_monographs(directory = nil)
50
+ dir = directory || @existing_dir
51
+ return {} unless dir && Dir.exist?(dir)
52
+
53
+ Dir.glob(File.join(dir, "**", "*.jsonld")).each do |file|
54
+ load_monograph_file(file)
55
+ end
56
+
57
+ @existing_monographs
58
+ end
59
+
60
+ # Load and parse Japanese monographs
61
+ #
62
+ # @param html_file [String] Path to Japanese HTML file
63
+ # @return [Array<Hash>] Parsed Japanese monographs
64
+ def load_japanese_monographs(html_file = nil)
65
+ file = html_file || @japanese_dir
66
+ return [] unless file && File.exist?(file)
67
+
68
+ parser = Openphar::Parsers::JpJaHtmlParser.new(
69
+ File.read(file, encoding: "UTF-8")
70
+ )
71
+ @japanese_monographs = parser.parse
72
+
73
+ # Index by normalized Latin name (standard Ruby, no ActiveSupport)
74
+ @japanese_index = {}
75
+ @japanese_monographs.each do |m|
76
+ key = normalize_latin_name(m[:latin_name])
77
+ @japanese_index[key] = m
78
+ end
79
+ @japanese_index
80
+ end
81
+
82
+ # Merge Japanese content into existing monographs
83
+ #
84
+ # @param english_monograph [Hash] Existing JSON-LD monograph (as Ruby Hash)
85
+ # @param japanese_data [Hash] Parsed Japanese monograph data
86
+ # @return [Hash] Updated monograph with bilingual content
87
+ def merge_japanese_content(english_monograph, japanese_data)
88
+ return english_monograph unless japanese_data
89
+
90
+ merged = english_monograph.dup
91
+
92
+ # Add Japanese name to prefLabel
93
+ if japanese_data[:japanese_name]
94
+ merged["prefLabel"] ||= {}
95
+ merged["prefLabel"]["ja"] = japanese_data[:japanese_name]
96
+ end
97
+
98
+ # Merge Japanese content into sections
99
+ if japanese_data[:sections]
100
+ japanese_data[:sections].each do |section_type, content|
101
+ property_name = SECTION_PROPERTY_MAP[section_type]
102
+ next unless property_name
103
+
104
+ japanese_content = content.join("\n\n")
105
+
106
+ case property_name
107
+ when "definition"
108
+ merge_language_content(merged, property_name, japanese_content)
109
+ when "macroscopicDescription"
110
+ merged["macroscopicDescription"] ||= {}
111
+ merged["macroscopicDescription"]["ja"] = japanese_content
112
+ when "microscopicDescription"
113
+ merged["microscopicDescription"] ||= {}
114
+ merged["microscopicDescription"]["ja"] = japanese_content
115
+ when "storageConditions"
116
+ merge_language_content(merged, property_name, japanese_content)
117
+ when "identification"
118
+ merge_identification_tests(merged, japanese_data)
119
+ when "assay"
120
+ merge_assay_tests(merged, japanese_data)
121
+ when "purity"
122
+ merge_purity_tests(merged, japanese_data)
123
+ else
124
+ # Generic handling for other test specifications
125
+ merge_test_specifications(merged, section_type, japanese_content)
126
+ end
127
+ end
128
+ end
129
+
130
+ merged
131
+ end
132
+
133
+ # Merge all Japanese monographs into existing monographs
134
+ #
135
+ # @param existing_monographs [Hash] Existing monographs indexed by Latin name
136
+ # @param japanese_monographs [Array<Hash>] Parsed Japanese monographs
137
+ # @return [Array<Hash>] Updated monographs
138
+ def merge_all(existing_monographs = nil, japanese_monographs = nil)
139
+ existing = existing_monographs || @existing_monographs
140
+ japanese_index = @japanese_index || {}
141
+
142
+ merged_results = []
143
+
144
+ japanese_index.each do |latin_name, japanese_data|
145
+ english_data = existing[latin_name]
146
+
147
+ if english_data
148
+ merged = merge_japanese_content(english_data.dup, japanese_data)
149
+ merged_results << merged
150
+ else
151
+ # No matching English monograph found - could be a new monograph
152
+ # or one that exists only in Japanese
153
+ merged_results << create_japanese_only_monograph(japanese_data)
154
+ end
155
+ end
156
+
157
+ merged_results
158
+ end
159
+
160
+ # Write merged monographs to directory
161
+ #
162
+ # @param monographs [Array<Hash>] Merged monographs
163
+ # @param output_dir [String] Output directory path
164
+ def write_merged_monographs(monographs, output_dir)
165
+ FileUtils.mkdir_p(output_dir)
166
+
167
+ monographs.each do |monograph|
168
+ next unless monograph["@id"]
169
+
170
+ # Extract filename from IRI
171
+ slug = monograph["@id"].split("/").last
172
+ output_path = File.join(output_dir, "#{slug}.jsonld")
173
+
174
+ File.write(output_path, JSON.pretty_generate(monograph))
175
+ end
176
+ end
177
+
178
+ private
179
+
180
+ # Load a single monograph JSON-LD file
181
+ #
182
+ # @param file_path [String] Path to JSON-LD file
183
+ def load_monograph_file(file_path)
184
+ data = JSON.parse(File.read(file_path))
185
+
186
+ # Extract Latin name from prefLabel or monographId
187
+ latin_name = extract_latin_name_from_monograph(data)
188
+
189
+ @existing_monographs[normalize_latin_name(latin_name)] = data if latin_name
190
+ end
191
+
192
+ # Extract Latin name from monograph data
193
+ #
194
+ # @param data [Hash] Monograph JSON-LD data
195
+ # @return [String, nil] Latin name
196
+ def extract_latin_name_from_monograph(data)
197
+ # Try prefLabel first
198
+ label = data["prefLabel"]&.[]("en")
199
+ return label if label
200
+
201
+ # Fall back to monographId
202
+ data["monographId"]
203
+ end
204
+
205
+ # Normalize Latin name for matching
206
+ #
207
+ # @param name [String] Original name
208
+ # @return [String] Normalized name
209
+ def normalize_latin_name(name)
210
+ return nil unless name
211
+
212
+ name
213
+ .to_s
214
+ .strip
215
+ .upcase
216
+ .gsub(/\s+/, " ")
217
+ .gsub(/[^A-Z\s\-]/, "")
218
+ end
219
+
220
+ # Merge language-tagged content
221
+ #
222
+ # @param merged [Hash] The merged monograph
223
+ # @param property [String] Property name
224
+ # @param japanese_content [String] Japanese content
225
+ def merge_language_content(merged, property, japanese_content)
226
+ merged[property] ||= {}
227
+ merged[property]["ja"] = japanese_content
228
+ end
229
+
230
+ # Merge identification tests
231
+ #
232
+ # @param merged [Hash] The merged monograph
233
+ # @param japanese_data [Hash] Japanese data
234
+ def merge_identification_tests(merged, japanese_data)
235
+ # For identification, add Japanese content to existing test specifications
236
+ merged["testSpecification"] ||= []
237
+
238
+ japanese_id_tests = japanese_data[:sections][:identification]
239
+ return unless japanese_id_tests
240
+
241
+ japanese_content = japanese_id_tests.join("\n\n")
242
+
243
+ # Find or create identification test
244
+ id_test = merged["testSpecification"].find do |spec|
245
+ spec["testType"] == "TestType-Identification"
246
+ end
247
+
248
+ if id_test
249
+ id_test["testConditions"] ||= {}
250
+ id_test["testConditions"]["ja"] = japanese_content
251
+ end
252
+ end
253
+
254
+ # Merge assay tests
255
+ #
256
+ # @param merged [Hash] The merged monograph
257
+ # @param japanese_data [Hash] Japanese data
258
+ def merge_assay_tests(merged, japanese_data)
259
+ merged["testSpecification"] ||= []
260
+
261
+ japanese_assay = japanese_data[:sections][:assay]
262
+ return unless japanese_assay
263
+
264
+ japanese_content = japanese_assay.join("\n\n")
265
+
266
+ # Find or create assay test
267
+ assay_test = merged["testSpecification"].find do |spec|
268
+ spec["testType"] == "TestType-Assay"
269
+ end
270
+
271
+ if assay_test
272
+ assay_test["testConditions"] ||= {}
273
+ assay_test["testConditions"]["ja"] = japanese_content
274
+ end
275
+ end
276
+
277
+ # Merge purity tests
278
+ #
279
+ # @param merged [Hash] The merged monograph
280
+ # @param japanese_data [Hash] Japanese data
281
+ def merge_purity_tests(merged, japanese_data)
282
+ merged["testSpecification"] ||= []
283
+
284
+ japanese_purity = japanese_data[:sections][:purity]
285
+ return unless japanese_purity
286
+
287
+ japanese_content = japanese_purity.join("\n\n")
288
+
289
+ # Find or create purity test
290
+ purity_test = merged["testSpecification"].find do |spec|
291
+ spec["testType"] == "TestType-Purity"
292
+ end
293
+
294
+ if purity_test
295
+ purity_test["testConditions"] ||= {}
296
+ purity_test["testConditions"]["ja"] = japanese_content
297
+ end
298
+ end
299
+
300
+ # Generic test specification merger
301
+ #
302
+ # @param merged [Hash] The merged monograph
303
+ # @param section_type [Symbol] Section type
304
+ # @param content [String] Japanese content
305
+ def merge_test_specifications(merged, section_type, content)
306
+ merged["testSpecification"] ||= []
307
+
308
+ property_name = SECTION_PROPERTY_MAP[section_type]
309
+
310
+ # Create or update test specification
311
+ test_type = section_type_to_test_type(section_type)
312
+
313
+ existing_spec = merged["testSpecification"].find do |spec|
314
+ spec["testType"] == test_type
315
+ end
316
+
317
+ if existing_spec
318
+ existing_spec["testConditions"] ||= {}
319
+ existing_spec["testConditions"]["ja"] = content
320
+ end
321
+ end
322
+
323
+ # Convert section type to test type
324
+ #
325
+ # @param section_type [Symbol] Section type
326
+ # @return [String] Test type
327
+ def section_type_to_test_type(section_type)
328
+ case section_type
329
+ when :identification
330
+ "TestType-Identification"
331
+ when :purity
332
+ "TestType-Purity"
333
+ when :assay
334
+ "TestType-Assay"
335
+ when :loss_on_drying
336
+ "TestType-LossOnDrying"
337
+ when :total_ash
338
+ "TestType-TotalAsh"
339
+ when :acid_insoluble_ash
340
+ "TestType-AcidInsolubleAsh"
341
+ when :foreign_matter
342
+ "TestType-ForeignMatter"
343
+ else
344
+ "TestType-Physical"
345
+ end
346
+ end
347
+
348
+ # Create a Japanese-only monograph entry
349
+ #
350
+ # @param japanese_data [Hash] Japanese monograph data
351
+ # @return [Hash] New monograph entry
352
+ def create_japanese_only_monograph(japanese_data)
353
+ slug = japanese_data[:japanese_name]
354
+ .to_s
355
+ .downcase
356
+ .gsub(/\s+/, "-")
357
+ .gsub(/[^a-z0-9\-]/, "")
358
+
359
+ {
360
+ "@context" => "https://www.openphar.org/ontology/context/pharmacopoeia.jsonld",
361
+ "@id" => "https://www.openphar.org/data/jp/monographs/#{slug}",
362
+ "@type" => "CrudeDrugMonograph",
363
+ "prefLabel" => {
364
+ "ja" => japanese_data[:japanese_name],
365
+ "en" => japanese_data[:latin_name]
366
+ }.compact,
367
+ "publisher" => { "@id" => "op:JP" },
368
+ "definition" => {
369
+ "ja" => japanese_data[:sections][:definition]&.join("\n\n")
370
+ }.compact
371
+ }
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Openphar
4
+ module Parsers
5
+ # Abstract base class for all monograph parsers.
6
+ #
7
+ # Defines the contract that all parsers must follow:
8
+ # - Return Lutaml::Model instances (not Hashes)
9
+ # - Track parsing statistics
10
+ # - Handle errors consistently
11
+ #
12
+ # @example Creating a custom parser
13
+ # class MyParser < BaseMonographParser
14
+ # def parse(source)
15
+ # # Parse source and return model instances
16
+ # MyMonograph.new(**attributes)
17
+ # end
18
+ # end
19
+ class BaseMonographParser
20
+ attr_reader :stats
21
+
22
+ def initialize
23
+ @stats = {
24
+ total: 0,
25
+ parsed: 0,
26
+ failed: 0,
27
+ errors: []
28
+ }
29
+ end
30
+
31
+ # Parse a single source (string, file content, etc.)
32
+ # Subclasses MUST implement this method.
33
+ #
34
+ # @param source [String] The source content to parse
35
+ # @return [Lutaml::Model::Serializable, nil] A model instance or nil on failure
36
+ # @raise [NotImplementedError] If not implemented by subclass
37
+ def parse(source)
38
+ raise NotImplementedError, 'Subclasses must implement #parse'
39
+ end
40
+
41
+ # Parse a single file.
42
+ # Subclasses MUST implement this method.
43
+ #
44
+ # @param file_path [String] Path to the file to parse
45
+ # @return [Lutaml::Model::Serializable, nil] A model instance or nil on failure
46
+ # @raise [NotImplementedError] If not implemented by subclass
47
+ def parse_file(file_path)
48
+ raise NotImplementedError, 'Subclasses must implement #parse_file'
49
+ end
50
+
51
+ # Parse all files in a directory.
52
+ # Subclasses MUST implement this method.
53
+ #
54
+ # @param directory [String] Path to the directory containing files
55
+ # @return [Array<Lutaml::Model::Serializable>] Array of model instances
56
+ # @raise [NotImplementedError] If not implemented by subclass
57
+ def parse_directory(directory)
58
+ raise NotImplementedError, 'Subclasses must implement #parse_directory'
59
+ end
60
+
61
+ # Reset parsing statistics
62
+ def reset_stats
63
+ @stats = {
64
+ total: 0,
65
+ parsed: 0,
66
+ failed: 0,
67
+ errors: []
68
+ }
69
+ end
70
+
71
+ protected
72
+
73
+ # Increment the total count
74
+ def increment_total
75
+ @stats[:total] += 1
76
+ end
77
+
78
+ # Increment the parsed count
79
+ def increment_parsed
80
+ @stats[:parsed] += 1
81
+ end
82
+
83
+ # Record an error with context
84
+ #
85
+ # @param source [String] The source that caused the error
86
+ # @param message [String] The error message
87
+ def record_error(source, message)
88
+ @stats[:failed] += 1
89
+ @stats[:errors] << { source: source, message: message }
90
+ end
91
+
92
+ # Print statistics summary
93
+ def print_stats_summary
94
+ puts "\nParsing Statistics:"
95
+ puts " Total: #{@stats[:total]}"
96
+ puts " Parsed: #{@stats[:parsed]}"
97
+ puts " Failed: #{@stats[:failed]}"
98
+ return unless @stats[:errors].any?
99
+
100
+ puts "\nErrors:"
101
+ @stats[:errors].first(5).each do |error|
102
+ puts " - #{error[:source]}: #{error[:message]}"
103
+ end
104
+ puts " ... and #{@stats[:errors].size - 5} more" if @stats[:errors].size > 5
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Openphar
4
+ module Parsers
5
+ # Parser for Japan Pharmacopoeia HTML monographs
6
+ class JpHtmlParser
7
+ # JP monograph section patterns
8
+ SECTION_PATTERNS = {
9
+ definition: /(?:Definition|Origin and Definition)/i,
10
+ identification: /Identification/i,
11
+ purity: /Purity/i,
12
+ assay: /Assay/i,
13
+ storage: /Containers and Storage/i
14
+ }.freeze
15
+
16
+ attr_reader :document, :monographs
17
+
18
+ def initialize(html_content)
19
+ @document = Nokogiri::HTML(html_content)
20
+ @monographs = []
21
+ end
22
+
23
+ # Parse all monographs from the document
24
+ def parse
25
+ # JP HTML structure varies; this is a template
26
+ monograph_nodes = document.css(".monograph, .crude-drug")
27
+
28
+ monograph_nodes.each do |node|
29
+ monographs << parse_monograph(node)
30
+ end
31
+
32
+ monographs
33
+ end
34
+
35
+ private
36
+
37
+ def parse_monograph(node)
38
+ {
39
+ id: extract_id(node),
40
+ name: extract_name(node),
41
+ japanese_name: extract_japanese_name(node),
42
+ definition: extract_section(node, :definition),
43
+ identification: extract_section(node, :identification),
44
+ purity: extract_section(node, :purity),
45
+ assay: extract_section(node, :assay),
46
+ storage: extract_section(node, :storage)
47
+ }
48
+ end
49
+
50
+ def extract_id(node)
51
+ node.at_css(".monograph-id, .id")&.text&.strip
52
+ end
53
+
54
+ def extract_name(node)
55
+ node.at_css(".monograph-name, h1, h2")&.text&.strip
56
+ end
57
+
58
+ def extract_japanese_name(node)
59
+ node.at_css(".japanese-name, .ja")&.text&.strip
60
+ end
61
+
62
+ def extract_section(node, section_type)
63
+ pattern = SECTION_PATTERNS[section_type]
64
+ return nil unless pattern
65
+
66
+ section_header = node.at_css("h3, h4, .section-header") do |h|
67
+ h.text =~ pattern
68
+ end
69
+
70
+ return nil unless section_header
71
+
72
+ # Get all content until the next section header
73
+ content = []
74
+ sibling = section_header.next_element
75
+
76
+ while sibling && !section_header?(sibling)
77
+ content << sibling.text.strip
78
+ sibling = sibling.next_element
79
+ end
80
+
81
+ content.join("\n")
82
+ end
83
+
84
+ def section_header?(element)
85
+ return false unless element
86
+
87
+ element.name == "h3" || element.name == "h4" ||
88
+ element.classes.include?("section-header")
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Openphar
6
+ module Parsers
7
+ # Base class for JP HTML parsers
8
+ #
9
+ # Provides common functionality for parsing Japan Pharmacopoeia HTML files.
10
+ # Subclasses should implement:
11
+ # - #parse method
12
+ # - Section pattern constants
13
+ class JpHtmlParserBase
14
+ attr_reader :document
15
+
16
+ # Initialize parser with HTML content
17
+ #
18
+ # @param html_content [String] The HTML content to parse
19
+ def initialize(html_content)
20
+ @document = Nokogiri::HTML(html_content, nil, "UTF-8")
21
+ end
22
+
23
+ # Parse monographs from the document
24
+ # Subclasses should override this method
25
+ #
26
+ # @return [Array<Hash>] Array of parsed monograph data
27
+ def parse
28
+ raise NotImplementedError, "Subclasses must implement #parse"
29
+ end
30
+
31
+ # Parse monographs from a file
32
+ #
33
+ # @param file_path [String] Path to HTML file
34
+ # @return [Array<Hash>] Array of parsed monograph data
35
+ def self.parse_file(file_path)
36
+ html_content = File.read(file_path, encoding: "UTF-8")
37
+ new(html_content).parse
38
+ end
39
+
40
+ # Parse monographs from multiple files
41
+ #
42
+ # @param file_paths [Array<String>] Paths to HTML files
43
+ # @return [Array<Hash>] Combined array of parsed monograph data
44
+ def self.parse_files(file_paths)
45
+ file_paths.flat_map { |path| parse_file(path) }
46
+ end
47
+
48
+ protected
49
+
50
+ # Normalize Latin name for matching
51
+ #
52
+ # @param name [String] The Latin name
53
+ # @return [String] Normalized name
54
+ def normalize_latin_name(name)
55
+ return nil unless name
56
+
57
+ name
58
+ .strip
59
+ .upcase
60
+ .gsub(/\s+/, " ")
61
+ .gsub(/[^A-Z\s]/, "")
62
+ end
63
+
64
+ # Clean text content
65
+ #
66
+ # @param text [String] Raw text
67
+ # @return [String] Cleaned text
68
+ def clean_text(text)
69
+ return "" unless text
70
+
71
+ text
72
+ .strip
73
+ .gsub(/\s+/, " ")
74
+ .gsub(/[\u00A0]/, " ") # Non-breaking spaces
75
+ end
76
+
77
+ # Extract numeric value from text
78
+ #
79
+ # @param text [String] Text containing numeric value
80
+ # @return [Float, nil] Extracted numeric value
81
+ def extract_numeric(text)
82
+ match = text.match(/(\d+\.?\d*)/)
83
+ match ? match[1].to_f : nil
84
+ end
85
+
86
+ # Extract percentage from text
87
+ #
88
+ # @param text [String] Text containing percentage
89
+ # @return [Float, nil] Percentage value
90
+ def extract_percentage(text)
91
+ match = text.match(/(\d+\.?\d*)\s*%/)
92
+ match ? match[1].to_f : nil
93
+ end
94
+
95
+ # Extract limit from text (e.g., "not more than 1.0%", "not less than 0.5%")
96
+ #
97
+ # @param text [String] Text containing limit
98
+ # @return [Hash] Limit information
99
+ def extract_limit(text)
100
+ limit_type = nil
101
+ limit_type = :maximum if text.match?(/not more than|以下|not exceed/i)
102
+ limit_type = :minimum if text.match?(/not less than|以上|at least/i)
103
+
104
+ value = extract_percentage(text) || extract_numeric(text)
105
+
106
+ {
107
+ type: limit_type,
108
+ value: value
109
+ }.compact
110
+ end
111
+ end
112
+ end
113
+ end