Coverage for src/wiktextract/extractor/ru/page.py: 78%

246 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-12 07:03 +0000

1import re 

2from typing import Any 

3 

4from wikitextprocessor.parser import ( 

5 LEVEL_KIND_FLAGS, 

6 LevelNode, 

7 NodeKind, 

8 TemplateNode, 

9 WikiNode, 

10) 

11 

12from ...config import POSSubtitleData 

13from ...page import clean_node 

14from ...wxr_context import WiktextractContext 

15from ...wxr_logging import logger 

16from .etymology import extract_etymology 

17from .gloss import extract_gloss, process_meaning_template 

18from .inflection import ( 

19 extract_прил_ru_comparative_forms, 

20 parse_html_forms_table, 

21 parse_wikitext_forms_table, 

22) 

23from .linkage import ( 

24 extract_alt_form_section, 

25 extract_linkage_section, 

26 extract_phrase_section, 

27) 

28from .models import AltForm, Form, Hyphenation, Sense, Sound, WordEntry 

29from .pronunciation import ( 

30 extract_homophone_section, 

31 extract_pronunciation_section, 

32 extract_rhyme_section, 

33) 

34from .section_titles import ( 

35 ALT_FORM_SECTIONS, 

36 LINKAGE_TITLES, 

37 POS_TEMPLATE_NAMES, 

38 POS_DATA, 

39) 

40from .tags import MORPHOLOGICAL_TEMPLATE_TAGS 

41from .translation import extract_translations 

42 

43 

44def process_semantic_section( 

45 wxr: WiktextractContext, page_data: list[WordEntry], level_node: LevelNode 

46): 

47 for list_node in level_node.find_child(NodeKind.LIST): 

48 for template_node in list_node.find_child_recursively( 

49 NodeKind.TEMPLATE 

50 ): 

51 if template_node.template_name == "значение": 51 ↛ 48line 51 didn't jump to line 48 because the condition on line 51 was always true

52 sense = process_meaning_template( 

53 wxr, None, page_data[-1], template_node 

54 ) 

55 if len(sense.glosses) > 0: 55 ↛ 48line 55 didn't jump to line 48 because the condition on line 55 was always true

56 page_data[-1].senses.append(sense) 

57 

58 

59MORPH_TEMPLATE_ARGS = { 

60 "p": "prefix", 

61 "prefix": "prefix", 

62 "i": "interfix", 

63 "interfix": "interfix", 

64 "in": "infix", 

65 "infix": "infix", 

66 "s": "suffix", 

67 "suffix": "suffix", 

68 "t": "transfix", 

69 "transfix": "transfix", 

70 "po": "suffix", 

71 "postfix": "suffix", 

72 "c": "circumfix", 

73 "confix": "circumfix", 

74 "circumfix": "circumfix", 

75 "r": "root", 

76 "e": "suffix", 

77 "ending": "suffix", 

78} 

79 

80 

81def get_pos_from_template( 

82 wxr: WiktextractContext, template_node: TemplateNode 

83) -> POSSubtitleData | None: 

84 # Search for POS in template names 

85 template_name = template_node.template_name.lower() 

86 if template_name == "morph": 

87 # https://ru.wiktionary.org/wiki/Шаблон:morph 

88 pos_type = template_node.template_parameters.get("тип", "") 

89 if pos_type in MORPH_TEMPLATE_ARGS: 89 ↛ 107line 89 didn't jump to line 107 because the condition on line 89 was always true

90 return { 

91 "pos": MORPH_TEMPLATE_ARGS[pos_type], 

92 "tags": ["morpheme"], 

93 } 

94 elif ( 

95 template_name in {"заголовок", "з"} 

96 and 1 in template_node.template_parameters 

97 ): 

98 pos_text = clean_node( 

99 wxr, None, template_node.template_parameters[1] 

100 ).strip("()") 

101 if len(pos_text) == 0: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true

102 return 

103 pos_text = pos_text.split()[0] 

104 if pos_text in POS_DATA: 

105 return POS_DATA[pos_text] 

106 

107 if template_name.startswith("прил ru"): 

108 pos_arg = clean_node( 

109 wxr, None, template_node.template_parameters.get("часть речи", "") 

110 ).lower() 

111 if pos_arg != "": 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true

112 for pos_string in POS_DATA.keys(): 

113 if pos_string in pos_arg: 

114 return POS_DATA[pos_string] 

115 else: 

116 return {"pos": "adj"} 

117 

