xmlriver-mcp-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Independent XMLRiver client and MCP server."""
2
+
3
+ __version__ = "0.1.0"
xmlriver_mcp/batch.py ADDED
@@ -0,0 +1,190 @@
1
+ import asyncio
2
+ import time
3
+
4
+ from .client import XmlRiverClient, resolve_xmlriver_creds
5
+ from .config import Config
6
+ from .errors import XmlRiverError
7
+ from .models import DEFAULT_REGION, SerpArgs
8
+ from .parsers import find_matches, normalize_domain
9
+ from .services import collect_serp, utcnow
10
+ from .storage import Storage, make_run_id, normalize_phrase
11
+
12
+
13
+ def build_tasks(cfg: Config, phrases: list[str], regional: bool = False) -> list[dict]:
14
+ tasks = []
15
+ seen = set()
16
+ for phrase in phrases:
17
+ norm = normalize_phrase(phrase)
18
+ if not norm or norm in seen:
19
+ continue
20
+ seen.add(norm)
21
+ for engine in cfg.engines:
22
+ regions: list[tuple[int, list[str]]] = [(engine.region, [])]
23
+ if regional:
24
+ regions = [
25
+ (getattr(region, engine.name), region.expected_hosts)
26
+ for region in cfg.regions
27
+ if getattr(region, engine.name) is not None
28
+ ]
29
+ for region, hosts in regions:
30
+ args = SerpArgs.model_validate(
31
+ dict(
32
+ query=phrase,
33
+ engine=engine.name,
34
+ region=region,
35
+ device=engine.device,
36
+ pages=cfg.depth // 10,
37
+ target_domain=cfg.target_domain,
38
+ include_subdomains=cfg.include_subdomains,
39
+ )
40
+ )
41
+ tasks.append({**args.model_dump(), "expected_hosts": hosts})
42
+ if not tasks:
43
+ raise ValueError("No collection tasks")
44
+ return tasks
45
+
46
+
47
+ async def run_batch(
48
+ cfg: Config,
49
+ tasks: list[dict],
50
+ store: Storage,
51
+ *,
52
+ dry_run: bool = False,
53
+ refresh: bool = False,
54
+ client=None,
55
+ ) -> dict:
56
+ started = time.monotonic()
57
+ records = []
58
+ pending = []
59
+ for task in tasks:
60
+ task["region"] = (
61
+ task.get("region") if task.get("region") is not None else DEFAULT_REGION[task["engine"]]
62
+ )
63
+ cached = None if refresh else store.cached(task, cfg.cache_ttl_hours)
64
+ if cached:
65
+ cached["expected_hosts"] = task.get("expected_hosts", [])
66
+ cached["expected_host_matches"] = [
67
+ doc
68
+ for doc in cached["results"]
69
+ if normalize_domain(doc["url"])
70
+ in {normalize_domain(h) for h in cached["expected_hosts"]}
71
+ ]
72
+ records.append(cached)
73
+ else:
74
+ pending.append(task)
75
+ estimate = (
76
+ None
77
+ if any(task["engine"] not in cfg.costs_per_1000 for task in pending)
78
+ else sum(task["pages"] * cfg.costs_per_1000[task["engine"]] / 1000 for task in pending)
79
+ )
80
+ if dry_run:
81
+ return dict(
82
+ status="dry_run",
83
+ tasks=len(tasks),
84
+ cached_hits=len(records),
85
+ successful_pages=0,
86
+ http_attempts=0,
87
+ estimated_cost_rub=estimate,
88
+ pages_without_retries=sum(task["pages"] for task in pending),
89
+ max_attempts=cfg.max_attempts,
90
+ network_requests=False,
91
+ )
92
+ manifest = dict(
93
+ run_id=make_run_id(),
94
+ created_at=utcnow(),
95
+ status="running",
96
+ http_attempts=0,
97
+ successful_pages=0,
98
+ estimated_cost_rub=None,
99
+ elapsed_seconds=0,
100
+ tasks=len(tasks),
101
+ cached_hits=len(records),
102
+ )
103
+ store.save_run(manifest, records)
104
+ own_client = client is None
105
+ client = client or XmlRiverClient(
106
+ *resolve_xmlriver_creds(),
107
+ rate_max=cfg.rate_max,
108
+ rate_window=cfg.rate_window,
109
+ max_attempts=cfg.max_attempts,
110
+ )
111
+ attempt_start = client.http_attempts
112
+ if own_client:
113
+ await client.__aenter__()
114
+ status = "ok"
115
+ try:
116
+ for task in pending:
117
+ progress = {
118
+ **task,
119
+ "phrase": task["query"],
120
+ "phrase_norm": normalize_phrase(task["query"]),
121
+ }
122
+ attempts_before = client.http_attempts
123
+ try:
124
+ async with asyncio.timeout(cfg.deadline):
125
+ await collect_serp(
126
+ client,
127
+ SerpArgs.model_validate(
128
+ {k: v for k, v in task.items() if k != "expected_hosts"}
129
+ ),
130
+ progress=progress,
131
+ )
132
+ except (XmlRiverError, TimeoutError) as exc:
133
+ error = exc if isinstance(exc, XmlRiverError) else XmlRiverError(kind="deadline")
134
+ progress.update(status="error", complete=False, error=error.payload())
135
+ status = "partial"
136
+ if error.kind in {"auth", "balance", "ip", "budget"}:
137
+ status = "aborted"
138
+ except asyncio.CancelledError:
139
+ status = "aborted"
140
+ progress.update(status="incomplete", complete=False)
141
+ raise
142
+ finally:
143
+ progress["http_attempts"] = client.http_attempts - attempts_before
144
+ matches = find_matches(
145
+ progress.get("results", []), task["target_domain"], task["include_subdomains"]
146
+ )
147
+ progress.update(
148
+ matches=matches,
149
+ position=matches[0]["position"] if matches else None,
150
+ url=matches[0]["url"] if matches else None,
151
+ )
152
+ if progress.get("complete"):
153
+ progress["status"] = "found" if matches else "not_found"
154
+ progress["expected_host_matches"] = [
155
+ doc
156
+ for doc in progress.get("results", [])
157
+ if normalize_domain(doc["url"])
158
+ in {normalize_domain(h) for h in task.get("expected_hosts", [])}
159
+ ]
160
+ price = cfg.costs_per_1000.get(task["engine"])
161
+ progress["estimated_cost_rub"] = (
162
+ None if price is None else progress["http_attempts"] * price / 1000
163
+ )
164
+ records.append(progress)
165
+ store.save_cache(progress)
166
+ manifest.update(
167
+ http_attempts=client.http_attempts - attempt_start,
168
+ elapsed_seconds=time.monotonic() - started,
169
+ )
170
+ store.save_run(manifest, records)
171
+ if status == "aborted":
172
+ break
173
+ finally:
174
+ if own_client:
175
+ await client.__aexit__()
176
+ fresh = [r for r in records if r.get("source") == "api"]
177
+ costs = [r.get("estimated_cost_rub") for r in fresh]
178
+ manifest.update(
179
+ status=status,
180
+ http_attempts=client.http_attempts - attempt_start,
181
+ successful_pages=sum(r.get("successful_pages", 0) for r in fresh),
182
+ estimated_cost_rub=None
183
+ if any(v is None for v in costs)
184
+ else sum(v for v in costs if v is not None),
185
+ elapsed_seconds=time.monotonic() - started,
186
+ completed_tasks=sum(bool(r.get("complete")) for r in records),
187
+ )
188
+ store.save_run(manifest, records)
189
+ store.append_history(records, manifest)
190
+ return manifest
xmlriver_mcp/cli.py ADDED
@@ -0,0 +1,117 @@
1
+ import argparse
2
+ import asyncio
3
+ import json
4
+ import logging
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+ from pydantic import ValidationError
10
+
11
+ from .batch import build_tasks, run_batch
12
+ from .client import XmlRiverClient, resolve_xmlriver_creds
13
+ from .config import load_config
14
+ from .csv_io import read_phrases, update_csv
15
+ from .errors import XmlRiverError
16
+ from .models import INPUTS
17
+ from .regions import validate_regions
18
+ from .services import execute
19
+ from .storage import Storage
20
+
21
+
22
+ def parser():
23
+ ap = argparse.ArgumentParser(
24
+ description="Independent XMLRiver toolkit. API requests may cost money."
25
+ )
26
+ commands = ap.add_subparsers(dest="command", required=True)
27
+ commands.add_parser("balance")
28
+ serp = commands.add_parser("serp")
29
+ serp.add_argument("query")
30
+ serp.add_argument("--engine", choices=["yandex", "google"], default="yandex")
31
+ serp.add_argument("--region", type=int)
32
+ serp.add_argument("--device", choices=["desktop", "mobile", "tablet"], default="mobile")
33
+ serp.add_argument("--pages", type=int, default=1)
34
+ serp.add_argument("--target-domain")
35
+ serp.add_argument("--include-subdomains", action=argparse.BooleanOptionalAction, default=True)
36
+ ws = commands.add_parser("wordstat").add_subparsers(dest="action", required=True)
37
+ for name in ("top", "history"):
38
+ command = ws.add_parser(name)
39
+ command.add_argument("query")
40
+ command.add_argument("--regions", type=int, default=213)
41
+ command.add_argument("--device", choices=["desktop", "phone", "tablet"])
42
+ if name == "top":
43
+ command.add_argument("--limit", type=int, default=50)
44
+ command.add_argument("--min-frequency", type=int, default=0)
45
+ command.add_argument("--include-associations", action="store_true")
46
+ command.add_argument("--drop-self", action=argparse.BooleanOptionalAction, default=True)
47
+ else:
48
+ command.add_argument("--period", choices=["day", "week", "month"], default="month")
49
+ command.add_argument("--start")
50
+ command.add_argument("--end")
51
+ for group, actions in (("positions", ("run", "update-csv")), ("regions", ("run", "validate"))):
52
+ sub = commands.add_parser(group).add_subparsers(dest="action", required=True)
53
+ for action in actions:
54
+ command = sub.add_parser(action)
55
+ command.add_argument("--config", required=True, type=Path)
56
+ command.add_argument("--workspace", type=Path)
57
+ command.add_argument("--dry-run", action="store_true")
58
+ command.add_argument("--refresh", action="store_true")
59
+ command.add_argument("--csv", type=Path)
60
+ if action == "update-csv":
61
+ command.add_argument("--run", required=True)
62
+ return ap
63
+
64
+
65
+ async def run(args):
66
+ if args.command in ("positions", "regions"):
67
+ cfg = load_config(args.config)
68
+ store = Storage(args.workspace or args.config.resolve().parent / "xmlriver-data")
69
+ path = args.csv or (Path(cfg.csv) if cfg.csv else None)
70
+ if args.action == "update-csv":
71
+ if path is None:
72
+ raise ValueError("CSV path required")
73
+ return update_csv(store, path, args.run, cfg.column, dry_run=args.dry_run)
74
+ if args.action == "validate":
75
+ async with XmlRiverClient(
76
+ *resolve_xmlriver_creds(), max_attempts=cfg.max_attempts
77
+ ) as client:
78
+ return await validate_regions(
79
+ cfg, store, client, refresh=args.refresh, dry_run=args.dry_run
80
+ )
81
+ phrases = read_phrases(path, cfg.column) if path else cfg.phrases
82
+ tasks = build_tasks(cfg, phrases, regional=args.command == "regions")
83
+ return await run_batch(cfg, tasks, store, dry_run=args.dry_run, refresh=args.refresh)
84
+ name = {
85
+ "balance": "get_balance",
86
+ "serp": "serp",
87
+ "wordstat": "wordstat_" + getattr(args, "action", ""),
88
+ }[args.command]
89
+ data = {k: v for k, v in vars(args).items() if k not in {"command", "action"}}
90
+ INPUTS[name].model_validate(data)
91
+ async with XmlRiverClient(*resolve_xmlriver_creds()) as client:
92
+ return await execute(client, name, data)
93
+
94
+
95
+ def main():
96
+ logging.getLogger("httpx").disabled = True
97
+ logging.getLogger("httpcore").disabled = True
98
+ args = parser().parse_args()
99
+ try:
100
+ result = asyncio.run(run(args))
101
+ code = 4 if result.get("status") in {"partial", "aborted"} else 0
102
+ except (ValueError, ValidationError, yaml.YAMLError):
103
+ result, code = XmlRiverError(kind="params").payload(), 2
104
+ except XmlRiverError as exc:
105
+ result, code = exc.payload(), 3
106
+ except KeyboardInterrupt:
107
+ result, code = {"status": "aborted"}, 4
108
+ except Exception:
109
+ result, code = XmlRiverError().payload(), 3
110
+ if code:
111
+ print("XMLRiver operation did not complete successfully.", file=sys.stderr)
112
+ print(json.dumps(result, ensure_ascii=False))
113
+ raise SystemExit(code)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
xmlriver_mcp/client.py ADDED
@@ -0,0 +1,278 @@
1
+ """Reusable async client. Credentials come only from arguments or environment."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import math
7
+ import os
8
+ import random
9
+ import re
10
+ import time
11
+ from collections import deque
12
+ from datetime import datetime, timezone
13
+ from email.utils import parsedate_to_datetime
14
+
15
+ import httpx
16
+
17
+ from .errors import XmlRiverError
18
+ from .models import Engine, HistoryArgs, SerpArgs, WordstatArgs
19
+ from .parsers import extract_error, parse_serp, parse_wordstat
20
+
21
+ GOOGLE_URL = "https://xmlriver.com/search/xml"
22
+ YANDEX_URL = "https://xmlriver.com/search_yandex/xml"
23
+ WORDSTAT_URL = "https://xmlriver.com/wordstat/new/json"
24
+ BALANCE_URL = "https://xmlriver.com/api/get_balance/"
25
+ COST_URL = "https://xmlriver.com/api/get_cost/{system}/"
26
+ DEFAULT_TIMEOUT = 45.0
27
+ CREDS_HELP = "Set XMLRIVER_USER and XMLRIVER_KEY in the environment."
28
+ TEMP_CODES = {"110", "111", "115", "201", "202", "500"}
29
+ WS_TEMP_CODES = {"101", "110", "115", "500"}
30
+ NO_RESULTS_CODES = {"15"}
31
+
32
+
33
+ class AuthenticationFilter(logging.Filter):
34
+ """Redact authenticated URLs even when an embedding application enables HTTP logs."""
35
+
36
+ def filter(self, record):
37
+ message = record.getMessage()
38
+ record.msg = re.sub(r"""(?i)([?&](?:key|user)=)[^&\s'"\\]+""", r"\1[redacted]", message)
39
+ record.args = ()
40
+ return True
41
+
42
+
43
+ _auth_filter = AuthenticationFilter()
44
+ for _logger in (
45
+ "httpx",
46
+ "httpcore.connection",
47
+ "httpcore.http11",
48
+ "httpcore.http2",
49
+ "httpcore.proxy",
50
+ "httpcore.socks",
51
+ ):
52
+ logging.getLogger(_logger).addFilter(_auth_filter)
53
+
54
+
55
+ def classify_error_code(code: str) -> str:
56
+ return {
57
+ "31": "auth",
58
+ "42": "auth",
59
+ "45": "ip",
60
+ "200": "balance",
61
+ "2": "params",
62
+ "121": "params",
63
+ "400": "params",
64
+ }.get(code, "temp" if code in TEMP_CODES else "other")
65
+
66
+
67
+ def resolve_xmlriver_creds() -> tuple[str, str]:
68
+ return os.environ.get("XMLRIVER_USER", "").strip(), os.environ.get("XMLRIVER_KEY", "").strip()
69
+
70
+
71
+ class RateLimiter:
72
+ def __init__(self, max_requests: int = 5, window_seconds: float = 1.5):
73
+ if max_requests < 1 or window_seconds <= 0:
74
+ raise ValueError("Invalid rate limit")
75
+ self.maximum, self.window = max_requests, window_seconds
76
+ self.times: deque[float] = deque()
77
+ self.lock = asyncio.Lock()
78
+
79
+ async def acquire(self):
80
+ async with self.lock:
81
+ while True:
82
+ now = time.monotonic()
83
+ while self.times and self.times[0] <= now - self.window:
84
+ self.times.popleft()
85
+ if len(self.times) < self.maximum:
86
+ self.times.append(now)
87
+ return
88
+ await asyncio.sleep(max(0, self.window - (now - self.times[0])))
89
+
90
+
91
+ def retry_after(value: str | None) -> float | None:
92
+ if not value:
93
+ return None
94
+ try:
95
+ seconds = float(value)
96
+ except ValueError:
97
+ try:
98
+ date = parsedate_to_datetime(value)
99
+ seconds = (date - datetime.now(timezone.utc)).total_seconds()
100
+ except (ValueError, TypeError, OverflowError):
101
+ return None
102
+ return max(0, seconds) if math.isfinite(seconds) else None
103
+
104
+
105
+ class XmlRiverClient:
106
+ def __init__(
107
+ self,
108
+ user: str = "",
109
+ key: str = "",
110
+ *,
111
+ timeout: float = DEFAULT_TIMEOUT,
112
+ rate_max: int = 5,
113
+ rate_window: float = 1.5,
114
+ max_concurrent: int = 5,
115
+ max_attempts: int | None = None,
116
+ transport=None,
117
+ ):
118
+ if timeout <= 0 or max_concurrent < 1 or (max_attempts is not None and max_attempts < 0):
119
+ raise ValueError("Invalid client limits")
120
+ self._user, self._key = user, key
121
+ self._timeout, self._transport = timeout, transport
122
+ self._limiter = RateLimiter(rate_max, rate_window)
123
+ self._semaphore = asyncio.Semaphore(max_concurrent)
124
+ self._http: httpx.AsyncClient | None = None
125
+ self.max_attempts = max_attempts
126
+ self.http_attempts = 0
127
+
128
+ async def __aenter__(self):
129
+ self._http = httpx.AsyncClient(timeout=self._timeout, transport=self._transport)
130
+ return self
131
+
132
+ async def __aexit__(self, *exc):
133
+ if self._http:
134
+ await self._http.aclose()
135
+ self._http = None
136
+
137
+ async def _request(self, url: str, params: dict, parser, *, max_retries: int = 3):
138
+ if not self._user or not self._key:
139
+ raise XmlRiverError(kind="auth")
140
+ if self._http is None:
141
+ raise RuntimeError("Use XmlRiverClient inside async with")
142
+ if not 0 <= max_retries <= 10:
143
+ raise ValueError("max_retries must be 0..10")
144
+ for attempt in range(max_retries + 1):
145
+ delay = None
146
+ async with self._semaphore:
147
+ await self._limiter.acquire()
148
+ if self.max_attempts is not None and self.http_attempts >= self.max_attempts:
149
+ raise XmlRiverError(kind="budget")
150
+ self.http_attempts += 1
151
+ try:
152
+ response = await self._http.get(
153
+ url, params={**params, "user": self._user, "key": self._key}
154
+ )
155
+ status = response.status_code
156
+ if status == 429 or 500 <= status < 600:
157
+ delay = retry_after(response.headers.get("Retry-After"))
158
+ raise XmlRiverError(code=str(status), kind="temp")
159
+ if not 200 <= status < 300:
160
+ raise XmlRiverError(
161
+ code=str(status), kind="auth" if status in (401, 403) else "params"
162
+ )
163
+ return parser(response)
164
+ except httpx.HTTPError:
165
+ error = XmlRiverError(kind="temp")
166
+ except XmlRiverError as exc:
167
+ error = exc
168
+ if error.kind != "temp" or attempt == max_retries:
169
+ raise error from None
170
+ await asyncio.sleep(
171
+ delay if delay is not None else min(2**attempt, 15) + random.uniform(0, 0.25)
172
+ )
173
+ raise AssertionError("unreachable")
174
+
175
+ async def fetch_serp(
176
+ self,
177
+ query: str,
178
+ *,
179
+ engine: Engine,
180
+ region: int,
181
+ device: str = "mobile",
182
+ page_index: int = 0,
183
+ max_retries: int = 3,
184
+ ) -> str:
185
+ args = SerpArgs.model_validate(
186
+ dict(query=query, engine=engine, region=region, device=device)
187
+ )
188
+ if type(page_index) is not int or not 0 <= page_index < 10:
189
+ raise ValueError("page_index must be 0..9")
190
+ params = {
191
+ "query": args.query,
192
+ "device": device,
193
+ "loc" if engine == "google" else "lr": region,
194
+ "page": page_index + (1 if engine == "google" else 0),
195
+ }
196
+
197
+ def parse(response):
198
+ text = response.text
199
+ error = extract_error(text)
200
+ if error and error[0] != "15":
201
+ raise XmlRiverError(code=error[0], kind=classify_error_code(error[0]))
202
+ parse_serp(text)
203
+ return text
204
+
205
+ return await self._request(
206
+ GOOGLE_URL if engine == "google" else YANDEX_URL, params, parse, max_retries=max_retries
207
+ )
208
+
209
+ async def fetch_wordstat(
210
+ self,
211
+ query: str,
212
+ *,
213
+ regions: int | None = 213,
214
+ device: str | None = None,
215
+ pagetype: str = "words",
216
+ period: str | None = None,
217
+ start: str | None = None,
218
+ end: str | None = None,
219
+ max_retries: int = 3,
220
+ ) -> dict:
221
+ args = dict(query=query, regions=regions, device=device)
222
+ validated: WordstatArgs
223
+ if pagetype == "history":
224
+ args.update(period=period or "month", start=start, end=end)
225
+ validated = HistoryArgs.model_validate(args)
226
+ elif pagetype == "words":
227
+ validated = WordstatArgs.model_validate(args)
228
+ else:
229
+ raise ValueError("Unknown pagetype")
230
+
231
+ def parse(response):
232
+ try:
233
+ # JSON is decoded as UTF-8. Do not replace undecodable bytes silently.
234
+ data = json.loads(response.content.decode("utf-8-sig"))
235
+ except (ValueError, UnicodeError):
236
+ raise XmlRiverError(kind="format") from None
237
+ if not isinstance(data, dict):
238
+ raise XmlRiverError(kind="format")
239
+ if "error" in data:
240
+ code = str(data.get("code", ""))
241
+ raise XmlRiverError(
242
+ code=code, kind="temp" if code in WS_TEMP_CODES else classify_error_code(code)
243
+ )
244
+ if pagetype == "words":
245
+ parse_wordstat(data, include_associations=True)
246
+ return data
247
+
248
+ return await self._request(
249
+ WORDSTAT_URL,
250
+ {**validated.model_dump(exclude_none=True), "pagetype": pagetype},
251
+ parse,
252
+ max_retries=max_retries,
253
+ )
254
+
255
+ @staticmethod
256
+ def _number(response) -> float:
257
+ try:
258
+ value = float(response.text.strip().replace(",", "."))
259
+ if not math.isfinite(value) or value < 0:
260
+ raise ValueError
261
+ return value
262
+ except ValueError:
263
+ raise XmlRiverError(kind="format") from None
264
+
265
+ async def get_balance(self, *, max_retries: int = 3) -> float:
266
+ return await self._request(BALANCE_URL, {}, self._number, max_retries=max_retries)
267
+
268
+ async def get_cost(self, engine: Engine, *, max_retries: int = 3) -> float | None:
269
+ if engine not in ("google", "yandex"):
270
+ raise ValueError("Unknown engine")
271
+ try:
272
+ return await self._request(
273
+ COST_URL.format(system=engine), {}, self._number, max_retries=max_retries
274
+ )
275
+ except XmlRiverError as exc:
276
+ if exc.kind in {"format", "temp"}:
277
+ return None
278
+ raise
xmlriver_mcp/config.py ADDED
@@ -0,0 +1,46 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ import yaml
5
+ from pydantic import Field
6
+
7
+ from .models import Arguments, Engine
8
+
9
+
10
+ class EngineConfig(Arguments):
11
+ name: Engine
12
+ region: Annotated[int, Field(ge=0)]
13
+ device: str = "mobile"
14
+
15
+
16
+ class RegionConfig(Arguments):
17
+ name: str
18
+ yandex: Annotated[int, Field(ge=0)] | None = None
19
+ google: Annotated[int, Field(ge=0)] | None = None
20
+ hints: list[str] = []
21
+ expected_hosts: list[str] = []
22
+
23
+
24
+ class Config(Arguments):
25
+ target_domain: str
26
+ include_subdomains: bool = True
27
+ depth: Annotated[int, Field(ge=10, le=100, multiple_of=10)] = 50
28
+ engines: list[EngineConfig] = [EngineConfig(name="yandex", region=213)]
29
+ regions: list[RegionConfig] = []
30
+ phrases: list[str] = []
31
+ csv: str | None = None
32
+ column: str = "phrase"
33
+ cache_ttl_hours: Annotated[float, Field(ge=0)] = 24
34
+ max_attempts: Annotated[int, Field(ge=1)] = 1000
35
+ deadline: Annotated[float, Field(gt=0)] = 180
36
+ rate_max: Annotated[int, Field(ge=1)] = 5
37
+ rate_window: Annotated[float, Field(gt=0)] = 1.5
38
+ costs_per_1000: dict[Engine, Annotated[float, Field(ge=0)]] = {}
39
+
40
+
41
+ def load_config(path: Path) -> Config:
42
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
43
+ cfg = Config.model_validate(data)
44
+ if cfg.csv:
45
+ cfg.csv = str((path.resolve().parent / cfg.csv).resolve())
46
+ return cfg