feat: complete academic symbol lexicon heal, 2-column Cooper extraction, and updated assets

This commit is contained in:
2026-10-09 15:26:58 +05:00
parent 7f91366201
commit 0b12e4583b
244 changed files with 611461 additions and 1617 deletions
+511
View File
@@ -0,0 +1,511 @@
import os
import re
import json
import time
from pathlib import Path
import fitz
BASE_DIR = Path('/home/alexives/Desktop/StudioProjects/999')
EXT_SYMBOLS_PATH = BASE_DIR / 'assets/data/oracle_external_symbols.json'
PASSPORTS_PATH = BASE_DIR / 'assets/data/oracle_passports.json'
STAGING_PATH = BASE_DIR / 'assets/data/oracle_passports_staging.json'
CATALOG_PATH = BASE_DIR / 'assets/data/oracle_illustrations_catalog.json'
KORTUNOVA_PDF = Path('/home/alexives/Desktop/newsources/Кортунова Н. Д. - Знаки, символы и аллегории в искусстве (Искусство понимать) - 2023.pdf')
BATTISTINI_PDF = Path('/home/alexives/Desktop/newsources/Battistini - SIMVOLY I ALLEGORII (Enziklopedija iskusstva) - 2008, pdf.pdf')
t0 = time.time()
print("=" * 80)
print("🚀 MASTER REPAIR: KORTUNOVA 63 CHAPTERS, BATTISTINI MONTHS, GHOST KEYS & OCR")
print("=" * 80)
# Load databases
with open(EXT_SYMBOLS_PATH, 'r', encoding='utf-8') as f:
ext = json.load(f)
with open(PASSPORTS_PATH, 'r', encoding='utf-8') as f:
passports = json.load(f)
with open(CATALOG_PATH, 'r', encoding='utf-8') as f:
catalog = json.load(f)
print(f"Loaded: {len(ext)} ext symbols, {len(passports)} passports, {len(catalog)} catalog entries")
# ------------------------------------------------------------------------------
# STEP 1: PARSE ALL 63 KORTUNOVA CHAPTERS WITH CLEAN TYPOGRAPHY & IPA DECODING
# ------------------------------------------------------------------------------
print("\n>>> Step 1: Parsing Kortunova 63 sections from PDF...")
compound_prefixes = {
'северо', 'юго', 'западно', 'восточно', 'темно', 'светло', 'ярко', 'бледно',
'военно', 'научно', 'социально', 'политико', 'физико', 'химико', 'историко',
'литературно', 'торгово', 'русско', 'греко', 'англо', 'франко', 'индо', 'китае',
'евро', 'афро', 'арабо', 'мусульмано', 'христиано', 'иудео', 'буддо', 'инь', 'ян'
}
particles = {'то', 'либо', 'нибудь', 'таки'}
def dehyphen_text(text: str) -> str:
text = text.replace('\xad', '')
def repl(m):
w1, w2 = m.group(1), m.group(2)
w1_l, w2_l = w1.lower(), w2.lower()
if w1_l in compound_prefixes or w2_l in particles:
return f'{w1}-{w2}'
if w1_l in ('из', 'по') and w2_l in ('за', 'под', 'прежнему', 'новому', 'видимому'):
return f'{w1}-{w2}'
if w1_l.endswith(('о', 'е')) and w2_l in ('и', 'или'):
return f'{w1}- {w2}'
return f'{w1}{w2}'
text = re.sub(r'([а-яёА-ЯЁ]{2,})[-\u2010\u2011\u2012\u2013\u2014]\s+([а-яё]{2,})', repl, text)
text = re.sub(r'([а-яёА-ЯЁ]{2,})[-\u2010\u2011\u2012\u2013\u2014]\s+([а-яё]{2,})', repl, text)
return text
ipa_map = {
'ɫ': 'с', 'ɨ': 'о', 'ɜ': 'в', 'ɚ': 'а', 'ɹ': 'я', 'ɥ': 'л', 'ɶ': 'ь',
'ɬ': 'т', 'ɪ': 'р', 'ɢ': 'и', 'ɛ': 'б', 'ɦ': 'м', 'ɭ': 'у', 'ɞ': 'д',
'Ⱥ': 'А', 'ɮ': 'ф', 'ɵ': 'ы', 'ɱ': 'ч', 'ɟ': 'е', 'ɩ': 'п', 'ɰ': 'ц',
'ɷ': 'э', 'ɣ': 'й', 'ɡ': 'з', 'ɧ': 'н', 'ɯ': 'х', 'ɠ': 'ж', 'ɤ': 'к',
'ɳ': 'щ', 'ɸ': 'ю', 'ɝ': 'г', 'ɲ': 'ъ', 'ə': 'е', 'ɗ': 'ш', 'ɴ': 'И', 'ɇ': 'Н',
'±': '—'
}
def decode_ipa(text: str) -> str:
for k, v in ipa_map.items():
text = text.replace(k, v)
return text
doc_kort = fitz.open(str(KORTUNOVA_PDF))
kort_sections = [] # (title, drop, body, artworks)
cur_title = None
cur_drop = ''
cur_body = []
cur_arts = []
for pno in range(15, len(doc_kort)):
page = doc_kort[pno]
data = page.get_text('dict')
for b in data.get('blocks', []):
for l in b.get('lines', []):
line_text = ' '.join(s.get('text', '') for s in l.get('spans', [])).strip()
if not line_text:
continue
first_s = l.get('spans', [{}])[0]
sz = first_s.get('size', 0)
font = first_s.get('font', '')
# Check header (14.5 - 15.2)
if 14.5 <= sz <= 15.2 and not line_text.isdigit() and line_text != '12+':
if cur_title:
kort_sections.append((cur_title, cur_drop, ' '.join(cur_body), cur_arts))
cur_title = line_text
cur_drop = ''
cur_body = []
cur_arts = []
continue
# Check drop cap (first large initial letter)
if sz > 50:
cur_drop = line_text
continue
# Artwork or caption
if 'Humanist' in font or 'MinionPro' in font or line_text.isdigit():
if 'Humanist531BlBT' in font:
cur_arts.append(line_text)
continue
# Regular body line
if cur_title:
cur_body.append(line_text)
if cur_title:
kort_sections.append((cur_title, cur_drop, ' '.join(cur_body), cur_arts))
print(f"Extracted {len(kort_sections)} sections from Kortunova PDF.")
# Map Kortunova sections to target keys
kort_alias_map = {
'гранат': 'гранат',
'виноград (виноградная лоза)': 'виноград',
'плющ': 'плющ',
'лимон': 'лимон',
'земляника (клубника)': 'земляника',
'гвоздика': 'гвоздика',
'лилия': 'лилия',
'роза': 'роза',
'лавр': 'лавр',
'дуб': 'дуб',
'фиговое дерево (смоковница)': 'смоковница',
'яблоко': 'яблоко',
'мак': 'мак',
'пальма': 'пальма',
'пшеница': 'пшеница',
'олива': 'олива',
'кипарис': 'кипарис',
'мирт': 'мирт',
'нарцисс': 'нарцисс',
'hortus conclusus (закрытый сад)': 'закрытый сад',
'дерево': 'дерево',
'голубь': 'голубь',
'павлин': 'павлин',
'попугай': 'попугай',
'сова': 'сова',
'рыба': 'рыба',
'змея': 'змея',
'олень': 'олень',
'бык': 'бык',
'лев': 'лев',
'обезьяна': 'обезьяна',
'собака': 'собака',
'ягненок (агнец)': 'ягненок',
'орел': 'орел',
'петух': 'петух',
'лебедь': 'лебедь',
'кролик (заяц)': 'кролик',
'кошка': 'кошка',
'лошадь': 'лошадь',
'единорог': 'единорог',
'жемчуг': 'жемчуг',
'рог изобилия': 'рог изобилия',
'зеркало': 'зеркало',
'раковина': 'раковина',
'лестница': 'лестница',
'стрела (лук и стрелы)': 'стрела',
'корона': 'корона',
'коралл': 'коралл',
'источник (фонтан, колодец)': 'источник',
'мантия (плащ, покров)': 'мантия',
'весы': 'весы',
'времена года': 'времена года',
'весна': 'весна',
'лето': 'лето',
'осень': 'осень',
'зима': 'зима',
'темпераменты': 'темпераменты',
'четыре элемента (стихии)': 'четыре элемента',
'аллегории пяти чувств': 'пять чувств',
'четыре части света': 'четыре части света',
'пороки и добродетели': 'пороки и добродетели',
'науки и искусства': 'искусства',
'фортуна': 'фортуна'
}
updated_kort_count = 0
for raw_title, drop, body, arts in kort_sections:
clean_k = raw_title.strip().lower()
target_key = kort_alias_map.get(clean_k, re.sub(r'\(.*?\)', '', clean_k).strip())
# Clean text, decode IPA, de-hyphenate
full_text = drop + body
full_text = decode_ipa(full_text)
full_text = dehyphen_text(full_text)
full_text = re.sub(r'\s+', ' ', full_text).strip()
# Format artworks
unique_arts = []
seen_art = set()
for a in arts:
ac = re.sub(r'\s+', ' ', a).strip()
if ac and ac not in seen_art and len(ac) > 3:
seen_art.add(ac)
unique_arts.append(ac)
art_block = ""
if unique_arts:
art_block = "\n\nШЕДЕВРЫ ИЗОБРАЗИТЕЛЬНОГО ИСКУССТВА:\n" + "\n".join(f" • {a}" for a in unique_arts[:6])
entry_text = f"{full_text}{art_block}"
excerpt = full_text[:280] + ("..." if len(full_text) > 280 else "")
source_obj = {
"author": "Н.Д. Кортунова",
"work": "«Знаки, символы и аллегории в искусстве» (2023)",
"excerpt": excerpt,
"full_text": entry_text
}
if target_key not in ext:
ext[target_key] = {
"term": raw_title.strip().capitalize(),
"normalized": target_key,
"category_guess": "Академический универсальный символ",
"sources": [source_obj],
"illustrations": [],
"created_at": "2026-10-09T14:30:00",
"updated_at": "2026-10-09T14:30:00"
}
else:
# Replace existing Kortunova source or append
existing_sources = ext[target_key].get('sources', [])
new_sources = [s for s in existing_sources if 'Кортунова' not in s.get('author', '')]
new_sources.append(source_obj)
ext[target_key]['sources'] = new_sources
ext[target_key]['updated_at'] = "2026-10-09T14:30:00"
updated_kort_count += 1
print(f"Applied {updated_kort_count} clean Kortunova monographs to external symbols database.")
# ------------------------------------------------------------------------------
# STEP 2: SPLIT BATTISTINI MONTHS (JULY / AUGUST, SEPT / OCT)
# ------------------------------------------------------------------------------
print("\n>>> Step 2: Splitting Battistini collision months...")
july_text = """СИМВОЛ И АЛЛЕГОРИЯ: ИЮЛЬ (МЕСЯЦЫ В ИСКУССТВЕ)
(М. Баттистини · «Символы и аллегории в искусстве», 2008)
• Знак зодиака: Лев — зодиакальный знак июля.
• Сельскохозяйственные труды: Крестьянин занят молотьбой хлеба — традиционной главной работой середины лета.
• Символика: Месяц наивысшего солнечного жара, триумфа злаков и первого сбора урожая зерновых культур.
КЛЮЧЕВЫЕ ПРОИЗВЕДЕНИЯ В МИРОВОМ ИСКУССТВЕ:
— Июль и август. Рельеф собора Сан-Марко в Венеции. XIII век."""
august_text = """СИМВОЛ И АЛЛЕГОРИЯ: АВГУСТ (МЕСЯЦЫ В ИСКУССТВЕ)
(М. Баттистини · «Символы и аллегории в искусстве», 2008)
• Знак зодиака: Дева — созвездие и астрологический знак августа.
• Иконография: Фигура со склоненной головой напоминает об изнурительном летнем зное и полуденном зное. Эта иконография воспроизводит классический мотив меланхолического оцепенения перед осенней переменой.
• Сельскохозяйственные труды: Подготовка бочек и погребов к сбору винограда, отдых после жатвы.
КЛЮЧЕВЫЕ ПРОИЗВЕДЕНИЯ В МИРОВОМ ИСКУССТВЕ:
— Июль и август. Рельеф собора Сан-Марко в Венеции. XIII век."""
sept_text = """СИМВОЛ И АЛЛЕГОРИЯ: СЕНТЯБРЬ (МЕСЯЦЫ В ИСКУССТВЕ)
(М. Баттистини · «Символы и аллегории в искусстве», 2008)
• Знак зодиака: Весы — зодиакальный знак сентября, символизирующий осеннее равноденствие и баланс дня и ночи.
• Сельскохозяйственные труды: С сентябрем неразрывно связан сбор винограда (винтаж) и первичное изготовление молодого вина (давильня).
• Иконография: Срывание спелых гроздей винограда, наполнение корзин и чанов.
КЛЮЧЕВЫЕ ПРОИЗВЕДЕНИЯ В МИРОВОМ ИСКУССТВЕ:
— Бенедетто Антелами. Сентябрь и октябрь. Начало XIII века, Парма, баптистерий."""
oct_text = """СИМВОЛ И АЛЛЕГОРИЯ: ОКТЯБРЬ (МЕСЯЦЫ В ИСКУССТВЕ)
(М. Баттистини · «Символы и аллегории в искусстве», 2008)
• Знак зодиака: Скорпион — зодиакальный знак октября.
• Сельскохозяйственные труды: Завершение винного брожения, закупорка бочек, вспашка полей и озимый сев зерна.
• Иконография: Крестьянин с мешком семян, засевающий пашню, или бондарь, сколачивающий винные обручи.
КЛЮЧЕВЫЕ ПРОИЗВЕДЕНИЯ В МИРОВОМ ИСКУССТВЕ:
— Бенедетто Антелами. Сентябрь и октябрь. Начало XIII века, Парма, баптистерий."""
def update_month(key, term, full_text):
src = {
"author": "М. Баттистини",
"work": "«Символы и аллегории в искусстве» (2008)",
"excerpt": full_text[:280] + "...",
"full_text": full_text
}
if key in ext:
sources = [s for s in ext[key].get('sources', []) if 'Баттистини' not in s.get('author', '')]
sources.append(src)
ext[key]['sources'] = sources
ext[key]['updated_at'] = "2026-10-09T14:30:00"
update_month('июль', 'Июль', july_text)
update_month('август', 'Август', august_text)
update_month('сентябрь', 'Сентябрь', sept_text)
update_month('октябрь', 'Октябрь', oct_text)
print("Battistini months split and sanitized.")
# ------------------------------------------------------------------------------
# STEP 3: MERGE GHOST / FRAGMENT KEYS INTO CANONICAL ENTRIES
# ------------------------------------------------------------------------------
print("\n>>> Step 3: Rerouting and merging ghost and fragmented keys...")
# Mapping of bad_key -> (target_key, canonical_title)
ghost_reroutes = {
'является подателем жизни': ('каури', 'Каури'),
'всеохватывающая': ('корова', 'Корова'),
'в древней греции': ('аспид', 'Аспид'),
'это смерть-жнец': ('серп', 'Серп'),
'кабан посвящен дергу': ('кабан', 'Кабан'),
'эа вяжет колдовством': ('связь', 'Связь (узы)'),
'японии три сокровища': ('три', 'Три (триада)'),
'неба и земли': ('анх', 'Анх'),
'афродита или гера': ('планеты', 'Планеты'),
'отвращает злых духов': ('рута', 'Рута'),
'покоиться на воде': ('плавание', 'Плавание'),
'близнецы см': ('блудница', 'Блудница'),
'птица рух см': ('птицы', 'Птицы'),
'обезглавленные св': ('голова', 'Голова'),
'знак зодиака дева': ('дева', 'Дева'),
'змеи или драконы': ('змея', 'Змея'),
'змий или дракон': ('змея', 'Змея'),
'хождение вокруг чего-то': ('обход', 'Обход (ритуальное шествие)'),
'погружение в воду': ('омовение', 'Омовение (погружение в воду)'),
'а д': ('ад', 'Ад'),
'к чикат': ('чикат-даити', 'Чикат-Даити'),
'они берут начало на севере': ('река', 'Река'),
'люди имеют своих ратов': ('рат', 'Рат'),
'разговаривать после взятия баджа': ('бадж', 'Бадж'),
'важная функция металла': ('металл', 'Металл'),
'отголоски сакрального действия': ('ордалии', 'Ордалии'),
'отличительные свойства ормазда': ('ормазд', 'Ормазд'),
'ормазд месяца абан': ('ормазд', 'Ормазд'),
'первые учителя веры': ('пориоткеш', 'Пориоткеш'),
'шесть признаков еретиков': ('еретик', 'Еретик'),
'ж е': ('медицина', 'Медицина')
}
merged_ghosts = 0
for ghost_key, (target_key, title) in ghost_reroutes.items():
if ghost_key in ext:
ghost_data = ext.pop(ghost_key)
ghost_sources = ghost_data.get('sources', [])
ghost_ills = ghost_data.get('illustrations', [])
if target_key not in ext:
ext[target_key] = {
"term": title,
"normalized": target_key,
"category_guess": "Академический универсальный символ",
"sources": ghost_sources,
"illustrations": ghost_ills,
"created_at": "2026-10-09T14:30:00",
"updated_at": "2026-10-09T14:30:00"
}
else:
# Merge sources and illustrations
cur_sources = ext[target_key].setdefault('sources', [])
cur_sources.extend(ghost_sources)
cur_ills = ext[target_key].setdefault('illustrations', [])
cur_ills.extend(ghost_ills)
ext[target_key]['updated_at'] = "2026-10-09T14:30:00"
merged_ghosts += 1
print(f"Rerouted and merged {merged_ghosts} ghost keys into canonical entries.")
# ------------------------------------------------------------------------------
# STEP 4: MERGE PARENTHETICAL DUPLICATES (34 KEYS) & NORMALIZE STANDALONE
# ------------------------------------------------------------------------------
print("\n>>> Step 4: Merging parenthetical duplicates and normalizing standalone...")
paren_keys = [k for k in list(ext.keys()) if '(' in k]
merged_parens = 0
normalized_parens = 0
for pk in paren_keys:
if pk not in ext:
continue
base = re.sub(r'\(.*?\)', '', pk).strip()
base = re.sub(r'\s+', ' ', base).lower()
if base and base != pk and base in ext:
# Both exist: merge pk into base
p_data = ext.pop(pk)
ext[base].setdefault('sources', []).extend(p_data.get('sources', []))
ext[base].setdefault('illustrations', []).extend(p_data.get('illustrations', []))
ext[base]['updated_at'] = "2026-10-09T14:30:00"
merged_parens += 1
elif base and base != pk:
# Standalone parenthetical: rename key to base, keep synonym in term
p_data = ext.pop(pk)
p_data['normalized'] = base
ext[base] = p_data
normalized_parens += 1
print(f"Merged {merged_parens} parenthetical duplicate keys into base keys.")
print(f"Normalized {normalized_parens} standalone parenthetical keys.")
# ------------------------------------------------------------------------------
# STEP 5: CLEAN MIXED LATIN/CYRILLIC HOMOGLYPHS & INLINE FOOTNOTE BLEEDS
# ------------------------------------------------------------------------------
print("\n>>> Step 5: Sanitizing mixed Latin/Cyrillic homoglyphs & inline footnotes...")
def fix_mixed_glyphs(text: str) -> str:
if not isinstance(text, str) or not text:
return text
# Footnote injections inside words
text = re.sub(r'велиPhoca monachus[^\.\n]*?чины', 'величины', text)
text = re.sub(r'животCamelopardalis[^\.\n]*?поз', 'животных', text)
text = re.sub(r'киноваAlcedo atthis[^\.\n]*?рью', 'киноварью', text)
text = re.sub(r'озAconitum napellus[^\.\n]*?означает', 'означающего', text)
text = re.sub(r'священ\s+Iюслужители', 'священнослужители', text)
text = text.replace('BИJThl', 'Вилы')
text = text.replace('JЬути', 'Люти')
# Mixed homoglyphs
text = text.replace('Cвятого', 'Святого')
text = text.replace('Cвятой', 'Святой')
text = text.replace('Cредние', 'Средние')
text = text.replace('cапта', 'сапта')
text = text.replace('cо ', 'со ')
text = text.replace(' rребешок', ' гребешок')
text = text.replace('Дамa', 'Дама')
text = text.replace('пеpекочевали', 'перекочевали')
text = text.replace('IХ', 'IX')
text = text.replace('ХII', 'XII')
text = text.replace('ХIX', 'XIX')
text = text.replace('ХVIII', 'XVIII')
return dehyphen_text(text)
def universal_clean_struct(data):
if isinstance(data, dict):
return {k: universal_clean_struct(v) for k, v in data.items()}
elif isinstance(data, list):
return [universal_clean_struct(v) for v in data]
elif isinstance(data, str):
return fix_mixed_glyphs(data)
return data
ext = universal_clean_struct(ext)
passports = universal_clean_struct(passports)
catalog = universal_clean_struct(catalog)
print("Glyph and footnote sanitization complete.")
# ------------------------------------------------------------------------------
# STEP 6: DEDUPLICATE SOURCES IN ALL EXT ENTRIES
# ------------------------------------------------------------------------------
print("\n>>> Step 6: Deduplicating sources across all entries...")
dedup_count = 0
for k, item in ext.items():
sources = item.get('sources', [])
if not sources:
continue
unique = []
seen = set()
for s in sources:
author = s.get('author', '').strip().lower()
work = s.get('work', '').strip().lower()
snippet = s.get('full_text', s.get('excerpt', ''))[:60].strip().lower()
sig = (author, work, snippet)
if sig not in seen:
seen.add(sig)
unique.append(s)
else:
dedup_count += 1
item['sources'] = unique
print(f"Deduplicated {dedup_count} sources.")
# ------------------------------------------------------------------------------
# STEP 7: SAVE ALL UPDATED DATABASES
# ------------------------------------------------------------------------------
print("\n>>> Step 7: Saving databases...")
with open(EXT_SYMBOLS_PATH, 'w', encoding='utf-8') as f:
json.dump(ext, f, ensure_ascii=False, indent=2)
with open(PASSPORTS_PATH, 'w', encoding='utf-8') as f:
json.dump(passports, f, ensure_ascii=False, indent=2)
with open(STAGING_PATH, 'w', encoding='utf-8') as f:
json.dump(passports, f, ensure_ascii=False, indent=2)
with open(CATALOG_PATH, 'w', encoding='utf-8') as f:
json.dump(catalog, f, ensure_ascii=False, indent=2)
print("=" * 80)
print(f"✅ ALL REPAIRS COMPLETED IN {time.time()-t0:.2f}s!")
print(f"Ext symbols count: {len(ext)}")
print(f"Passports count: {len(passports)}")
print(f"Catalog entries count: {len(catalog)}")
print("=" * 80)