118 for part in template_name.split(maxsplit=2): 

119 for subpart in part.split("-", maxsplit=2): 

120 if subpart in POS_TEMPLATE_NAMES: 

121 return POS_TEMPLATE_NAMES[subpart] 

122 

123 

124def get_pos( 

125 wxr: WiktextractContext, level_node: WikiNode 

126) -> POSSubtitleData | None: 

127 for template_node in level_node.find_child(NodeKind.TEMPLATE): 

128 pos_data = get_pos_from_template(wxr, template_node) 

129 if pos_data is not None: 129 ↛ 127line 129 didn't jump to line 127 because the condition on line 129 was always true

130 return pos_data 

131 # POS text could also in level node content 

132 for template_node in level_node.find_content(NodeKind.TEMPLATE): 

133 pos_data = get_pos_from_template(wxr, template_node) 

134 if pos_data is not None: 

135 return pos_data 

136 

137 # Search for POS in section text 

138 text = clean_node( 

139 wxr, 

140 None, 

141 list( 

142 level_node.invert_find_child( 

143 LEVEL_KIND_FLAGS, include_empty_str=True 

144 ) 

145 ), 

146 ) 

147 for pos_string in POS_DATA.keys(): 

148 if pos_string in text.lower(): 

149 return POS_DATA[pos_string] 

150 

151 

152def extract_morphological_section( 

153 wxr: WiktextractContext, page_data: list[WordEntry], level_node: WikiNode 

154) -> None: 

155 pos_data = get_pos(wxr, level_node) 

156 if pos_data is not None: 

157 page_data[-1].pos = pos_data["pos"] 

158 page_data[-1].tags.extend(pos_data.get("tags", [])) 

159 for child_node in level_node.find_child(NodeKind.TEMPLATE): 

160 expanded_template = wxr.wtp.parse( 

161 wxr.wtp.node_to_wikitext(child_node), expand_all=True 

162 ) 

163 clean_node(wxr, page_data[-1], expanded_template) # add category links 

164 if child_node.template_name.startswith( 

165 ( 

166 "прил ru", 

167 "прил-ru", 

168 "сущ ", 

169 "сущ-ru", 

170 "гл ", 

171 "гл-ru", 

172 "мест ru ", 

173 "числ ru ", 

174 "числ-", 

175 "прич ru ", 

176 "Фам ru ", 

177 "падежи ", 

178 ) 

179 ): 

180 for table_node in expanded_template.find_child_recursively( 

181 NodeKind.TABLE 

182 ): 

183 parse_wikitext_forms_table(wxr, page_data[-1], table_node) 

184 for table_tag in expanded_template.find_html("table"): 

185 parse_html_forms_table(wxr, page_data[-1], table_tag) 

186 h_str = clean_node( 

187 wxr, None, child_node.template_parameters.get("слоги", "") 

188 ) 

189 if h_str != "": 

190 page_data[-1].hyphenations.append( 

191 Hyphenation(parts=h_str.split("-")) 

192 ) 

193 

194 if child_node.template_name.startswith(("прил ru", "прил-ru")): 

195 extract_прил_ru_comparative_forms( 

196 wxr, page_data[-1], expanded_template 

197 ) 

198 

199 for node in expanded_template.children: 

200 node_text = clean_node(wxr, page_data[-1], node) 

201 for text in node_text.split(","): 

202 text = text.strip() 

203 if text in MORPHOLOGICAL_TEMPLATE_TAGS: 

204 tr_tag = MORPHOLOGICAL_TEMPLATE_TAGS[text] 

205 if isinstance(tr_tag, str): 205 ↛ 207line 205 didn't jump to line 207 because the condition on line 205 was always true

206 page_data[-1].tags.append(tr_tag) 

207 elif isinstance(tr_tag, list): 

208 page_data[-1].tags.extend(tr_tag) 

209 

210 

211def parse_section( 

212 wxr: WiktextractContext, page_data: list[WordEntry], level_node: WikiNode 

213) -> None: 

214 section_title = clean_node(wxr, None, level_node.largs).lower() 

215 wxr.wtp.start_subsection(section_title) 

216 if section_title in [ 

217 # Morphological and syntactic properties 

218 "морфологические и синтаксические свойства", 

219 # Type and syntactic properties of the word combination 

220 "тип и синтаксические свойства сочетания", 

221 "тип и свойства сочетания", 

222 ]: 

223 extract_morphological_section(wxr, page_data, level_node) 

