#!/usr/bin/env python3 """Двуязычный глоссарий из параллельных изданий одной книги. Задача: машинный перевод очередного тома цикла не должен расходиться с уже изданными переводами в именах и реалиях. Частотный список даёт только имена; пары вида White Council → Белый Совет так не получить. Метод — выравнивание по относительной позиции в тексте. Абзацы английского и русского изданий не совпадают ни числом, ни границами, но идут в одном порядке, поэтому термин, встречающийся в английском тексте на 12%, 34% и 78% длины, в переводе окажется примерно там же. Кандидат, чьи позиции совпали с позициями термина лучше, чем с текстом вообще, и есть перевод. Работает с fb2, epub и голым текстом. Только стандартная библиотека. """ import argparse import collections import difflib import html import re import sys import zipfile from pathlib import Path # Слова, с которых начинается предложение, — не имена собственные. RU_STOP = {"Когда", "Если", "Что", "Как", "Это", "Она", "Они", "Мы", "Вы", "Так", "Потом", "Затем", "Даже", "Его", "Её", "Их", "Может", "Все", "Теперь", "Нет", "Да", "Меня", "Мне", "Тогда", "Только", "После", "Пока", "Там", "Здесь", "Однако", "Впрочем", "Просто", "Один", "Вот", "Или", "Почему", "Мой", "Моя", "Кто", "Конечно", "Хорошо", "Ага", "Думаю", "Возможно", "Глава", "Никто", "Ничего", "Что-то", "Кажется", "Значит", "Сейчас"} EN_STOP = {"The", "A", "An", "And", "But", "I", "It", "He", "She", "They", "We", "You", "This", "That", "There", "Then", "When", "If", "So", "My", "His", "Her", "Chapter", "What", "Why", "How", "No", "Yes", "Not", "For", "Of", "In", "On", "At", "To", "With", "As", "Was", "Were", "Had", "Have", "Would", "Could", "Should", "One", "All", "Just", "Like", "Now", "Well", "Okay", "Oh", "Maybe", "Something", "Nothing", "Someone"} def read_text(path): """Текст книги из fb2, epub или txt — разметка выкидывается.""" p = Path(path) if p.suffix.lower() == ".epub": with zipfile.ZipFile(p) as z: parts = [z.read(n).decode("utf-8", "replace") for n in z.namelist() if n.lower().endswith((".xhtml", ".html"))] raw = "\n".join(parts) elif p.suffix.lower() in (".fb2", ".xml"): raw = p.read_bytes().decode("utf-8", "replace") else: raw = p.read_text(encoding="utf-8", errors="replace") raw = re.sub(r"<(script|style|head)[^>]*>.*?", " ", raw, flags=re.S | re.I) raw = re.sub(r"

