Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions tools/steam-sniper/extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,23 @@

const API_BASE = "http://72.56.37.150";
const DEFAULT_USER = "lesha";
const REQUEST_TIMEOUT_MS = 8000;

// fetch wrapper with hard timeout — raw fetch never aborts, leading to silent
// drops (item appears not added, but actual server state unknown).
async function fetchWithTimeout(url, opts, timeoutMs) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
return await fetch(url, { ...opts, signal: ctrl.signal });
} finally {
clearTimeout(timer);
}
}

async function addToList(itemName, listType, sourceUrl) {
try {
const res = await fetch(`${API_BASE}/api/lists`, {
const res = await fetchWithTimeout(`${API_BASE}/api/lists`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand All @@ -15,21 +28,24 @@ async function addToList(itemName, listType, sourceUrl) {
list_type: listType,
source_url: sourceUrl || null,
}),
});
}, REQUEST_TIMEOUT_MS);
const data = await res.json().catch(() => ({}));
// /api/lists returns 201 on new, 200 if already exists — both are fine
if ((res.status === 200 || res.status === 201) && data.ok !== false) {
return { ok: true };
return { ok: true, item_name: data.item_name, resolved_via: data.resolved_via };
}
return { ok: false, error: data.error || `HTTP ${res.status}` };
} catch (err) {
if (err.name === "AbortError") {
return { ok: false, error: `Сервер не ответил за ${REQUEST_TIMEOUT_MS / 1000}с — проверь дашборд, мог уйти в БД на левый скин` };
}
return { ok: false, error: err.message || "Network error" };
}
}

async function setTargets(itemName, listType, targetBelow, targetAbove) {
try {
const res = await fetch(`${API_BASE}/api/lists/target`, {
const res = await fetchWithTimeout(`${API_BASE}/api/lists/target`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand All @@ -39,13 +55,16 @@ async function setTargets(itemName, listType, targetBelow, targetAbove) {
target_below_rub: targetBelow,
target_above_rub: targetAbove,
}),
});
}, REQUEST_TIMEOUT_MS);
const data = await res.json().catch(() => ({}));
if (res.ok && data.ok !== false) {
return { ok: true };
}
return { ok: false, error: data.error || `HTTP ${res.status}` };
} catch (err) {
if (err.name === "AbortError") {
return { ok: false, error: `Таргет не сохранён за ${REQUEST_TIMEOUT_MS / 1000}с — проверь дашборд` };
}
return { ok: false, error: err.message || "Network error" };
}
}
Expand Down
12 changes: 9 additions & 3 deletions tools/steam-sniper/extension/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,13 @@
return;
}

// Server returns canonical name — use it for targets so we PATCH the same row
const canonicalName = addResp.item_name || itemName;
const nameMismatch = canonicalName !== itemName;

