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.
- checksums.yaml +7 -0
- data/README.adoc +221 -0
- data/lib/openphar/cli/main.rb +137 -0
- data/lib/openphar/cli.rb +11 -0
- data/lib/openphar/core/slug_generator.rb +107 -0
- data/lib/openphar/core.rb +11 -0
- data/lib/openphar/errors/configuration_error.rb +8 -0
- data/lib/openphar/errors/monograph_not_found_error.rb +15 -0
- data/lib/openphar/errors/parse_error.rb +18 -0
- data/lib/openphar/errors/unknown_format_error.rb +8 -0
- data/lib/openphar/errors/unknown_publisher_error.rb +8 -0
- data/lib/openphar/errors/unknown_type_error.rb +8 -0
- data/lib/openphar/errors/validation_error.rb +15 -0
- data/lib/openphar/errors.rb +17 -0
- data/lib/openphar/exporters/coverage_validator.rb +265 -0
- data/lib/openphar/exporters/neo4j/model_registry.rb +130 -0
- data/lib/openphar/exporters/neo4j/node_builder.rb +84 -0
- data/lib/openphar/exporters/neo4j/property_mapper.rb +154 -0
- data/lib/openphar/exporters/neo4j/relationship_builder.rb +87 -0
- data/lib/openphar/exporters/neo4j.rb +16 -0
- data/lib/openphar/exporters/neo4j_exporter.rb +199 -0
- data/lib/openphar/exporters.rb +15 -0
- data/lib/openphar/linkers/chp/cross_edition_linker.rb +101 -0
- data/lib/openphar/linkers/chp.rb +11 -0
- data/lib/openphar/linkers/cross_publisher_linker.rb +295 -0
- data/lib/openphar/linkers/herbapedia_linker.rb +125 -0
- data/lib/openphar/linkers.rb +13 -0
- data/lib/openphar/migrators/chp/classifier.rb +86 -0
- data/lib/openphar/migrators/chp.rb +15 -0
- data/lib/openphar/migrators/chp_migrator.rb +130 -0
- data/lib/openphar/migrators/jp_migrator.rb +345 -0
- data/lib/openphar/migrators/phint_migrator.rb +426 -0
- data/lib/openphar/migrators.rb +15 -0
- data/lib/openphar/models/ahp.rb +25 -0
- data/lib/openphar/models/amino_acid_monograph.rb +28 -0
- data/lib/openphar/models/api.rb +29 -0
- data/lib/openphar/models/assay_specification.rb +19 -0
- data/lib/openphar/models/base_entity.rb +221 -0
- data/lib/openphar/models/biological_substance_monograph.rb +31 -0
- data/lib/openphar/models/chemical_drug_monograph.rb +39 -0
- data/lib/openphar/models/chp/biologic.rb +28 -0
- data/lib/openphar/models/chp/chemical_preparation.rb +28 -0
- data/lib/openphar/models/chp/chemical_substance.rb +28 -0
- data/lib/openphar/models/chp/general_chapter.rb +30 -0
- data/lib/openphar/models/chp/monograph.rb +116 -0
- data/lib/openphar/models/chp/section.rb +37 -0
- data/lib/openphar/models/chp/tcm_crude_drug.rb +28 -0
- data/lib/openphar/models/chp/tcm_extract.rb +29 -0
- data/lib/openphar/models/chp/tcm_formulation.rb +28 -0
- data/lib/openphar/models/chp.rb +33 -0
- data/lib/openphar/models/crude_drug_monograph.rb +31 -0
- data/lib/openphar/models/edition.rb +41 -0
- data/lib/openphar/models/formulation_monograph.rb +31 -0
- data/lib/openphar/models/hkcmms.rb +26 -0
- data/lib/openphar/models/identification_specification.rb +19 -0
- data/lib/openphar/models/jp/kampo_formula.rb +94 -0
- data/lib/openphar/models/jp.rb +21 -0
- data/lib/openphar/models/limit.rb +54 -0
- data/lib/openphar/models/mineral_substance_monograph.rb +28 -0
- data/lib/openphar/models/monograph.rb +58 -0
- data/lib/openphar/models/ph_int/buffer_solution.rb +38 -0
- data/lib/openphar/models/ph_int/dosage_form_monograph.rb +43 -0
- data/lib/openphar/models/ph_int/radiopharmaceutical_monograph.rb +52 -0
- data/lib/openphar/models/ph_int/reagent.rb +50 -0
- data/lib/openphar/models/ph_int/reference_substance.rb +50 -0
- data/lib/openphar/models/ph_int/test_method.rb +50 -0
- data/lib/openphar/models/ph_int/test_solution.rb +39 -0
- data/lib/openphar/models/ph_int/volumetric_solution.rb +38 -0
- data/lib/openphar/models/ph_int.rb +37 -0
- data/lib/openphar/models/physical_specification.rb +17 -0
- data/lib/openphar/models/publisher.rb +98 -0
- data/lib/openphar/models/purity_specification.rb +16 -0
- data/lib/openphar/models/supplement.rb +31 -0
- data/lib/openphar/models/test_specification.rb +29 -0
- data/lib/openphar/models/thp.rb +24 -0
- data/lib/openphar/models/vitamin_monograph.rb +28 -0
- data/lib/openphar/models.rb +58 -0
- data/lib/openphar/monograph_merger.rb +374 -0
- data/lib/openphar/parsers/base_monograph_parser.rb +108 -0
- data/lib/openphar/parsers/jp_html_parser.rb +92 -0
- data/lib/openphar/parsers/jp_html_parser_base.rb +113 -0
- data/lib/openphar/parsers/jp_ja_html_parser.rb +220 -0
- data/lib/openphar/parsers/phint_json_parser.rb +332 -0
- data/lib/openphar/parsers.rb +21 -0
- data/lib/openphar/registry/publisher_registry.rb +46 -0
- data/lib/openphar/registry/type_registry.rb +147 -0
- data/lib/openphar/registry.rb +24 -0
- data/lib/openphar/repositories/monograph_repository.rb +350 -0
- data/lib/openphar/repositories.rb +11 -0
- data/lib/openphar/transformers/jsonld_transformer.rb +106 -0
- data/lib/openphar/transformers.rb +11 -0
- data/lib/openphar/version.rb +5 -0
- data/lib/openphar.rb +119 -0
- data/ontology/bibliographic/edition.ttl +267 -0
- data/ontology/context/chp.jsonld +69 -0
- data/ontology/context/crude-drugs.jsonld +56 -0
- data/ontology/context/pharmacopoeia.jsonld +631 -0
- data/ontology/core/identification.ttl +456 -0
- data/ontology/core/pharmacopoeia.ttl +3427 -0
- data/ontology/core/substance-form.ttl +302 -0
- data/ontology/core/unit.ttl +493 -0
- data/ontology/publisher/chp/extensions.ttl +186 -0
- data/ontology/publisher/phint/extensions.ttl +327 -0
- data/ontology/quality/test-method.ttl +610 -0
- data/shapes/chp/monograph.ttl +153 -0
- data/shapes/edition-shapes.ttl +230 -0
- data/shapes/monograph-shapes.ttl +1324 -0
- 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
|