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.
- xmlriver_mcp/__init__.py +3 -0
- xmlriver_mcp/batch.py +190 -0
- xmlriver_mcp/cli.py +117 -0
- xmlriver_mcp/client.py +278 -0
- xmlriver_mcp/config.py +46 -0
- xmlriver_mcp/csv_io.py +103 -0
- xmlriver_mcp/errors.py +29 -0
- xmlriver_mcp/models.py +100 -0
- xmlriver_mcp/parsers.py +112 -0
- xmlriver_mcp/regions.py +70 -0
- xmlriver_mcp/server.py +112 -0
- xmlriver_mcp/services.py +100 -0
- xmlriver_mcp/storage.py +172 -0
- xmlriver_mcp_toolkit-0.1.0.dist-info/METADATA +232 -0
- xmlriver_mcp_toolkit-0.1.0.dist-info/RECORD +18 -0
- xmlriver_mcp_toolkit-0.1.0.dist-info/WHEEL +4 -0
- xmlriver_mcp_toolkit-0.1.0.dist-info/entry_points.txt +4 -0
- xmlriver_mcp_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
xmlriver_mcp/__init__.py
ADDED
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
|