if (withTargets && (below !== null || above !== null)) {
const targetResp = await setTargetsRequest(
itemName,
canonicalName,
listType,
below,
above
Expand All @@ -270,12 +274,14 @@
const parts = [];
if (below !== null) parts.push(`🔴 ${below} ₽`);
if (above !== null) parts.push(`🟢 ${above} ₽`);
const suffix = nameMismatch ? ` → ${canonicalName}` : "";
showToast(
`Добавлено в ${listLabel.toLowerCase()} (${parts.join(", ")})`,
`Добавлено в ${listLabel.toLowerCase()} (${parts.join(", ")})${suffix}`,
false
);
} else {
showToast(`Добавлено в ${listLabel.toLowerCase()}`, false);
const suffix = nameMismatch ? ` → ${canonicalName}` : "";
showToast(`Добавлено в ${listLabel.toLowerCase()}${suffix}`, false);
}
closeTargetForm();
}
Expand Down
4 changes: 2 additions & 2 deletions tools/steam-sniper/extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "Steam Sniper",
"version": "1.4",
"description": "Add items from lis-skins.com to Steam Sniper with target prices (🔴 below / 🟢 above) and Telegram alerts. v1.4: robust URL wear parser + source_url to backend for fallback.",
"version": "1.5",
"description": "Add items from lis-skins.com to Steam Sniper with target prices (🔴 below / 🟢 above) and Telegram alerts. v1.5: 8s timeout + URL slug as authoritative resolver (fixes Tiger Tooth → Scorched skin mix-up).",
"permissions": [],
"host_permissions": [
"http://72.56.37.150/*"
Expand Down
174 changes: 168 additions & 6 deletions tools/steam-sniper/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
# --- Module-level state ---

_prices: dict[str, dict] = {} # {name_lower: {name, price, url, count}}
_slug_to_name: dict[str, str] = {} # {url_slug: canonical_name} for source_url-based resolution
_category_counts: dict[str, int] = {} # {category: count} for sidebar
_image_cache: dict[str, str] = {} # {name_lower: image_url} from ByMykel API
_item_meta: dict[str, dict[str, str]] = {} # {name_lower: {rarity_name, rarity_label, rarity_color}}
Expand Down Expand Up @@ -637,6 +638,9 @@ async def _collect_once(send_list_alerts: bool = True) -> None:
logger.error("Failed to fetch lis-skins: %s", e)
return

# Rebuild slug→name index used by /api/lists for source_url-based resolution
_rebuild_slug_index()

# Rebuild category counts for catalog sidebar
counts: dict[str, int] = {}
for item in _prices.values():
Expand Down Expand Up @@ -1083,6 +1087,102 @@ def _wear_from_source_url(source_url: str | None) -> str | None:
return None


def _slugify_name(name: str) -> str:
"""Generate lis-skins-style URL slug from canonical item name.

Drops decorative chars (★, ™), lowercases, and replaces all non-alphanumeric
runs with single dashes. Mirrors lis-skins' URL convention so we can match
a source_url back to the exact catalog name (skin + wear).

Examples:
'★ Karambit | Tiger Tooth (Field-Tested)' → 'karambit-tiger-tooth-field-tested'
'StatTrak™ AK-47 | Redline (Field-Tested)' → 'stattrak-ak-47-redline-field-tested'
'Glock-18 | Water Elemental (Minimal Wear)' → 'glock-18-water-elemental-minimal-wear'
"""
s = name.replace("★", "").replace("™", "")
s = s.lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")


def _slug_from_source_url(source_url: str | None) -> str | None:
"""Extract last path segment (item slug) from a lis-skins source URL.

Returns the slug in its on-the-wire (percent-encoded) lowercase form —
`_rebuild_slug_index` registers BOTH encoded and decoded variants so the
lookup matches whether the caller sent `%E2%98%85-karambit-...` or the
decoded `★-karambit-...`.
"""
if not source_url:
return None
try:
from urllib.parse import urlparse

path = urlparse(source_url).path.rstrip("/").lower()
except Exception:
return None
if not path:
return None
return path.rsplit("/", 1)[-1] or None


def _rebuild_slug_index() -> None:
"""Rebuild {url_slug: canonical_name} index from current _prices.

Indexes three slug forms per item to bullet-proof against URL-encoding
variants the caller might send:
1. Generated slug from canonical name (★/™ stripped, ASCII).
2. Lis-skins-provided URL slug (percent-encoded form, e.g. %e2%98%85-...).
3. Decoded form of the URL slug (with literal ★, etc.) — for callers
whose `location.href` was already URL-decoded by the browser.
Lis-skins-provided forms (2, 3) override generated form (1) on collision —
they're authoritative.
"""
global _slug_to_name
from urllib.parse import unquote

new_index: dict[str, str] = {}
for item in _prices.values():
name = item.get("name", "")
if not name:
continue
# Form 1: generated slug — always available, ASCII-only
gen_slug = _slugify_name(name)
if gen_slug:
new_index.setdefault(gen_slug, name)
# Forms 2, 3: lis-skins-provided URL slug (encoded + decoded variants)
url = item.get("url", "")
if url:
url_slug = _slug_from_source_url(url)
if url_slug:
new_index[url_slug] = name
decoded = unquote(url_slug)
if decoded and decoded != url_slug:
new_index[decoded] = name
_slug_to_name = new_index


def _resolve_from_source_url(source_url: str | None) -> str | None:
"""Resolve a lis-skins URL to canonical catalog name via slug match.

Tries both the on-the-wire (percent-encoded) and URL-decoded slug forms
so we match regardless of whether `location.href` was decoded by the
browser before being sent.
"""
slug = _slug_from_source_url(source_url)
if not slug:
return None
hit = _slug_to_name.get(slug)
if hit:
return hit
from urllib.parse import unquote

decoded = unquote(slug)
if decoded and decoded != slug:
return _slug_to_name.get(decoded)
return None


def _resolve_item_name(name: str) -> str:
"""Normalize list item names to canonical lis-skins English names.

Expand Down Expand Up @@ -1120,6 +1220,16 @@ def _resolve_item_name(name: str) -> str:
continue

if any("\u0400" <= c <= "\u04ff" for c in candidate):
# Log every Steam-API fallback so we can monitor whether this code
# path is still hit after the URL slug resolver took over for /api/lists.
# Per Codex feedback: keep behavior, but make it observable so we can
# later decide whether to refuse instead of silent first-match.
import time as _time
_t0 = _time.perf_counter()
logger.warning(
"[resolve] Steam-API fallback START: candidate=%r requested_wear=%r",
candidate, requested_wear,
)
steam_results: list[dict] = []
for query in _steam_search_queries_for_ru_name(candidate):
steam_results = _steam_search(query)
Expand All @@ -1135,20 +1245,38 @@ def _resolve_item_name(name: str) -> str:
and _localized_name_matches(candidate, steam_item.get("name_ru", ""))
and _wear_matches_requested(lis_item["name"], requested_wear)
):
logger.warning(
"[resolve] Steam-API HIT (localized): candidate=%r -> hash=%r -> %r (%.2fs)",
candidate, steam_item["hash_name"], lis_item["name"], _time.perf_counter() - _t0,
)
return lis_item["name"]
break

for steam_item in steam_results:
lis_item = _prices.get(steam_item["hash_name"].lower())
if lis_item and _wear_matches_requested(lis_item["name"], requested_wear):
logger.warning(
"[resolve] Steam-API HIT (first-match, NO localized validation): "
"candidate=%r -> hash=%r -> %r (%.2fs) -- POSSIBLE WRONG SKIN",
candidate, steam_item["hash_name"], lis_item["name"], _time.perf_counter() - _t0,
)
return lis_item["name"]

en_query = _translate_ru_to_en(candidate)
if en_query:
matched = _match_catalog_name(en_query, requested_wear=requested_wear)
if matched:
logger.warning(
"[resolve] Steam-API HIT (RU->EN translate): candidate=%r -> en=%r -> %r (%.2fs)",
candidate, en_query, matched, _time.perf_counter() - _t0,
)
return matched

logger.warning(
"[resolve] Steam-API MISS: candidate=%r results=%d (%.2fs) -- returning raw",
candidate, len(steam_results), _time.perf_counter() - _t0,
)

return raw


Expand Down Expand Up @@ -1637,30 +1765,59 @@ def get_alerts(limit: int = Query(default=20, le=100)) -> dict:
def add_list_item_endpoint(body: ListItemRequest) -> JSONResponse | dict:
"""Add item to user's personal list (LIST-02).

Wear resolution priority (early-fallback):
1. If body.item_name has no wear AND source_url provides one → augment raw name first.
This prevents _resolve_item_name() (Steam-search / RU-translate path) from picking
a random wear before source_url could help.
Resolution priority:
0. source_url slug match against catalog (authoritative — URL is single source
of truth on lis-skins; DOM title can mislead when page has multiple skins).
1. If item_name has no wear AND source_url provides one → augment raw name.
2. Resolve via catalog match (with wear-aware logic, ambiguity-safe).
3. If still ambiguous → 400 wear_required.
"""
if body.list_type not in ("favorite", "wishlist"):
return JSONResponse({"error": "list_type must be 'favorite' or 'wishlist'"}, status_code=400)

logger.info(
"[/api/lists] received: user=%r list_type=%r item_name=%r source_url=%r",
body.user, body.list_type, body.item_name, body.source_url,
)

# Step 0: source_url slug match — the most reliable resolver.
# Lis-skins URL slug uniquely identifies skin + wear; DOM h1 may be wrong
# on pages with related-item carousels. Use this first when available.
url_resolved = _resolve_from_source_url(body.source_url)
if url_resolved:
# Surface DOM-vs-URL mismatch so we can spot extension's getItemName()
# picking carousel/related-item h1 instead of the page's main item.
if body.item_name and url_resolved.lower() != body.item_name.strip().lower():
logger.warning(
"[/api/lists] DOM/URL mismatch: dom=%r url=%r resolved=%r",
body.item_name, body.source_url, url_resolved,
)
else:
logger.info(
"[/api/lists] resolved via source_url slug: %r (item_name=%r)",
url_resolved, body.item_name,
)
db.add_list_item(user_id=body.user, item_name=url_resolved, list_type=body.list_type)
return {"ok": True, "item_name": url_resolved, "resolved_via": "source_url"}

# Step 1: early augment from source_url if name lacks wear
raw_name = body.item_name
if _wear_code_from_query(raw_name) is None:
url_wear = _wear_from_source_url(body.source_url)
if url_wear:
raw_name = f"{raw_name.strip()} ({url_wear})"
logger.info("Augmented item_name from source_url: %r", raw_name)
logger.info("[/api/lists] augmented item_name from source_url wear: %r", raw_name)

# Step 2: catalog resolve (ambiguity-safe — refuses to silently pick wear)
resolved_name = _resolve_item_name(raw_name)
requested_wear = _wear_code_from_query(resolved_name)

# Step 3: still ambiguous → reject
if requested_wear is None and _has_multiple_wear_variants(resolved_name):
logger.warning(
"[/api/lists] rejecting ambiguous wear: item_name=%r resolved=%r source_url=%r",
body.item_name, resolved_name, body.source_url,
)
return JSONResponse(
{
"error": "wear_required",
Expand All @@ -1673,8 +1830,13 @@ def add_list_item_endpoint(body: ListItemRequest) -> JSONResponse | dict:
},
status_code=400,
)
via = "catalog" if _prices.get(resolved_name.lower()) else "fallback_raw"
logger.info(
"[/api/lists] resolved via %s: item_name=%r → %r",
via, body.item_name, resolved_name,
)
db.add_list_item(user_id=body.user, item_name=resolved_name, list_type=body.list_type)
return {"ok": True, "item_name": resolved_name}
return {"ok": True, "item_name": resolved_name, "resolved_via": via}


@app.delete("/api/lists")
Expand Down
Loading