||", "\n", raw, flags=re.I) return html.unescape(re.sub(r"<[^>]+>", " ", raw)) def paragraphs(text): return [p.strip() for p in text.split("\n") if len(p.strip()) > 40] def candidates(paras, pattern, stop, min_count): """{термин: [относительные позиции вхождений]} Позиция считается по символам, а не по номеру абзаца: русские издания разбивают диалоги построчно, и абзацев там втрое больше — по индексу тексты расходятся, по доле знаков идут почти вровень. """ pos = collections.defaultdict(list) total = sum(len(p) for p in paras) or 1 acc = 0 for p in paras: rel = acc / total acc += len(p) for m in set(pattern.findall(p)): head = m.split()[0] if head in stop or len(m) < 3: continue pos[m].append(rel) return {t: v for t, v in pos.items() if len(v) >= min_count} # Кириллица -> латиница для сравнения имён. Имена собственные в переводе почти # всегда транслитерация, и это признак куда надёжнее позиционного совпадения. LAT = {"а":"a","б":"b","в":"v","г":"g","д":"d","е":"e","ё":"e","ж":"j","з":"z", "и":"i","й":"i","к":"k","л":"l","м":"m","н":"n","о":"o","п":"p","р":"r", "с":"s","т":"t","у":"u","ф":"f","х":"h","ц":"c","ч":"c","ш":"s","щ":"s", "ъ":"","ы":"i","ь":"","э":"e","ю":"u","я":"a"} def translit(s): return "".join(LAT.get(c, c) for c in s.lower()) def name_similarity(en, ru): """Похожесть после огрубления: латиница обеих сторон без удвоений и гласных на конце. Nicodemus/Никодимус дают почти единицу, Harry/Молли — ноль.""" a = re.sub(r"[^a-z]", "", en.lower()) b = re.sub(r"[^a-z]", "", translit(ru)) a = re.sub(r"(.)\1+", r"\1", a) b = re.sub(r"(.)\1+", r"\1", b) if not a or not b: return 0.0 return difflib.SequenceMatcher(None, a, b[:len(a) + 3]).ratio() def overlap(a, b, tol): """Доля вхождений a, у которых нашлось вхождение b поблизости.""" if not a or not b: return 0.0 b = sorted(b) hit = 0 for x in a: lo, hi = 0, len(b) - 1 best = 1.0 while lo <= hi: mid = (lo + hi) // 2 best = min(best, abs(b[mid] - x)) if b[mid] < x: lo = mid + 1 else: hi = mid - 1 if best <= tol: hit += 1 return hit / len(a) def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--en", action="append", required=True, help="английское издание") ap.add_argument("--ru", action="append", required=True, help="русское издание того же тома") ap.add_argument("--min-count", type=int, default=4) ap.add_argument("--tol", type=float, default=0.02, help="допуск по относительной позиции") ap.add_argument("--score", type=float, default=0.6, help="порог уверенности") ap.add_argument("--top", type=int, default=80) args = ap.parse_args() if len(args.en) != len(args.ru): sys.exit("нужно одинаковое число --en и --ru: это должны быть пары изданий") EN = re.compile(r"\b[A-Z][a-z]+(?:\s+(?:of\s+|the\s+)?[A-Z][a-z]+){0,2}") lower_seen = collections.Counter() ru_lower = collections.Counter() RU = re.compile(r"\b[А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+){0,2}") en_pos, ru_pos = collections.defaultdict(list), collections.defaultdict(list) for i, (fe, fr) in enumerate(zip(args.en, args.ru)): pe, pr = paragraphs(read_text(fe)), paragraphs(read_text(fr)) # Настоящее имя собственное почти не встречается со строчной буквы: # так отсеиваются After, Once, More и прочие начала предложений. for w in re.findall(r"\b[a-z]{3,}\b", " ".join(pe)): lower_seen[w] += 1 for w in re.findall(r"\b[а-яё]{3,}\b", " ".join(pr)): ru_lower[w] += 1 print("пара %d: %d абзацев EN, %d RU" % (i + 1, len(pe), len(pr)), file=sys.stderr) for t, v in candidates(pe, EN, EN_STOP, 1).items(): en_pos[t] += [(i, x) for x in v] for t, v in candidates(pr, RU, RU_STOP, 1).items(): ru_pos[t] += [(i, x) for x in v] en_pos = {t: v for t, v in en_pos.items() if len(v) >= args.min_count and lower_seen[t.split()[0].lower()] < max(3, len(v) * 0.2)} # Зеркальный отсев: «Надеюсь», «Зачем», «Парень» — обычные слова, они # встречаются со строчной буквы и именами собственными быть не могут. ru_pos = {t: v for t, v in ru_pos.items() if len(v) >= args.min_count and ru_lower[t.split()[0].lower()] < max(3, len(v) * 0.2)} print("кандидатов: %d EN, %d RU" % (len(en_pos), len(ru_pos)), file=sys.stderr) by_book_ru = collections.defaultdict(dict) for t, v in ru_pos.items(): for b, x in v: by_book_ru[b].setdefault(t, []).append(x) out = [] for term, occ in sorted(en_pos.items(), key=lambda kv: -len(kv[1])): mine = collections.defaultdict(list) for b, x in occ: mine[b].append(x) best, best_score = None, 0.0 for cand, cocc in ru_pos.items(): # Частота кандидата должна быть сопоставима: «Гарри» встречается на # каждой странице и по одностороннему совпадению побеждает всех. if not (0.25 <= len(cocc) / len(occ) <= 4.0): continue fwd = back = tot_f = tot_b = 0.0 for b, xs in mine.items(): cand_pos = by_book_ru[b].get(cand) if not cand_pos: continue fwd += overlap(xs, cand_pos, args.tol) * len(xs) back += overlap(cand_pos, xs, args.tol) * len(cand_pos) tot_f += len(xs); tot_b += len(cand_pos) if tot_f < len(occ) * 0.5 or not tot_b: continue # Симметричная мера: термин должен находить перевод, а перевод — # термин. Иначе частотное слово выигрывает у настоящего соответствия. score = (fwd / tot_f) * (back / tot_b) # Транслитерация — сильный самостоятельный довод: если написание # совпадает, позиционного подтверждения нужно куда меньше. sim = name_similarity(term, cand) if sim >= 0.72: score = max(score, sim) + 0.25 elif sim < 0.3 and " " not in term: score *= 0.35 # разные имена, позиции совпали случайно if score > best_score: best, best_score = cand, score if best and best_score >= args.score: out.append((len(occ), best_score, term, best)) print("# частота | уверенность | оригинал -> перевод") seen = set() for n, sc, en, ru in out[:args.top]: if (en, ru) in seen: continue seen.add((en, ru)) print("%5d %.2f %-30s -> %s" % (n, sc, en, ru)) if __name__ == "__main__": main()