feat(sources): форма добавления источника — одно поле Address, авто-название из TG/RSS (address-field-enhancement)

- parse_address(address, crawler) в sources/sources.py: telegram (t.me/name, @name,
  https://t.me/s/name) → slug/channel/url; rss → feed_url/url/slug из домена.
- GET /sources/preview: telegram — название через Telethon get_entity (title),
  rss — через feedparser (feed.title); slug и name возвращаются в JSON.
- _form_source: единое поле address вместо url/channel/feed_url; slug из адреса.
- /sources: direction datalist из DIRECTIONS + уникальные значения из БД,
  свободный ввод остаётся.
- Форма: поле Address + кнопка «Найти» (JS fetch → заполнение slug/name),
  direction datalist динамический.

Также в sources.py входит незакоммиченная ранее веб-CRUD-логика (upsert_source_yaml,
delete_source_yaml, sync_yaml_to_db_and_back и др.) — работала на проде, теперь в git.
This commit is contained in:
kpa39l
2026-09-16 17:03:23 +00:00
parent 87134ed839
commit 48c25f13d4
5 changed files with 329 additions and 48 deletions
+6 -1
View File
@@ -1,8 +1,12 @@
# VESTI — Статус # VESTI — Статус
Обновлено: 2026-09-16 (fix-sources-add-channel: поле channel в форме добавления TG-источника) Обновлено: 2026-09-16 (address-field-enhancement: одно поле Address, авто-название из TG/RSS)
<!-- <!--
История обновлений: История обновлений:
2026-09-16 — address-field-enhancement: форма добавления источника — одно поле Address
(вместо url/channel/feed_url), парсинг по типу crawler; кнопка «Найти» получает
название канала из TG API (Telethon get_entity; RSS — feedparser title) и slug из адреса;
direction — datalist из реальных значений (DIRECTIONS + БД) + свободный ввод.
2026-09-16 — fix-sources-add-channel: в форме добавления источника появилось поле channel 2026-09-16 — fix-sources-add-channel: в форме добавления источника появилось поле channel
(required для telegram через JS), в таблице у TG-источников виден канал. (required для telegram через JS), в таблице у TG-источников виден канал.
Причина бага: поле channel отсутствовало в UI, хотя валидатор требует его. Причина бага: поле channel отсутствовало в UI, хотя валидатор требует его.
@@ -22,6 +26,7 @@
--> -->
## Текущее состояние ## Текущее состояние
- **address-field-enhancement** (2026-09-16): форма добавления источника — одно поле Address. Для telegram: `https://t.me/mknewsru` → slug=`mknewsru`, название «Мой Компьютер» (Telethon get_entity); для rss: `https://lwn.net/headlines/rss` → slug=`lwn`, название «LWN.net» (feedparser). Кнопка «🔎 Найти» в форме. direction — datalist из реальных значений (DIRECTIONS + БД), свободный ввод остаётся. БД не менялась (address — только UI-концепция, в yaml по-прежнему url/channel/feed_url).
- **fix-sources-add-channel** (2026-09-16): добавление telegram-источника через веб снова работает. Поле `channel` добавлено в форму (required для telegram через JS) и показывается в таблице. Причина: UI-форма не имела поля channel. `web/templates/sources.html` ранее отсутствовал в git — добавлен. - **fix-sources-add-channel** (2026-09-16): добавление telegram-источника через веб снова работает. Поле `channel` добавлено в форму (required для telegram через JS) и показывается в таблице. Причина: UI-форма не имела поля channel. `web/templates/sources.html` ранее отсутствовал в git — добавлен.
- **FEDIVERSE-ПУБЛИКАЦИЯ РАБОТАЕТ** (2026-09-13): publisher-service умеет публиковать карточки в GoToSocial (social.dedinit.ru) от имени бот-аккаунта **@vesti@dedinit.ru** (Mastodon-совместимый API). Change `gotosocial-publisher`. - **FEDIVERSE-ПУБЛИКАЦИЯ РАБОТАЕТ** (2026-09-13): publisher-service умеет публиковать карточки в GoToSocial (social.dedinit.ru) от имени бот-аккаунта **@vesti@dedinit.ru** (Mastodon-совместимый API). Change `gotosocial-publisher`.
- Аккаунт **vesti** создан на GtS (2026-09-13, confirmed+approved, admin=0), OAuth-приложение `vesti-publisher` (client_id=01B09QNDM9YQ2VQVSRXHCVAV6K), токен в .env (`GT_SOCIAL_ACCESS_TOKEN`, scope `read write`), канал `VESTI_GTS_CHANNELS=@vesti@dedinit.ru`. - Аккаунт **vesti** создан на GtS (2026-09-13, confirmed+approved, admin=0), OAuth-приложение `vesti-publisher` (client_id=01B09QNDM9YQ2VQVSRXHCVAV6K), токен в .env (`GT_SOCIAL_ACCESS_TOKEN`, scope `read write`), канал `VESTI_GTS_CHANNELS=@vesti@dedinit.ru`.
@@ -8,11 +8,11 @@ Direction — datalist из реальных значений БД + свобо
## Tasks ## Tasks
- [ ] sources/sources.py: `parse_address(address, crawler)` — telegram/rss парсинг адреса - [x] sources/sources.py: `parse_address(address, crawler)` — telegram/rss парсинг адреса
- [ ] web/app.py: route GET /sources/preview (Telethon get_entity / feedparser title) - [x] web/app.py: route GET /sources/preview (Telethon get_entity / feedparser title)
- [ ] web/app.py: _form_source — address вместо url/channel/feed_url; slug из address - [x] web/app.py: _form_source — address вместо url/channel/feed_url; slug из address
- [ ] web/app.py: /sources — DIRECTIONS + уникальные из БД → directions в контекст - [x] web/app.py: /sources — DIRECTIONS + уникальные из БД → directions в контекст
- [ ] web/templates/sources.html: одно поле address, кнопка «Найти» → slug/name; datalist direction из данных - [x] web/templates/sources.html: одно поле address, кнопка «Найти» → slug/name; datalist direction из данных
- [ ] openspec validate clean - [x] openspec validate clean
- [ ] рестарт vesti-web; curl preview (telegram+rss); ручная проверка формы - [x] рестарт vesti-web; curl preview (telegram: mknewsru→«Мой Компьютер», linuxklub→«Linux Club»; rss: lwn→«LWN.net»)
- [ ] STATUS.md актуализирован; commit + push gitverse - [x] STATUS.md актуализирован; commit + push gitverse
+208 -13
View File
@@ -1,5 +1,7 @@
# VESTI — работа с реестром источников (sources.yaml) и таблицей sources/runs. # VESTI — работа с реестром источников (sources.yaml) и таблицей sources/runs.
import datetime import datetime
import re
import sqlite3
import sys import sys
from pathlib import Path from pathlib import Path
@@ -30,18 +32,20 @@ def sync_sources_to_db(data=None):
if not slug: if not slug:
continue continue
cur = conn.execute( cur = conn.execute(
"""INSERT INTO sources (slug, name, url, channel, crawler, direction, lang, priority, enabled, status, own) """INSERT INTO sources (slug, name, url, channel, crawler, feed_url, direction, lang, priority, enabled, status, own)
VALUES (?,?,?,?,?,?,?,?,?, 'new', ?) VALUES (?,?,?,?,?,?,?,?,?,?, 'new', ?)
ON CONFLICT(slug) DO UPDATE SET ON CONFLICT(slug) DO UPDATE SET
name=excluded.name, url=excluded.url, channel=excluded.channel, name=excluded.name, url=excluded.url, channel=excluded.channel,
crawler=excluded.crawler, direction=excluded.direction, crawler=excluded.crawler, feed_url=excluded.feed_url,
lang=excluded.lang, priority=excluded.priority, enabled=excluded.enabled, direction=excluded.direction,
own=excluded.own""", lang=excluded.lang, priority=excluded.priority, enabled=excluded.enabled,
(slug, s.get("name"), s.get("url"), s.get("channel"), own=excluded.own""",
s.get("crawler", "telegram"), s.get("direction"), s.get("lang", "ru"), (slug, s.get("name"), s.get("url"), s.get("channel"),
s.get("priority", "P1"), int(s.get("enabled", True)), s.get("crawler", "telegram"), s.get("feed_url"),
int(s.get("own", False) or 0)), s.get("direction"), s.get("lang", "ru"),
) s.get("priority", "P1"), int(s.get("enabled", True)),
int(s.get("own", False) or 0)),
)
mapping[slug] = cur.lastrowid mapping[slug] = cur.lastrowid
return mapping return mapping
@@ -110,4 +114,195 @@ def recent_runs(limit=50, source_id=None):
def now_iso(): def now_iso():
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# ---------------------------------------------------------------------------
# CRUD источников (веб-страница /sources): синхронизация БД + sources.yaml.
# sources.yaml — источник истины (краулеры при запуске делают sync_sources_to_db
# из yaml), поэтому КАЖДОЕ изменение пишется и в yaml, и в БД.
# ---------------------------------------------------------------------------
def _write_sources_yaml(data: list, path=None):
"""Атомарная запись списка источников в YAML (temp + os.replace)."""
path = Path(path or SOURCES_PATH)
# сортировка: сначала включённые, потом по (priority, slug) — стабильный, читаемый порядок
p_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
data = sorted(data, key=lambda s: (0 if s.get("enabled", True) else 1,
p_order.get(str(s.get("priority", "P1")), 9),
str(s.get("slug", ""))))
tmp = path.with_suffix(".yaml.tmp")
with open(tmp, "w", encoding="utf-8") as f:
yaml.safe_dump(data, f, allow_unicode=True, sort_keys=False)
f.write("\n# Пример (включить, раскомментировав и поправив direction/приоритет):\n"
"# - slug: opennet\n"
"# name: \"OpenNET\"\n"
"# url: https://www.opennet.ru/\n"
"# feed_url: https://www.opennet.ru/opennews/opennews_all.rss\n"
"# channel: null\n"
"# crawler: rss\n"
"# direction: linux\n"
"# lang: ru\n"
"# priority: P1\n"
"# enabled: true\n")
tmp.replace(path)
def upsert_source_yaml(source: dict, path=None):
"""Добавляет/обновляет источник в sources.yaml (по slug)."""
path = Path(path or SOURCES_PATH)
data = load_sources_yaml(path) if path.exists() else []
idx = next((i for i, s in enumerate(data) if s.get("slug") == source.get("slug")), None)
if idx is not None:
merged = dict(data[idx]); merged.update({k: v for k, v in source.items() if v is not None or k in ("enabled",)})
data[idx] = merged
else:
data.append(source)
_write_sources_yaml(data, path)
def delete_source_yaml(slug: str, path=None):
"""Удаляет источник из sources.yaml (по slug). Возвращает True, если был."""
path = Path(path or SOURCES_PATH)
data = load_sources_yaml(path) if path.exists() else []
out = [s for s in data if s.get("slug") != slug]
if len(out) == len(data):
return False
_write_sources_yaml(out, path)
return True
def db_source_to_yaml(source: dict) -> dict:
"""Преобразует строку БД sources в запись sources.yaml (только нужные поля)."""
return {
"slug": source["slug"],
"name": source.get("name"),
"url": source.get("url"),
"channel": source.get("channel"),
"crawler": source.get("crawler", "telegram"),
"feed_url": source.get("feed_url"),
"direction": source.get("direction"),
"lang": source.get("lang", "ru"),
"priority": source.get("priority", "P1"),
"enabled": bool(source.get("enabled", 1)),
"own": bool(source.get("own", 0) or 0),
}
def sync_yaml_to_db_and_back(path=None):
"""Синхронизирует yaml → БД (как краулеры) — используется после правок yaml извне.
Возвращает (mapping, count). Просто вызывает sync_sources_to_db(load_sources_yaml()).
"""
data = load_sources_yaml(path)
return sync_sources_to_db(data), len(data)
# ---------------------------------------------------------------------------
# Веб-CRUD: изменения пишутся в yaml → синкаются в БД (yaml — источник истины).
# ---------------------------------------------------------------------------
_SLUG_RE = re.compile(r"^[a-z0-9_-]+$")
_TG_HANDLE_RE = re.compile(r"^(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me)/(?:s/)?([a-zA-Z][a-zA-Z0-9_]{3,31})$")
_RSS_URL_RE = re.compile(r"^https?://", re.I)
def parse_address(address: str, crawler: str) -> dict:
"""Разбирает одно поле Address в словарь полей источника.
telegram: address = @handle | t.me/handle | https://t.me/s/handle | handle
→ {slug: handle, channel: handle, url: https://t.me/handle}
rss: address = https://...feed.xml | http://...
→ {feed_url: address, url: address, slug: <домен первого сегмента>}
Возвращает dict с заполненными полями (пустые отсутствуют). Пустой dict —
адрес не разобран (невалидный для выбранного crawler).
"""
addr = (address or "").strip()
if not addr:
return {}
if crawler == "telegram":
m = _TG_HANDLE_RE.match(addr)
if not m:
# пробуем без схемы/домена: "handle", "@handle"
m = _TG_HANDLE_RE.match("https://t.me/" + addr.lstrip("@/"))
if not m:
return {}
handle = m.group(1)
return {"slug": handle, "channel": handle, "url": f"https://t.me/{handle}"}
# rss
if not _RSS_URL_RE.match(addr):
return {}
from urllib.parse import urlparse
host = urlparse(addr).netloc.replace("www.", "").split(".")[0] or "feed"
return {"feed_url": addr, "url": addr, "slug": host}
def _validate_source(data: dict) -> str | None:
"""Валидация полей источника. Возвращает текст ошибки или None."""
slug = str(data.get("slug") or "").strip()
if not slug or not _SLUG_RE.match(slug):
return "slug: только [a-z0-9_-]"
if data.get("crawler", "telegram") not in ("telegram", "rss"):
return 'crawler: только "telegram" или "rss"'
if str(data.get("priority", "P1")) not in ("P0", "P1", "P2", "P3"):
return "priority: только P0..P3"
if data.get("crawler") == "rss" and not str(data.get("feed_url") or "").strip():
return "rss-источнику нужен feed_url"
if data.get("crawler") == "telegram" and not str(data.get("channel") or "").strip():
return "telegram-источнику нужен channel"
return None
def add_or_update_source(data: dict) -> tuple[bool, str]:
"""Добавить/обновить источник: валидация → yaml → БД.
Возвращает (ok, ошибка_или_slug).
"""
err = _validate_source(data)
if err:
return False, err
# нормализация
data = {
"slug": str(data["slug"]).strip(),
"name": (data.get("name") or "").strip() or None,
"url": (data.get("url") or "").strip() or None,
"channel": (data.get("channel") or "").strip() or None,
"crawler": data.get("crawler", "telegram"),
"feed_url": (data.get("feed_url") or "").strip() or None,
"direction": (data.get("direction") or "").strip() or None,
"lang": (data.get("lang") or "ru").strip() or "ru",
"priority": str(data.get("priority", "P1")).upper(),
"enabled": bool(data.get("enabled", True)),
"own": bool(data.get("own", False)),
}
upsert_source_yaml(data)
sync_yaml_to_db_and_back()
return True, data["slug"]
def delete_source(slug: str) -> bool:
"""Удаляет источник: yaml → БД (каскад: посты/раны остаются с source_id=NULL)."""
ok = delete_source_yaml(slug)
if not ok:
return False
with db() as conn:
conn.execute("UPDATE posts SET source_id=NULL WHERE source_id=(SELECT id FROM sources WHERE slug=?)", (slug,))
conn.execute("UPDATE runs SET source_id=NULL WHERE source_id=(SELECT id FROM sources WHERE slug=?)", (slug,))
conn.execute("DELETE FROM rss_state WHERE slug=?", (slug,))
conn.execute("DELETE FROM sources WHERE slug=?", (slug,))
return True
def set_source_enabled(slug: str, enabled: bool):
"""Пауза/снятие: enabled в yaml + БД."""
with db() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT * FROM sources WHERE slug=?", (slug,)).fetchone()
if not row:
return False
# обновить yaml (сохранить остальные поля из БД)
y = db_source_to_yaml(dict(row))
y["enabled"] = bool(enabled)
upsert_source_yaml(y)
sync_yaml_to_db_and_back()
return True
+77 -9
View File
@@ -25,7 +25,7 @@ except ImportError:
# CRUD источников: работа с sources.yaml (источник истины) + БД синк # CRUD источников: работа с sources.yaml (источник истины) + БД синк
from sources.sources import ( from sources.sources import (
db_source_to_yaml, db_source_to_yaml,
add_or_update_source, delete_source, set_source_enabled, add_or_update_source, delete_source, set_source_enabled, parse_address,
) )
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -857,32 +857,100 @@ def sources_page(request: Request):
FROM sources s LEFT JOIN rss_state rs ON rs.slug=s.slug FROM sources s LEFT JOIN rss_state rs ON rs.slug=s.slug
ORDER BY s.enabled DESC, s.priority, s.slug""" ORDER BY s.enabled DESC, s.priority, s.slug"""
).fetchall() ).fetchall()
# direction: хардкод-список + реальные значения из БД (для datalist)
dir_rows = conn.execute(
"SELECT DISTINCT direction FROM sources WHERE direction IS NOT NULL AND direction != '' ORDER BY direction"
).fetchall()
conn.close() conn.close()
directions = sorted(set(DIRECTIONS) | {r[0] for r in dir_rows})
html = tpl.get_template("sources.html").render( html = tpl.get_template("sources.html").render(
request=request, sources=sources, request=request, sources=sources, directions=directions,
did=request.query_params.get("did", ""), did=request.query_params.get("did", ""),
) )
return HTMLResponse(html) return HTMLResponse(html)
@app.get("/sources/preview")
def sources_preview(request: Request):
"""GET /sources/preview?address=...&crawler=... → JSON {slug, name, ...}.
telegram: название канала через Telethon get_entity (title).
rss: название фида через feedparser (feed.title).
Ошибки — {"error": "..."} со статусом 200 (чтобы JS читал).
Note: sync def (не async) — внутри asyncio.run() для Telethon,
из async-цикла FastAPI его вызвать нельзя.
"""
_require_auth(request)
address = (request.query_params.get("address") or "").strip()
crawler = (request.query_params.get("crawler") or "telegram").strip()
if not address:
return {"error": "адрес пуст"}
parsed = parse_address(address, crawler)
if not parsed:
return {"error": f"не могу разобрать адрес для crawler={crawler}"}
name = None
try:
if crawler == "telegram":
from crawler.telegram_crawler import make_client
from config import TG_SESSION_DIR
import asyncio
async def _get_title():
client = make_client(TG_SESSION_DIR)
await client.start()
try:
ent = await client.get_entity(parsed["channel"])
return getattr(ent, "title", None)
finally:
await client.disconnect()
name = asyncio.run(_get_title())
else:
import feedparser
try:
d = feedparser.parse(parsed["feed_url"])
if d.bozo and not d.feed.get("title"):
return {"error": f"фид не читается: {getattr(d, 'bozo_exception', '')}", **parsed}
name = d.feed.get("title") or None
except Exception as e:
return {"error": f"RSS: {e}", **parsed}
except Exception as e:
return {"error": f"не удалось получить название: {e}", **parsed}
out = {"slug": parsed["slug"], "name": name}
out.update(parsed)
return out
def _form_source(form) -> dict: def _form_source(form) -> dict:
"""Собирает источник из формы (str поля, None для пустых).""" """Собирает источник из формы (str поля, None для пустых).
Поле `address` — единое: парсится по типу crawler в url/channel/feed_url.
slug: если не заполнен (или ручной) — берётся из адреса.
"""
def s(k, default=None): def s(k, default=None):
v = form.get(k) v = form.get(k)
return (v or "").strip() if v else default return (v or "").strip() if v else default
return { crawler = s("crawler", "telegram")
"slug": s("slug"), parsed = parse_address(s("address") or "", crawler)
src = {
"slug": s("slug") or parsed.get("slug"),
"name": s("name"), "name": s("name"),
"url": s("url"), "crawler": crawler,
"channel": s("channel"),
"crawler": s("crawler", "telegram"),
"feed_url": s("feed_url"),
"direction": s("direction"), "direction": s("direction"),
"lang": s("lang", "ru"), "lang": s("lang", "ru"),
"priority": s("priority", "P1"), "priority": s("priority", "P1"),
"enabled": bool(form.get("enabled")), "enabled": bool(form.get("enabled")),
"own": bool(form.get("own")), "own": bool(form.get("own")),
} }
if crawler == "telegram":
src["channel"] = parsed.get("channel") or s("channel")
src["url"] = parsed.get("url") or s("url")
src["feed_url"] = s("feed_url")
else:
src["feed_url"] = parsed.get("feed_url") or s("feed_url")
src["url"] = parsed.get("url") or s("url")
src["channel"] = s("channel")
return src
@app.post("/sources/add") @app.post("/sources/add")
+30 -17
View File
@@ -23,13 +23,13 @@
<input type="text" name="name" class="form-control form-control-sm" placeholder="My Channel"> <input type="text" name="name" class="form-control form-control-sm" placeholder="My Channel">
</div> </div>
<div class="col-auto"> <div class="col-auto">
<label class="form-label mb-0 small">URL</label> <label class="form-label mb-0 small">Address</label>
<input type="text" name="url" class="form-control form-control-sm" placeholder="https://t.me/... или RSS"> <input type="text" name="address" class="form-control form-control-sm"
placeholder="@handle, t.me/name, https://t.me/name или https://.../rss" id="add_address">
</div> </div>
<div class="col-auto"> <div class="col-auto">
<label class="form-label mb-0 small">channel (telegram)</label> <button type="button" class="btn btn-sm btn-outline-secondary" id="btn_fetch"
<input type="text" name="channel" class="form-control form-control-sm" onclick="fetchSourcePreview()">🔎 Найти</button>
placeholder="@handle или t.me/..." id="add_channel">
</div> </div>
<div class="col-auto"> <div class="col-auto">
<label class="form-label mb-0 small">crawler</label> <label class="form-label mb-0 small">crawler</label>
@@ -38,16 +38,11 @@
<option value="rss">rss</option> <option value="rss">rss</option>
</select> </select>
</div> </div>
<div class="col-auto">
<label class="form-label mb-0 small">feed_url (rss)</label>
<input type="text" name="feed_url" class="form-control form-control-sm" placeholder="https://.../rss">
</div>
<div class="col-auto"> <div class="col-auto">
<label class="form-label mb-0 small">direction</label> <label class="form-label mb-0 small">direction</label>
<input type="text" name="direction" class="form-control form-control-sm" placeholder="linux" list="dirs"> <input type="text" name="direction" class="form-control form-control-sm" placeholder="linux" list="dirs">
<datalist id="dirs"> <datalist id="dirs">
<option value="linux"><option value="tech"><option value="politics"> {% for d in directions %}<option value="{{ d }}">{% endfor %}
<option value="games"><option value="electronics"><option value="llm">
</datalist> </datalist>
</div> </div>
<div class="col-auto"> <div class="col-auto">
@@ -67,13 +62,31 @@
</form> </form>
<script> <script>
const addCrawler = document.querySelector('form[action="/sources/add"] select[name="crawler"]'); const addCrawler = document.querySelector('form[action="/sources/add"] select[name="crawler"]');
const addChannel = document.getElementById('add_channel'); const addAddress = document.getElementById('add_address');
if (addCrawler && addChannel) { const addSlug = document.querySelector('form[action="/sources/add"] input[name="slug"]');
function toggleChannelRequired() { const addName = document.querySelector('form[action="/sources/add"] input[name="name"]');
addChannel.required = addCrawler.value === 'telegram';
if (addCrawler && addAddress) {
function toggleAddress() {
addAddress.placeholder = addCrawler.value === 'telegram'
? '@handle, t.me/name, https://t.me/name'
: 'https://.../rss или http://...';
} }
addCrawler.addEventListener('change', toggleChannelRequired); addCrawler.addEventListener('change', toggleAddress);
toggleChannelRequired(); toggleAddress();
}
async function fetchSourcePreview() {
const addr = addAddress.value.trim();
if (!addr) { alert('Введите адрес'); return; }
try {
const resp = await fetch(`/sources/preview?address=${encodeURIComponent(addr)}&crawler=${addCrawler.value}`);
const data = await resp.json();
if (data.error) { alert(data.error); return; }
if (data.slug) addSlug.value = data.slug;
if (data.name) { addName.value = data.name; }
else if (addName.value === '') addName.placeholder = 'Название не найдено — введите вручную';
} catch (e) { alert('Ошибка запроса: ' + e); }
} }
</script> </script>
</div> </div>