224 elif section_title in POS_DATA: 

225 pos_data = POS_DATA[section_title] 

226 page_data[-1].pos = pos_data["pos"] 

227 page_data[-1].tags.extend(pos_data.get("tags", [])) 

228 extract_gloss(wxr, page_data[-1], level_node) 

229 elif section_title == "произношение" and wxr.config.capture_pronunciation: 

230 extract_pronunciation_section(wxr, page_data[-1], level_node) 

231 elif section_title == "семантические свойства": # Semantic properties 

232 process_semantic_section(wxr, page_data, level_node) 

233 elif section_title in [ 

234 "значение", 

235 "значения", 

236 "как самостоятельный глагол", 

237 "в значении вспомогательного глагола или связки", 

238 ]: 

239 extract_gloss(wxr, page_data[-1], level_node) 

240 elif section_title == "этимология" and wxr.config.capture_etymologies: 

241 extract_etymology(wxr, page_data[-1], level_node) 

242 elif ( 

243 section_title 

244 in [ 

245 "фразеологизмы и устойчивые сочетания", 

246 "типичные сочетания", 

247 "фразеологизмы", 

248 "пословицы и поговорки", 

249 ] 

250 and wxr.config.capture_linkages 

251 ): 

252 extract_phrase_section(wxr, page_data[-1], level_node, section_title) 

253 elif ( 

254 section_title in ["перевод", "иноязычные аналоги"] 

255 and wxr.config.capture_translations 

256 ): 

257 extract_translations(wxr, page_data[-1], level_node) 

258 elif section_title in LINKAGE_TITLES and wxr.config.capture_linkages: 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true

259 extract_linkage_section( 

260 wxr, page_data[-1], LINKAGE_TITLES[section_title], level_node 

261 ) 

262 elif section_title == "библиография": 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true

263 pass 

264 elif section_title in ["латиница (latinça)", "латиница (latinca)"]: 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true

265 parse_roman_section(wxr, page_data[-1], level_node) 

266 elif section_title == "прочее": 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true

267 pass 

268 elif section_title == "омофоны" and wxr.config.capture_pronunciation: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 extract_homophone_section(wxr, page_data[-1], level_node) 

270 elif section_title in ALT_FORM_SECTIONS: 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 extract_alt_form_section( 

272 wxr, page_data[-1], level_node, ALT_FORM_SECTIONS[section_title] 

273 ) 

274 elif section_title == "рифмы": 274 ↛ 276line 274 didn't jump to line 276 because the condition on line 274 was always true

275 extract_rhyme_section(wxr, page_data[-1], level_node) 

276 elif section_title not in ["см. также", "смотреть также", "смотрите также"]: 

277 wxr.wtp.debug( 

278 f"Unprocessed section {section_title}", 

279 sortid="wixtextract/extractor/ru/page/parse_section/66", 

280 ) 

281 

282 for next_level_node in level_node.find_child(LEVEL_KIND_FLAGS): 

283 parse_section(wxr, page_data, next_level_node) 

284 

285 extract_section_end_templates(wxr, page_data[-1], level_node) 

286 

287 

288def parse_page( 

289 wxr: WiktextractContext, page_title: str, page_text: str 

290) -> list[dict[str, Any]]: 

291 # Help site describing page structure: 

292 # https://ru.wiktionary.org/wiki/Викисловарь:Правила_оформления_статей 

293 

294 if wxr.config.verbose: 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true

295 logger.info(f"Parsing page: {page_title}") 

296 wxr.config.word = page_title 

297 wxr.wtp.start_page(page_title) 

298 tree = wxr.wtp.parse(page_text) 

299 page_data: list[WordEntry] = [] 

300 

301 for level1_node in tree.find_child(NodeKind.LEVEL1): 

302 lang_code = "" 

303 for subtitle_template in level1_node.find_content(NodeKind.TEMPLATE): 303 ↛ 306line 303 didn't jump to line 306 because the loop on line 303 didn't complete

304 lang_code = subtitle_template.template_name.strip(" -") 

305 break 

306 if lang_code == "": 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true

307 lang_code = "unknown" 

308 if ( 308 ↛ 312line 308 didn't jump to line 312 because the condition on line 308 was never true

309 wxr.config.capture_language_codes is not None 

310 and lang_code not in wxr.config.capture_language_codes 

311 ): 

312 continue 

313 categories = {"categories": []} 

314 lang_name = clean_node(wxr, categories, level1_node.largs) 

