| from asyncio import gather, sleep |
|
|
| from fastapi import FastAPI, HTTPException |
| from fastapi.responses import JSONResponse, HTMLResponse |
| from httpx import AsyncClient, Timeout |
| from parsel import Selector |
| from py_fake_useragent import UserAgent |
| from pydantic import BaseModel |
| from uvicorn import run as uvicorn_run |
|
|
| ua = UserAgent(use_disk_cache=True) |
|
|
| async def rifme_net(word: str, client: AsyncClient) -> list: |
| url = f'https://rifme.net/r/{word}' |
| words = [] |
| try: |
| response = await client.get(url, headers=ua.get_headers(url)) |
| response.raise_for_status() |
| selector = Selector(text=response.text) |
| words = selector.css('ul#tochnye li.riLi::attr(data-w)').getall() |
| if not words: |
| words = [''.join(word.split()) for word in selector.css('ul#tochnye li.riLi').xpath('string()').getall()] |
| except Exception as exc: |
| print(exc) |
| return words |
|
|
|
|
| async def ryfma_com(word: str, client: AsyncClient) -> dict: |
| url = 'https://ryfma.com/graphql' |
| words = [] |
| query = { |
| 'operationName': 'searchRhyme', |
| 'variables': {'word': word}, |
| 'query': ''' |
| query searchRhyme($word: String!) { |
| searchRhyme(word: $word) { |
| sortedRhymes |
| } |
| } |
| ''' |
| } |
| headers = ua.get_headers(url) |
| headers = headers.update({ |
| 'Content-Type': 'application/json', |
| }) |
| try: |
| response = await client.post(url, headers=headers, json=query) |
| response.raise_for_status() |
| words = response.json().get('data', {}).get('searchRhyme', {}).get('sortedRhymes', []) |
| except Exception as exc: |
| print(exc) |
| return words |
|
|
|
|
| async def triumph_ru(word: str, client: AsyncClient) -> list[str]: |
| url = 'https://www.triumph.ru/html/serv/rifma-online.html' |
| words = [] |
| headers = ua.get_headers(url) |
| data = { |
| 'word': word, |
| 'dzen': 'search', |
| 'part': 'любая', |
| 'slog': 'неважно', |
| 'who': '100', |
| 'start': '' |
| } |
| try: |
| response = await client.post(url, headers=headers, data=data) |
| response.raise_for_status() |
| selector = Selector(text=response.text) |
| words = [''.join(word.split()) for word in selector.css('#content table td').xpath('string()').getall()] |
| except Exception as exc: |
| print(exc) |
| return words |
|
|
|
|
| async def get_rhyme_for_word(word: str, exact_rhyme: bool, client: AsyncClient) -> list[str]: |
| async def fetch_results(): |
| return await gather(rifme_net(word, client), ryfma_com(word, client), triumph_ru(word, client)) |
|
|
| max_attempts = 4 |
| delay = 2 |
|
|
| for attempt in range(max_attempts): |
| results = await fetch_results() |
| seen = set() |
| final_results = [] |
|
|
| if results: |
| for result in results: |
| if result and isinstance(result, (list, tuple)): |
| for _word in result: |
| _word = _word.strip() |
| if _word and len(_word) > 1: |
| if _word not in seen: |
| seen.add(_word) |
| if exact_rhyme: |
| if len(word) >= 2 and len(_word) >= 2 and word[-2:] == _word[-2:]: |
| final_results.append(_word) |
| else: |
| final_results.append(_word) |
|
|
| if final_results: |
| return final_results |
|
|
| if attempt < max_attempts - 1: |
| await sleep(delay) |
|
|
| return [] |
|
|
|
|
| async def get_rhymes(words: list[str], exact_rhyme: bool) -> dict[str, list[str]]: |
| async with AsyncClient(follow_redirects=True, verify=False, timeout=Timeout(connect=15, read=30, write=15, pool=15), http2=True) as client: |
| tasks = [get_rhyme_for_word(word, exact_rhyme, client) for word in words] |
| results = await gather(*tasks) |
| return {word: result for word, result in zip(words, results)} |
|
|
|
|
| app = FastAPI() |
|
|
|
|
| class WordsRequest(BaseModel): |
| words: list[str] |
| exact_rhyme: bool = False |
|
|
|
|
| html_content = """ |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>подбор рифм</title> |
| <style> |
| :root { |
| --bg-color: #121212; |
| --surface-color: #1e1e1e; |
| --input-bg: #2c2c2c; |
| --text-primary: #e0e0e0; |
| --text-secondary: #a0a0a0; |
| --accent-color: #7c4dff; |
| --accent-hover: #651fff; |
| --border-color: #333; |
| --success-color: #00e676; |
| --chip-bg: #333; |
| } |
| |
| body { |
| background-color: var(--bg-color); |
| color: var(--text-primary); |
| font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; |
| margin: 0; |
| padding: 0; |
| display: flex; |
| justify-content: center; |
| min-height: 100vh; |
| } |
| |
| .container { |
| width: 100%; |
| max-width: 900px; |
| padding: 2rem; |
| } |
| |
| h1 { |
| text-align: center; |
| color: var(--accent-color); |
| font-weight: 300; |
| margin-bottom: 2rem; |
| letter-spacing: 1px; |
| } |
| |
| .controls-area { |
| background-color: var(--surface-color); |
| padding: 1.5rem; |
| border-radius: 12px; |
| box-shadow: 0 4px 6px rgba(0,0,0,0.3); |
| margin-bottom: 2rem; |
| } |
| |
| label { |
| display: block; |
| margin-bottom: 0.5rem; |
| color: var(--text-secondary); |
| font-size: 0.9rem; |
| } |
| |
| textarea { |
| width: 100%; |
| height: 100px; |
| background-color: var(--input-bg); |
| border: 1px solid var(--border-color); |
| color: var(--text-primary); |
| border-radius: 8px; |
| padding: 10px; |
| font-size: 1rem; |
| resize: vertical; |
| box-sizing: border-box; |
| outline: none; |
| transition: border-color 0.2s; |
| margin-bottom: 1rem; |
| } |
| |
| textarea:focus { |
| border-color: var(--accent-color); |
| } |
| |
| .options-row { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-top: 10px; |
| } |
| |
| .switch { |
| position: relative; |
| display: inline-block; |
| width: 50px; |
| height: 24px; |
| vertical-align: middle; |
| margin-right: 10px; |
| } |
| |
| .switch input { |
| opacity: 0; |
| width: 0; |
| height: 0; |
| } |
| |
| .slider { |
| position: absolute; |
| cursor: pointer; |
| top: 0; left: 0; right: 0; bottom: 0; |
| background-color: var(--input-bg); |
| transition: .4s; |
| border-radius: 34px; |
| border: 1px solid var(--border-color); |
| } |
| |
| .slider:before { |
| position: absolute; |
| content: ""; |
| height: 16px; |
| width: 16px; |
| left: 4px; |
| bottom: 3px; |
| background-color: white; |
| transition: .4s; |
| border-radius: 50%; |
| } |
| |
| input:checked + .slider { |
| background-color: var(--accent-color); |
| } |
| |
| input:checked + .slider:before { |
| transform: translateX(26px); |
| } |
| |
| .switch-label { |
| color: var(--text-primary); |
| cursor: pointer; |
| user-select: none; |
| } |
| |
| button.search-btn { |
| padding: 12px 30px; |
| background-color: var(--accent-color); |
| color: white; |
| border: none; |
| border-radius: 8px; |
| font-size: 1rem; |
| cursor: pointer; |
| font-weight: 600; |
| transition: background-color 0.2s, transform 0.1s; |
| } |
| |
| button.search-btn:hover { |
| background-color: var(--accent-hover); |
| } |
| |
| button.search-btn:active { |
| transform: scale(0.98); |
| } |
| |
| button:disabled { |
| background-color: var(--input-bg); |
| color: var(--text-secondary); |
| cursor: not-allowed; |
| } |
| |
| .loader { |
| display: none; |
| text-align: center; |
| margin: 1rem 0; |
| } |
| |
| .spinner { |
| border: 4px solid rgba(255, 255, 255, 0.1); |
| width: 24px; |
| height: 24px; |
| border-radius: 50%; |
| border-left-color: var(--accent-color); |
| animation: spin 1s linear infinite; |
| display: inline-block; |
| vertical-align: middle; |
| margin-right: 10px; |
| } |
| |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } |
| |
| .results-area { |
| display: flex; |
| flex-direction: column; |
| gap: 1.5rem; |
| } |
| |
| .word-card { |
| background-color: var(--surface-color); |
| border-radius: 12px; |
| padding: 1.5rem; |
| box-shadow: 0 2px 4px rgba(0,0,0,0.2); |
| border: 1px solid var(--border-color); |
| } |
| |
| .word-title { |
| font-size: 1.2rem; |
| color: var(--success-color); |
| margin-bottom: 1rem; |
| border-bottom: 1px solid var(--border-color); |
| padding-bottom: 0.5rem; |
| text-transform: capitalize; |
| display: flex; |
| justify-content: space-between; |
| } |
| |
| .count-badge { |
| font-size: 0.8rem; |
| color: var(--text-secondary); |
| background: var(--input-bg); |
| padding: 2px 8px; |
| border-radius: 10px; |
| } |
| |
| .rhymes-list { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 0.5rem; |
| } |
| |
| .rhyme-chip { |
| background-color: var(--chip-bg); |
| color: var(--text-primary); |
| padding: 8px 14px; |
| border-radius: 20px; |
| font-size: 0.95rem; |
| border: 1px solid var(--border-color); |
| transition: all 0.2s; |
| cursor: pointer; |
| user-select: none; |
| } |
| |
| .rhyme-chip:hover { |
| background-color: var(--accent-color); |
| border-color: var(--accent-color); |
| transform: translateY(-2px); |
| box-shadow: 0 4px 8px rgba(0,0,0,0.3); |
| } |
| |
| .rhyme-chip:active { |
| transform: translateY(0); |
| } |
| |
| .no-rhymes { |
| color: var(--text-secondary); |
| font-style: italic; |
| } |
| |
| #toast { |
| visibility: hidden; |
| min-width: 250px; |
| margin-left: -125px; |
| background-color: #333; |
| color: #fff; |
| text-align: center; |
| border-radius: 25px; |
| padding: 16px; |
| position: fixed; |
| z-index: 1; |
| left: 50%; |
| bottom: 30px; |
| box-shadow: 0 4px 12px rgba(0,0,0,0.5); |
| border: 1px solid var(--accent-color); |
| opacity: 0; |
| transition: opacity 0.3s, bottom 0.3s; |
| } |
| |
| #toast.show { |
| visibility: visible; |
| opacity: 1; |
| bottom: 50px; |
| } |
| </style> |
| </head> |
| <body> |
| |
| <div class="container"> |
| <h1>подбор рифмы</h1> |
| |
| <div class="controls-area"> |
| <label for="words">введи слова (через запятую или с новой строки):</label> |
| <textarea id="words" placeholder="пример: солнце, радость, кот"></textarea> |
| |
| <div class="options-row"> |
| <div style="display: flex; align-items: center;"> |
| <label class="switch"> |
| <input type="checkbox" id="exactRhyme"> |
| <span class="slider"></span> |
| </label> |
| <span class="switch-label" onclick="document.getElementById('exactRhyme').click()"> |
| более точная рифма (фильтр по окончанию) |
| </span> |
| </div> |
| |
| <button id="searchBtn" class="search-btn" onclick="getRhymes()">найти рифмы</button> |
| </div> |
| </div> |
| |
| <div class="loader" id="loader"> |
| <span class="spinner"></span> ищем совпадения... |
| </div> |
| |
| <div id="results" class="results-area"></div> |
| </div> |
| |
| <div id="toast">слово скопировано</div> |
| |
| <script> |
| async function getRhymes() { |
| const input = document.getElementById('words').value; |
| const exactRhyme = document.getElementById('exactRhyme').checked; |
| const resultsDiv = document.getElementById('results'); |
| const loader = document.getElementById('loader'); |
| const btn = document.getElementById('searchBtn'); |
| |
| const words = input.split(/[\\n,]+/).map(s => s.trim()).filter(s => s.length > 0); |
| |
| if (words.length === 0) { |
| alert("нужно ввести хотя бы слово"); |
| return; |
| } |
| |
| resultsDiv.innerHTML = ''; |
| loader.style.display = 'block'; |
| btn.disabled = true; |
| |
| try { |
| const response = await fetch('/get_rhymes', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| words: words, |
| exact_rhyme: exactRhyme |
| }) |
| }); |
| |
| if (!response.ok) throw new Error('ошибка сервера: ' + response.statusText); |
| |
| const data = await response.json(); |
| displayResults(data); |
| |
| } catch (error) { |
| resultsDiv.innerHTML = `<div style="color: #ff5252; text-align: center;">ошибка: ${error.message}</div>`; |
| } finally { |
| loader.style.display = 'none'; |
| btn.disabled = false; |
| } |
| } |
| |
| function displayResults(data) { |
| const resultsDiv = document.getElementById('results'); |
| |
| for (const [word, rhymes] of Object.entries(data)) { |
| const card = document.createElement('div'); |
| card.className = 'word-card'; |
| |
| const title = document.createElement('div'); |
| title.className = 'word-title'; |
| |
| const titleText = document.createElement('span'); |
| titleText.textContent = word; |
| |
| const badge = document.createElement('span'); |
| badge.className = 'count-badge'; |
| badge.textContent = rhymes ? `${rhymes.length} шт.` : '0 шт.'; |
| |
| title.appendChild(titleText); |
| title.appendChild(badge); |
| |
| const list = document.createElement('div'); |
| list.className = 'rhymes-list'; |
| |
| if (rhymes && rhymes.length > 0) { |
| rhymes.forEach(rhyme => { |
| const chip = document.createElement('span'); |
| chip.className = 'rhyme-chip'; |
| chip.textContent = rhyme; |
| chip.title = "нажми, чтобы скопировать"; |
| chip.onclick = () => { |
| copyToClipboard(rhyme); |
| }; |
| |
| list.appendChild(chip); |
| }); |
| } else { |
| const noRhyme = document.createElement('span'); |
| noRhyme.className = 'no-rhymes'; |
| noRhyme.textContent = 'рифмы не найдены :('; |
| list.appendChild(noRhyme); |
| } |
| |
| card.appendChild(title); |
| card.appendChild(list); |
| resultsDiv.appendChild(card); |
| } |
| } |
| |
| function copyToClipboard(text) { |
| navigator.clipboard.writeText(text).then(() => { |
| showToast(`скопировано: ${text}`); |
| }, (err) => { |
| console.error('ошибка копирования: ', err); |
| }); |
| } |
| |
| function showToast(message) { |
| const toast = document.getElementById("toast"); |
| toast.textContent = message; |
| toast.className = "show"; |
| setTimeout(() => { toast.className = toast.className.replace("show", ""); }, 2500); |
| } |
| </script> |
| |
| </body> |
| </html> |
| """ |
|
|
| @app.get('/', response_class=HTMLResponse) |
| async def root(): |
| return HTMLResponse(content=html_content) |
|
|
|
|
| @app.post('/get_rhymes') |
| async def get_rhymes_endpoint(request: WordsRequest): |
| if not request.words: |
| raise HTTPException(status_code=400, detail='список слов не может быть пустым') |
| if not isinstance(request.words, list): |
| raise HTTPException(status_code=400, detail='запрос должен быть списком слов') |
|
|
| clean_words = [w.strip() for w in request.words if w.strip()] |
|
|
| rhymes = await get_rhymes(clean_words, request.exact_rhyme) |
|
|
| return JSONResponse(content=rhymes) |
|
|
|
|
| if __name__ == '__main__': |
| uvicorn_run(app, host='0.0.0.0', port=7860) |
|
|