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,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Openphar
|
|
4
|
+
module Parsers
|
|
5
|
+
# Parser for Japanese (JA) Japan Pharmacopoeia HTML monographs
|
|
6
|
+
#
|
|
7
|
+
# Parses JP monographs from Japanese HTML files where:
|
|
8
|
+
# - class="s1" contains Japanese names
|
|
9
|
+
# - class="s2" contains Latin names
|
|
10
|
+
# - class="s5" contains section headers
|
|
11
|
+
# - class="s3" contains content paragraphs
|
|
12
|
+
#
|
|
13
|
+
# Japanese section headers:
|
|
14
|
+
# - 生薬の性状 / 性状 - Macroscopic Description
|
|
15
|
+
# - 顕微鏡的性状 - Microscopic Description
|
|
16
|
+
# - 確認試験 - Identification
|
|
17
|
+
# - 純度試験 - Purity
|
|
18
|
+
# - 異物 - Foreign Matter
|
|
19
|
+
# - 乾燥減量 - Loss on Drying
|
|
20
|
+
# - 灰分 - Total Ash
|
|
21
|
+
# - 酸不溶性灰分 - Acid Insoluble Ash
|
|
22
|
+
# - エキス量 - Extractive
|
|
23
|
+
# - 定量法 - Assay
|
|
24
|
+
# - 貯法 - Storage
|
|
25
|
+
# - 容器 - Container
|
|
26
|
+
class JpJaHtmlParser < JpHtmlParserBase
|
|
27
|
+
# Japanese section patterns for crude drugs
|
|
28
|
+
CRUDE_DRUG_SECTION_PATTERNS = {
|
|
29
|
+
definition: /(?:生薬の性状|性状|成分規格)/,
|
|
30
|
+
macroscopic_description: /生薬の性状/,
|
|
31
|
+
microscopic_description: /顕微鏡的性状/,
|
|
32
|
+
identification: /確認試験/,
|
|
33
|
+
purity: /純度試験/,
|
|
34
|
+
foreign_matter: /異物/,
|
|
35
|
+
loss_on_drying: /乾燥減量/,
|
|
36
|
+
total_ash: /灰分/,
|
|
37
|
+
acid_insoluble_ash: /酸不溶性灰分/,
|
|
38
|
+
extractive: /エキス量/,
|
|
39
|
+
assay: /定量法/,
|
|
40
|
+
storage: /貯法/,
|
|
41
|
+
container: /容器/
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# Japanese section patterns for chemical drugs
|
|
45
|
+
CHEMICAL_DRUG_SECTION_PATTERNS = {
|
|
46
|
+
specification: /成分規格/,
|
|
47
|
+
description: /性状/,
|
|
48
|
+
identification: /確認試験/,
|
|
49
|
+
purity: /純度試験/,
|
|
50
|
+
loss_on_drying: /乾燥減量/,
|
|
51
|
+
residue_on_ignition: /強熱残分/,
|
|
52
|
+
assay: /定量法/,
|
|
53
|
+
storage: /貯法/
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
# CSS selectors for Japanese HTML
|
|
57
|
+
JAPANESE_NAME_SELECTOR = "p.s1"
|
|
58
|
+
LATIN_NAME_SELECTOR = "p.s2"
|
|
59
|
+
CONTENT_SELECTOR = "p.s3"
|
|
60
|
+
SECTION_HEADER_CLASS = "s5"
|
|
61
|
+
|
|
62
|
+
# Reference marker patterns (Japanese uses 〈〉 instead of <>)
|
|
63
|
+
REFERENCE_PATTERN = /〈([^〉]+)〉/
|
|
64
|
+
|
|
65
|
+
attr_reader :monographs
|
|
66
|
+
|
|
67
|
+
# Initialize parser with HTML content
|
|
68
|
+
#
|
|
69
|
+
# @param html_content [String] The HTML content to parse
|
|
70
|
+
# @param monograph_type [Symbol] :crude_drug or :chemical_drug
|
|
71
|
+
def initialize(html_content, monograph_type: :crude_drug)
|
|
72
|
+
super(html_content)
|
|
73
|
+
@monograph_type = monograph_type
|
|
74
|
+
@section_patterns = if monograph_type == :crude_drug
|
|
75
|
+
CRUDE_DRUG_SECTION_PATTERNS
|
|
76
|
+
else
|
|
77
|
+
CHEMICAL_DRUG_SECTION_PATTERNS
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Parse all monographs from the document
|
|
82
|
+
#
|
|
83
|
+
# @return [Array<Hash>] Array of parsed monograph data
|
|
84
|
+
def parse
|
|
85
|
+
@monographs = []
|
|
86
|
+
current_monograph = nil
|
|
87
|
+
|
|
88
|
+
# Iterate through all paragraph elements
|
|
89
|
+
document.css("p").each do |paragraph|
|
|
90
|
+
classes = paragraph.classes
|
|
91
|
+
|
|
92
|
+
if classes.include?("s1") && monograph_name?(paragraph.text)
|
|
93
|
+
# Start of a new monograph
|
|
94
|
+
if current_monograph
|
|
95
|
+
@monographs << current_monograph
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
current_monograph = start_new_monograph(paragraph)
|
|
99
|
+
elsif current_monograph && classes.include?("s2")
|
|
100
|
+
# Latin name follows Japanese name
|
|
101
|
+
latin_name = extract_latin_name(paragraph.text)
|
|
102
|
+
current_monograph[:latin_name] = latin_name if latin_name
|
|
103
|
+
elsif current_monograph
|
|
104
|
+
# Check for section headers (s5 class)
|
|
105
|
+
if classes.include?(SECTION_HEADER_CLASS)
|
|
106
|
+
current_section = detect_section(paragraph.text)
|
|
107
|
+
current_monograph[:current_section] = current_section
|
|
108
|
+
else
|
|
109
|
+
# Content for current section
|
|
110
|
+
add_content_to_section(current_monograph, paragraph.text)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Don't forget the last monograph
|
|
116
|
+
@monographs << current_monograph if current_monograph
|
|
117
|
+
|
|
118
|
+
@monographs
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Check if text is a monograph name (not just a section title)
|
|
124
|
+
#
|
|
125
|
+
# @param text [String] The paragraph text
|
|
126
|
+
# @return [Boolean] true if it appears to be a monograph name
|
|
127
|
+
def monograph_name?(text)
|
|
128
|
+
cleaned = text.strip
|
|
129
|
+
# Remove zero-width characters and other invisible Unicode
|
|
130
|
+
cleaned = cleaned.gsub(/\p{Zs}|\p{Cf}/, "").strip
|
|
131
|
+
|
|
132
|
+
return false if cleaned.empty?
|
|
133
|
+
|
|
134
|
+
# Filter out section headers and category titles
|
|
135
|
+
return false if cleaned.match?(/^(生薬等|医薬品|一般試験法|第[一二三四五六七八九十]+版)/)
|
|
136
|
+
return false if cleaned.length > 30
|
|
137
|
+
|
|
138
|
+
# Must contain at least one Japanese character (Hiragana, Katakana, or Kanji)
|
|
139
|
+
# and be a reasonable length for a monograph name
|
|
140
|
+
return false unless cleaned.match?(/[\p{Hiragana}\p{Katakana}\p{Han}]{2,}/)
|
|
141
|
+
|
|
142
|
+
true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Start a new monograph with Japanese name
|
|
146
|
+
#
|
|
147
|
+
# @param paragraph [Nokogiri::XML::Element] The paragraph element
|
|
148
|
+
# @return [Hash] New monograph hash
|
|
149
|
+
def start_new_monograph(paragraph)
|
|
150
|
+
{
|
|
151
|
+
japanese_name: paragraph.text.strip,
|
|
152
|
+
latin_name: nil,
|
|
153
|
+
sections: {},
|
|
154
|
+
current_section: :definition
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Extract Latin name from paragraph text
|
|
159
|
+
#
|
|
160
|
+
# @param text [String] The paragraph text
|
|
161
|
+
# @return [String, nil] The Latin name or nil
|
|
162
|
+
def extract_latin_name(text)
|
|
163
|
+
# Latin names typically have uppercase words
|
|
164
|
+
# Format: "Common Name LATIN NAME"
|
|
165
|
+
parts = text.strip.split
|
|
166
|
+
latin_parts = parts.select { |p| p.match?(/^[A-Z]+$/) || p.match?(/^[A-Z][a-z]+$/) }
|
|
167
|
+
|
|
168
|
+
return nil if latin_parts.empty?
|
|
169
|
+
|
|
170
|
+
# Find where Latin name starts (usually after common name)
|
|
171
|
+
latin_start = parts.index { |p| p.match?(/^[A-Z]{2,}/) }
|
|
172
|
+
return nil unless latin_start
|
|
173
|
+
|
|
174
|
+
parts[latin_start..].join(" ")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Detect section type from header text
|
|
178
|
+
#
|
|
179
|
+
# @param text [String] The header text
|
|
180
|
+
# @return [Symbol, nil] The section type
|
|
181
|
+
def detect_section(text)
|
|
182
|
+
cleaned = text.strip
|
|
183
|
+
|
|
184
|
+
@section_patterns.each do |section_type, pattern|
|
|
185
|
+
return section_type if cleaned.match?(pattern)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Add content to the current section
|
|
192
|
+
#
|
|
193
|
+
# @param monograph [Hash] The monograph hash
|
|
194
|
+
# @param content [String] The content to add
|
|
195
|
+
def add_content_to_section(monograph, content)
|
|
196
|
+
return unless content && monograph[:current_section]
|
|
197
|
+
|
|
198
|
+
section = monograph[:current_section]
|
|
199
|
+
monograph[:sections][section] ||= []
|
|
200
|
+
monograph[:sections][section] << clean_content(content)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Clean content text
|
|
204
|
+
#
|
|
205
|
+
# @param text [String] Raw text
|
|
206
|
+
# @return [String] Cleaned text
|
|
207
|
+
def clean_content(text)
|
|
208
|
+
text.strip.gsub(/\s+/, " ")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Convert Japanese reference markers to standard format
|
|
212
|
+
#
|
|
213
|
+
# @param text [String] Text with Japanese markers
|
|
214
|
+
# @return [String] Text with standard markers
|
|
215
|
+
def normalize_references(text)
|
|
216
|
+
text.gsub(REFERENCE_PATTERN, '<\1>')
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
|
|
6
|
+
module Openphar
|
|
7
|
+
module Parsers
|
|
8
|
+
# Parser for International Pharmacopoeia (Ph.Int.) JSON files
|
|
9
|
+
#
|
|
10
|
+
# This parser creates Lutaml::Model instances (not hashes) for proper
|
|
11
|
+
# model-driven architecture. All output uses model classes.
|
|
12
|
+
#
|
|
13
|
+
# Ph.Int. data files are JSON with embedded HTML content:
|
|
14
|
+
# {
|
|
15
|
+
# "id": "6.1.1",
|
|
16
|
+
# "title": "Abacavir sulfate (Abacaviri sulfas)",
|
|
17
|
+
# "data": {
|
|
18
|
+
# "t": "Title",
|
|
19
|
+
# "b": "<p>HTML body content...</p>",
|
|
20
|
+
# "c": []
|
|
21
|
+
# }
|
|
22
|
+
# }
|
|
23
|
+
class PhIntJsonParser
|
|
24
|
+
attr_reader :monographs, :stats
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@monographs = []
|
|
28
|
+
@stats = { total: 0, parsed: 0, failed: 0 }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Parse a single Ph.Int. JSON file
|
|
32
|
+
#
|
|
33
|
+
# @param file_path [String] Path to JSON file
|
|
34
|
+
# @return [Monograph, nil] Parsed monograph model instance
|
|
35
|
+
def parse_file(file_path)
|
|
36
|
+
@stats[:total] += 1
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
content = File.read(file_path, encoding: "UTF-8")
|
|
40
|
+
data = JSON.parse(content)
|
|
41
|
+
monograph = parse_data(data)
|
|
42
|
+
if monograph
|
|
43
|
+
@monographs << monograph
|
|
44
|
+
@stats[:parsed] += 1
|
|
45
|
+
end
|
|
46
|
+
monograph
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
puts " ERROR: Invalid JSON - #{e.message}"
|
|
49
|
+
@stats[:failed] += 1
|
|
50
|
+
nil
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
puts " ERROR: #{e.message}"
|
|
53
|
+
@stats[:failed] += 1
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Parse all Ph.Int. JSON files in a directory
|
|
59
|
+
#
|
|
60
|
+
# @param directory [String] Directory containing JSON files
|
|
61
|
+
# @return [Array<Monograph>] Parsed monograph model instances
|
|
62
|
+
def parse_directory(directory)
|
|
63
|
+
json_files = Dir.glob(File.join(directory, "b.*.json"))
|
|
64
|
+
puts "Found #{json_files.count} Ph.Int. JSON files"
|
|
65
|
+
|
|
66
|
+
json_files.sort.each do |file|
|
|
67
|
+
puts "Parsing: #{File.basename(file)}"
|
|
68
|
+
parse_file(file)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@monographs
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Parse Ph.Int. data structure and create model instance
|
|
77
|
+
#
|
|
78
|
+
# @param data [Hash] Raw JSON data
|
|
79
|
+
# @return [Monograph] Model instance
|
|
80
|
+
def parse_data(data)
|
|
81
|
+
return nil unless data["id"] && data["data"]
|
|
82
|
+
|
|
83
|
+
id = data["id"]
|
|
84
|
+
title = data["title"] || data["data"]["t"]
|
|
85
|
+
body_html = data["data"]["b"]
|
|
86
|
+
|
|
87
|
+
# Extract sections from HTML
|
|
88
|
+
sections = parse_html_body(body_html)
|
|
89
|
+
|
|
90
|
+
# Determine monograph type and create appropriate model
|
|
91
|
+
create_monograph_model(id, title, sections)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Parse HTML body content to extract sections
|
|
95
|
+
#
|
|
96
|
+
# @param html [String] HTML content
|
|
97
|
+
# @return [Hash] Extracted sections
|
|
98
|
+
def parse_html_body(html)
|
|
99
|
+
return {} if html.nil? || html.empty?
|
|
100
|
+
|
|
101
|
+
doc = Nokogiri::HTML.fragment(html)
|
|
102
|
+
{
|
|
103
|
+
molecular_formula: extract_molecular_formula(doc),
|
|
104
|
+
molecular_weight: extract_molecular_weight(doc),
|
|
105
|
+
cas_number: extract_cas_number(doc),
|
|
106
|
+
chemical_name: extract_chemical_name(doc),
|
|
107
|
+
description: extract_section_content(doc, "Description"),
|
|
108
|
+
solubility: extract_section_content(doc, "Solubility"),
|
|
109
|
+
storage: extract_section_content(doc, "Storage"),
|
|
110
|
+
definition: extract_section_content(doc, "Definition"),
|
|
111
|
+
identity_tests: extract_section_content(doc, "Identity tests"),
|
|
112
|
+
related_substances: extract_section_content(doc, "Related substances"),
|
|
113
|
+
assay: extract_section_content(doc, "Assay"),
|
|
114
|
+
impurities: extract_impurities(doc)
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Create the appropriate model instance based on type
|
|
119
|
+
def create_monograph_model(id, title, sections)
|
|
120
|
+
slug = generate_slug(title)
|
|
121
|
+
en_name, la_name = parse_title(title)
|
|
122
|
+
|
|
123
|
+
base_attrs = {
|
|
124
|
+
id: "https://www.openphar.org/data/phint/monographs/#{slug}",
|
|
125
|
+
monograph_id: "PhInt13-#{id}",
|
|
126
|
+
pref_label: { "en" => en_name, "la" => la_name }.compact,
|
|
127
|
+
publisher: "WHO",
|
|
128
|
+
status: "Official",
|
|
129
|
+
belongs_to_edition: "https://www.openphar.org/data/edition/phint/phint13"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case determine_monograph_type(id, sections)
|
|
133
|
+
when "RadiopharmaceuticalMonograph"
|
|
134
|
+
create_radiopharmaceutical_model(base_attrs, sections)
|
|
135
|
+
when "DosageFormMonograph"
|
|
136
|
+
create_dosage_form_model(base_attrs, sections)
|
|
137
|
+
when "TestMethod"
|
|
138
|
+
create_test_method_model(base_attrs, title, sections)
|
|
139
|
+
when "Reagent"
|
|
140
|
+
create_reagent_model(base_attrs, title, sections)
|
|
141
|
+
else
|
|
142
|
+
create_chemical_drug_model(base_attrs, sections)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Create ChemicalDrugMonograph model instance
|
|
147
|
+
def create_chemical_drug_model(attrs, sections)
|
|
148
|
+
# Build attributes hash, only including non-nil values
|
|
149
|
+
model_attrs = attrs.dup
|
|
150
|
+
|
|
151
|
+
# Only add molecular_weight if it's a valid number
|
|
152
|
+
if sections[:molecular_weight] && sections[:molecular_weight] > 0
|
|
153
|
+
model_attrs[:molecular_weight] = sections[:molecular_weight]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
model_attrs[:molecular_formula] = sections[:molecular_formula] if sections[:molecular_formula]
|
|
157
|
+
model_attrs[:cas_number] = sections[:cas_number] if sections[:cas_number]
|
|
158
|
+
model_attrs[:systematic_name] = { "en" => sections[:chemical_name] } if sections[:chemical_name]
|
|
159
|
+
model_attrs[:definition] = { "en" => sections[:definition] } if sections[:definition]
|
|
160
|
+
model_attrs[:test_specifications] = build_test_specifications(sections)
|
|
161
|
+
|
|
162
|
+
ChemicalDrugMonograph.new(**model_attrs)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Create RadiopharmaceuticalMonograph model instance
|
|
166
|
+
def create_radiopharmaceutical_model(attrs, sections)
|
|
167
|
+
model_attrs = attrs.dup
|
|
168
|
+
|
|
169
|
+
# Only add molecular_weight if it's a valid number
|
|
170
|
+
if sections[:molecular_weight] && sections[:molecular_weight] > 0
|
|
171
|
+
model_attrs[:molecular_weight] = sections[:molecular_weight]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
model_attrs[:molecular_formula] = sections[:molecular_formula] if sections[:molecular_formula]
|
|
175
|
+
model_attrs[:cas_number] = sections[:cas_number] if sections[:cas_number]
|
|
176
|
+
|
|
177
|
+
PhIntRadiopharmaceuticalMonograph.new(**model_attrs)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Create DosageFormMonograph model instance
|
|
181
|
+
def create_dosage_form_model(attrs, _sections)
|
|
182
|
+
PhIntDosageFormMonograph.new(**attrs)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Create TestMethod model instance
|
|
186
|
+
def create_test_method_model(attrs, title, _sections)
|
|
187
|
+
PhIntTestMethod.new(
|
|
188
|
+
**attrs,
|
|
189
|
+
method_id: attrs[:monograph_id],
|
|
190
|
+
pref_label: { "en" => title }
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Create Reagent model instance
|
|
195
|
+
def create_reagent_model(attrs, title, _sections)
|
|
196
|
+
PhIntReagent.new(
|
|
197
|
+
**attrs,
|
|
198
|
+
reagent_id: attrs[:monograph_id],
|
|
199
|
+
pref_label: { "en" => title }
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Extract molecular formula from HTML
|
|
204
|
+
def extract_molecular_formula(doc)
|
|
205
|
+
formula_elem = doc.at_xpath(".//p[contains(., 'Relative molecular mass')]")
|
|
206
|
+
return nil unless formula_elem
|
|
207
|
+
|
|
208
|
+
prev_elem = formula_elem.previous_element
|
|
209
|
+
return nil unless prev_elem
|
|
210
|
+
|
|
211
|
+
text = prev_elem.text.gsub(/<sub>/i, "").gsub(/<\/sub>/i, "").strip
|
|
212
|
+
text if text.match?(/\([A-Z][a-z]?\d*\)/)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Extract molecular weight from HTML
|
|
216
|
+
def extract_molecular_weight(doc)
|
|
217
|
+
weight_elem = doc.at_xpath(".//p[contains(., 'Relative molecular mass')]")
|
|
218
|
+
return nil unless weight_elem
|
|
219
|
+
|
|
220
|
+
match = weight_elem.text.match(/(\d+\.?\d*)/)
|
|
221
|
+
match[1].to_f if match
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Extract CAS number from HTML
|
|
225
|
+
def extract_cas_number(doc)
|
|
226
|
+
cas_elem = doc.at_xpath(".//p[contains(., 'CAS Reg. No')]")
|
|
227
|
+
return nil unless cas_elem
|
|
228
|
+
|
|
229
|
+
match = cas_elem.text.match(/CAS Reg\. No\.\s*(\d+-\d+-\d+)/i)
|
|
230
|
+
match[1] if match
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Extract chemical name from HTML
|
|
234
|
+
def extract_chemical_name(doc)
|
|
235
|
+
name_elem = doc.at_xpath(".//p[contains(., 'Chemical name')]")
|
|
236
|
+
return nil unless name_elem
|
|
237
|
+
|
|
238
|
+
text = name_elem.text.sub(/Chemical name\.\s*/i, "").strip
|
|
239
|
+
text if text.length > 10
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Extract content for a specific section
|
|
243
|
+
def extract_section_content(doc, section_name)
|
|
244
|
+
header = doc.at_xpath(".//p[contains(., '#{section_name}')]")
|
|
245
|
+
return nil unless header
|
|
246
|
+
|
|
247
|
+
text = header.text.sub(/.*#{section_name}\.?\s*/i, "").strip
|
|
248
|
+
text unless text.empty?
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Extract impurities list
|
|
252
|
+
def extract_impurities(doc)
|
|
253
|
+
impurities = []
|
|
254
|
+
|
|
255
|
+
doc.xpath(".//p[contains(., 'Impurities')]/following-sibling::p").each do |p|
|
|
256
|
+
text = p.text.strip
|
|
257
|
+
next if text.empty?
|
|
258
|
+
|
|
259
|
+
if text.match?(/^[A-Z]\.\s/)
|
|
260
|
+
impurities << {
|
|
261
|
+
letter: text[0],
|
|
262
|
+
description: text[3..].strip
|
|
263
|
+
}
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
impurities
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Determine monograph type from ID and sections
|
|
271
|
+
def determine_monograph_type(id, _sections)
|
|
272
|
+
case id
|
|
273
|
+
when /^6\.1\./ then "ChemicalDrugMonograph"
|
|
274
|
+
when /^6\.2\./ then "DosageFormMonograph"
|
|
275
|
+
when /^6\.3\./ then "RadiopharmaceuticalMonograph"
|
|
276
|
+
when /^6\.4\./ then "CrudeDrugMonograph"
|
|
277
|
+
when /^7\./ then "TestMethod"
|
|
278
|
+
when /^9\./ then "Reagent"
|
|
279
|
+
else "ChemicalDrugMonograph"
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Parse title into English and Latin names
|
|
284
|
+
def parse_title(title)
|
|
285
|
+
return [title, nil] unless title
|
|
286
|
+
|
|
287
|
+
match = title.match(/^(.+?)\s*\(([^)]+)\)/)
|
|
288
|
+
if match
|
|
289
|
+
[match[1].strip, match[2].strip]
|
|
290
|
+
else
|
|
291
|
+
[title, nil]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Generate URL-safe slug from title using SlugGenerator
|
|
296
|
+
def generate_slug(title)
|
|
297
|
+
Openphar::Core::SlugGenerator.generate_from_title(title)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Build TestSpecification model instances
|
|
301
|
+
def build_test_specifications(sections)
|
|
302
|
+
specs = []
|
|
303
|
+
|
|
304
|
+
if sections[:identity_tests]
|
|
305
|
+
specs << IdentificationSpecification.new(
|
|
306
|
+
test_name: { "en" => "Identity tests" },
|
|
307
|
+
test_type: "TestType-Identification",
|
|
308
|
+
test_conditions: { "en" => sections[:identity_tests] }
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
if sections[:related_substances]
|
|
313
|
+
specs << PuritySpecification.new(
|
|
314
|
+
test_name: { "en" => "Related substances" },
|
|
315
|
+
test_type: "TestType-Purity",
|
|
316
|
+
test_conditions: { "en" => sections[:related_substances] }
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
if sections[:assay]
|
|
321
|
+
specs << AssaySpecification.new(
|
|
322
|
+
test_name: { "en" => "Assay" },
|
|
323
|
+
test_type: "TestType-Assay",
|
|
324
|
+
test_conditions: { "en" => sections[:assay] }
|
|
325
|
+
)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
specs
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Openphar
|
|
4
|
+
module Parsers
|
|
5
|
+
# Parsers for various pharmacopoeia data formats.
|
|
6
|
+
#
|
|
7
|
+
# All parsers inherit from BaseMonographParser and return
|
|
8
|
+
# Lutaml::Model instances (not hashes).
|
|
9
|
+
|
|
10
|
+
# Abstract base class
|
|
11
|
+
autoload :BaseMonographParser, 'openphar/parsers/base_monograph_parser'
|
|
12
|
+
|
|
13
|
+
# Japan Pharmacopoeia parsers
|
|
14
|
+
autoload :JpHtmlParserBase, 'openphar/parsers/jp_html_parser_base'
|
|
15
|
+
autoload :JpHtmlParser, 'openphar/parsers/jp_html_parser'
|
|
16
|
+
autoload :JpJaHtmlParser, 'openphar/parsers/jp_ja_html_parser'
|
|
17
|
+
|
|
18
|
+
# International Pharmacopoeia parser
|
|
19
|
+
autoload :PhIntJsonParser, 'openphar/parsers/phint_json_parser'
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Openphar
|
|
4
|
+
module Registry
|
|
5
|
+
# Registry for pharmacopoeia publishers.
|
|
6
|
+
class PublisherRegistry
|
|
7
|
+
class << self
|
|
8
|
+
def get(code)
|
|
9
|
+
return nil if code.nil? || code.empty?
|
|
10
|
+
registry[code.to_s.upcase]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register(code, info)
|
|
14
|
+
registry[code.to_s.upcase] = info
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def registered?(code)
|
|
18
|
+
!get(code).nil?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def all_codes
|
|
22
|
+
registry.keys
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def namespace_for(code)
|
|
26
|
+
info = get(code)
|
|
27
|
+
return nil unless info
|
|
28
|
+
info[:namespace_module]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def clear
|
|
32
|
+
@registry = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def registry
|
|
38
|
+
@registry ||= {
|
|
39
|
+
'JP' => { namespace_module: Models::JP },
|
|
40
|
+
'PHINT' => { namespace_module: Models::PhInt }
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|