315 wxr.wtp.start_section(lang_name) 

316 base_data = WordEntry( 

317 lang=lang_name, 

318 lang_code=lang_code, 

319 word=page_title, 

320 pos="unknown", 

321 ) 

322 base_data.categories.extend(categories["categories"]) 

323 extract_section_end_templates(wxr, base_data, level1_node) 

324 pos_data = get_pos(wxr, level1_node) 

325 if pos_data is not None: 

326 base_data.pos = pos_data["pos"] 

327 base_data.tags.extend(pos_data.get("tags", [])) 

328 

329 for level2_node in level1_node.find_child(NodeKind.LEVEL2): 

330 if base_data.pos == "unknown": 

331 pos_data = get_pos(wxr, level2_node) 

332 if pos_data is not None: 

333 base_data.pos = pos_data["pos"] 

334 base_data.tags.extend(pos_data.get("tags", [])) 

335 page_data.append(base_data.model_copy(deep=True)) 

336 extract_level2_node_contents(wxr, page_data[-1], level2_node) 

337 has_level3 = False 

338 for level3_node in level2_node.find_child(NodeKind.LEVEL3): 

339 parse_section(wxr, page_data, level3_node) 

340 has_level3 = True 

341 if page_data[-1] == base_data or not has_level3: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true

342 page_data.pop() 

343 extract_low_quality_page(wxr, page_data, base_data, level2_node) 

344 

345 for any_level_index, any_level_node in enumerate( 

346 level1_node.find_child(LEVEL_KIND_FLAGS & ~NodeKind.LEVEL2) 

347 ): 

348 if any_level_index == 0 and ( 

349 len(page_data) == 0 

350 or page_data[-1].lang_code != base_data.lang_code 

351 ): 

352 page_data.append(base_data.model_copy(deep=True)) 

353 parse_section(wxr, page_data, any_level_node) 

354 

355 if len(page_data) > 0 and page_data[-1] == base_data: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true

356 page_data.pop() 

357 extract_low_quality_page(wxr, page_data, base_data, level1_node) 

358 

359 for d in page_data: 

360 if len(d.senses) == 0: 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true

361 d.senses.append(Sense(tags=["no-gloss"])) 

362 return [d.model_dump(exclude_defaults=True) for d in page_data] 

363 

364 

365def extract_low_quality_page( 

366 wxr: WiktextractContext, 

367 page_data: list[WordEntry], 

368 base_data: WordEntry, 

369 level_node: WikiNode, 

370) -> None: 

371 for node in level_node.invert_find_child(LEVEL_KIND_FLAGS): 

372 if isinstance(node, TemplateNode) and node.template_name.startswith( 

373 "Форма-" 

374 ): 

375 process_form_template(wxr, page_data, base_data, node) 

376 elif isinstance(node, WikiNode): 

377 for template_node in node.find_child_recursively(NodeKind.TEMPLATE): 

378 if template_node.template_name.startswith("Форма-"): 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was never true

379 process_form_template( 

380 wxr, page_data, base_data, template_node 

381 ) 

382 

383 

384def process_form_template( 

385 wxr: WiktextractContext, 

386 page_data: list[WordEntry], 

387 base_data: WordEntry, 

388 template_node: TemplateNode, 

389) -> None: 

390 # https://ru.wiktionary.org/wiki/Шаблон:Форма-сущ 

391 # Шаблон:Форма-гл, "Шаблон:форма-гл en" 

392 pos_data = get_pos_from_template(wxr, template_node) 

393 if pos_data is not None: 393 ↛ 397line 393 didn't jump to line 397 because the condition on line 393 was always true

394 base_data.pos = pos_data["pos"] 

395 base_data.tags.extend(pos_data.get("tags", [])) 

396 

397 form_of = clean_node( 

398 wxr, 

399 None, 

400 template_node.template_parameters.get( 

401 "база", template_node.template_parameters.get(1, "") 

402 ), 

403 ) 

404 ipa = clean_node( 

405 wxr, None, template_node.template_parameters.get("МФА", "") 

406 ) 

407 expanded_node = wxr.wtp.parse( 

408 wxr.wtp.node_to_wikitext(template_node), expand_all=True 

409 ) 

410 current_data = base_data.model_copy(deep=True) 

411 for list_item in expanded_node.find_child_recursively(NodeKind.LIST_ITEM): 

412 gloss_text = clean_node(wxr, None, list_item.children) 

413 if len(gloss_text) > 0: 413 ↛ 411line 413 didn't jump to line 411 because the condition on line 413 was always true

414 sense = Sense(glosses=[gloss_text]) 

415 if len(form_of) > 0: 415 ↛ 418line 415 didn't jump to line 418 because the condition on line 415 was always true

416 sense.form_of.append(AltForm(word=form_of)) 

417 sense.tags.append("form-of") 

418 current_data.senses.append(sense) 

419 

420 if len(ipa) > 0: 420 ↛ 422line 420 didn't jump to line 422 because the condition on line 420 was always true

421 current_data.sounds.append(Sound(ipa=ipa)) 

422 if len(current_data.senses) > 0 or len(current_data.sounds) > 0: 422 ↛ exitline 422 didn't return from function 'process_form_template' because the condition on line 422 was always true

423 clean_node(wxr, current_data, template_node) 

424 page_data.append(current_data) 

425 

426 

427def parse_roman_section( 

428 wxr: WiktextractContext, word_entry: WordEntry, level_node: WikiNode 

429) -> None: 

430 for link_node in level_node.find_child(NodeKind.LINK): 

431 form_text = clean_node(wxr, None, link_node) 

432 if form_text != "": 

433 form = Form(form=form_text, tags=["romanization"]) 

434 word_entry.forms.append(form) 

435 

436 

437def extract_section_end_templates( 

438 wxr: WiktextractContext, word_entry: WordEntry, level_node: WikiNode 

439) -> None: 

440 # category link templates 

441 # https://ru.wiktionary.org/wiki/Категория:Викисловарь:Шаблоны_категоризации 

442 for template_node in level_node.find_child(NodeKind.TEMPLATE): 

443 if template_node.template_name in { 443 ↛ 455line 443 didn't jump to line 455 because the condition on line 443 was never true

444 "-ание", 

445 "-атель", 

446 "-ация", 

447 "-ение", 

448 "-ка", 

449 "длина слова", 

450 "Категория", 

451 "Омонимы", 

452 "forms", 

453 "multilang", 

454 }: 

455 clean_node(wxr, word_entry, template_node) 

456 elif template_node.template_name == "zh-forms": 456 ↛ 457line 456 didn't jump to line 457 because the condition on line 456 was never true

457 extract_zh_forms_template(wxr, word_entry, template_node) 

458 

459 

460def extract_zh_forms_template( 

461 wxr: WiktextractContext, 

462 base_data: WordEntry, 

463 template_node: TemplateNode, 

464) -> None: 

465 # https://ru.wiktionary.org/wiki/Шаблон:zh-forms 

466 # https://ru.wiktionary.org/wiki/Модуль:zh-forms 

467 # similar to en and zh edition template 

468 for p_name, p_value in template_node.template_parameters.items(): 

469 if not isinstance(p_name, str): 

470 continue 

471 if re.fullmatch(r"s\d*", p_name): 

472 form_data = Form( 

473 form=clean_node(wxr, None, p_value), tags=["Simplified-Chinese"] 

474 ) 

475 if form_data.form not in ["", wxr.wtp.title]: 

476 base_data.forms.append(form_data) 

477 elif re.fullmatch(r"t\d*", p_name): 

478 form_data = Form( 

479 form=clean_node(wxr, None, p_value), 

480 tags=["Traditional-Chinese"], 

481 ) 

482 if form_data.form not in ["", wxr.wtp.title]: 

483 base_data.forms.append(form_data) 

484 elif p_name == "lit": 

485 base_data.literal_meaning = clean_node(wxr, None, p_value) 

486 

487 

488def extract_level2_node_contents( 

489 wxr: WiktextractContext, word_entry: WordEntry, level_node: LevelNode 

490) -> None: 

491 for t_node in level_node.find_content(NodeKind.TEMPLATE): 

492 if t_node.template_name in ["заголовок", "з"]: 492 ↛ 491line 492 didn't jump to line 491 because the condition on line 492 was always true

493 # https://ru.wiktionary.org/wiki/Шаблон:з 

494 stressed_form = clean_node( 

495 wxr, None, t_node.template_parameters.get("ударение", "") 

496 ) 

497 if "(" in stressed_form: 

498 stressed_form = stressed_form[ 

499 : stressed_form.index("(") 

500 ].strip() 

501 if stressed_form not in ["", wxr.wtp.title]: 

502 word_entry.forms.append( 

503 Form(form=stressed_form, tags=["stressed"]) 

504 )