feat(tradein/scripts): local Playwright runners для Cian backfill (bypass server-IP anti-bot) #553
4 changed files with 831 additions and 0 deletions
87
tradein-mvp/scripts/local-cian/README.md
Normal file
87
tradein-mvp/scripts/local-cian/README.md
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Local Cian backfill scrapers (Playwright, real browser)
|
||||||
|
|
||||||
|
Cian блокирует server-IP scraping anti-bot. Эти скрипты запускаются с **локальной машины разработчика**
|
||||||
|
через SSH-tunnel к prod tradein-postgres — обходят bot detection, используют real Chrome с persistent profile.
|
||||||
|
|
||||||
|
## Когда использовать
|
||||||
|
|
||||||
|
- Backfill `offer_price_history` для активных Cian listings (history scraper, надёжно)
|
||||||
|
- Best-effort backfill `external_valuations` для домов где Calculator отдаёт оценку без agent-wizard
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install playwright "psycopg[binary]" sqlalchemy
|
||||||
|
python -m playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. SSH tunnel к tradein-postgres (отдельное окно, держать открытым)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$IP = (ssh gendesign 'docker inspect tradein-postgres --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"').Trim()
|
||||||
|
ssh -L "15433:${IP}:5432" gendesign -N
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Env vars (creds из vault `meta/00_credentials.md`)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:TRADEIN_DB_DSN = "postgresql+psycopg://tradein:<PASSWORD>@localhost:15433/tradein"
|
||||||
|
$env:COOKIE_ENCRYPTION_KEY = (ssh gendesign 'grep ^COOKIE_ENCRYPTION_KEY= /opt/gendesign/tradein-mvp/.env.runtime | cut -d= -f2').Trim()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Залить Cian cookies (один раз / по мере истечения)
|
||||||
|
|
||||||
|
1. Login на cian.ru в обычном Chrome.
|
||||||
|
2. DevTools → Application → Cookies → www.cian.ru → скопировать `DMIR_AUTH` (httpOnly) + `_CIAN_GK`.
|
||||||
|
3. Вставить значения в `upload_cookies_local.py` (поля с плейсхолдером `<PASTE_FROM_BROWSER_DEVTOOLS>`).
|
||||||
|
4. Вставить реальный Cian userId в `ACCOUNT_USER_ID` (найти: DevTools → Network → XHR → `/v1/get-my-id/` → `userId`).
|
||||||
|
5. Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python upload_cookies_local.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### History backfill (production-ready, не блокируется)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -u playwright_history.py --batch 20 --delay 2 2>&1 | Tee-Object -FilePath cian_history.log
|
||||||
|
```
|
||||||
|
|
||||||
|
- Темп: ~25 listings/min
|
||||||
|
- Идёт по `id > last_id` ASC pagination
|
||||||
|
- Записывает в `offer_price_history` (UNIQUE listing_id + change_time)
|
||||||
|
- Скорость дрейна 5к листингов: ~3-4 часа
|
||||||
|
|
||||||
|
### Valuation backfill (best-effort, partial)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -u playwright_valuation.py --batch 10 --delay 5 --wait 8 2>&1 | Tee-Object -FilePath cian_valuation.log
|
||||||
|
```
|
||||||
|
|
||||||
|
- Использует persistent profile `./chrome_profile/` для сохранения сессии между runs
|
||||||
|
(переопределяется через `$env:CIAN_PROFILE_DIR`)
|
||||||
|
- На `not auth` в `--headed` mode — ждёт manual login в открытом окне
|
||||||
|
- Skip листинги где Cian не отдаёт оценку (~70% таких — новостройки, нет аналогов)
|
||||||
|
|
||||||
|
### Если auth теряется
|
||||||
|
|
||||||
|
Playwright Chrome имеет другой fingerprint чем твой обычный Chrome → Cian может invalidate сессию. Решения:
|
||||||
|
|
||||||
|
- Запустить с `--headed` → login manually в окне.
|
||||||
|
- Re-upload cookies через `upload_cookies_local.py`.
|
||||||
|
|
||||||
|
## Pre-existing knowledge
|
||||||
|
|
||||||
|
См. vault `decisions/Decision_Cian_History_Valuation_Activation_May24.md` — full 6-PR sequence
|
||||||
|
(#523/#525/#530/#535/#539/#543) + open follow-ups.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Calculator (`external_valuations`) собирается только для домов с готовой оценкой в
|
||||||
|
`_cianConfig['valuation-for-agent-frontend'].initialState.estimation`. Полный wizard-flow не автоматизируется.
|
||||||
|
- `DMIR_AUTH` expires ~30-60 min — нужно периодически перезаливать cookies.
|
||||||
|
- Не покрывает Cian listings вне нашего SERP (старые не-active, нестандартные region/category).
|
||||||
256
tradein-mvp/scripts/local-cian/playwright_history.py
Normal file
256
tradein-mvp/scripts/local-cian/playwright_history.py
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
"""Cian history backfill через real Chrome (Playwright) — обходит anti-bot detection.
|
||||||
|
|
||||||
|
Iterates Cian listings без offer_price_history, открывает в headless Chrome,
|
||||||
|
extract'ит defaultState.offerData.priceChanges через page.evaluate, пишет в БД.
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
pip install playwright "sqlalchemy>=2" "psycopg[binary]"
|
||||||
|
playwright install chromium
|
||||||
|
|
||||||
|
Run:
|
||||||
|
$env:TRADEIN_DB_DSN = "postgresql+psycopg://tradein:PASS@localhost:15433/tradein"
|
||||||
|
python C:\\Users\\user\\cian-backfill\\playwright_history.py --batch 20 --delay 2
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--headed показать браузер (debug)
|
||||||
|
--batch N листингов за батч (default 20)
|
||||||
|
--delay S seconds между listings (default 2)
|
||||||
|
--max N hard cap (default 5500)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
EXTRACT_JS = """
|
||||||
|
() => {
|
||||||
|
if (!window._cianConfig || !window._cianConfig['frontend-offer-card']) {
|
||||||
|
return { error: 'no _cianConfig', captcha: location.href.includes('captcha') };
|
||||||
|
}
|
||||||
|
const arr = window._cianConfig['frontend-offer-card'];
|
||||||
|
const def = arr.find(e => e && e.key === 'defaultState');
|
||||||
|
if (!def || !def.value) {
|
||||||
|
return { error: 'no defaultState' };
|
||||||
|
}
|
||||||
|
const v = def.value;
|
||||||
|
const offerData = v.offerData || {};
|
||||||
|
const offer = offerData.offer || {};
|
||||||
|
// priceChanges живёт в offerData.priceChanges (НЕ в offerData.offer.priceChanges)
|
||||||
|
const priceChanges = offerData.priceChanges || offer.priceChanges || v.priceChanges || [];
|
||||||
|
// bargainTerms.price — текущая цена
|
||||||
|
const price_rub = (offer.bargainTerms && offer.bargainTerms.price) || null;
|
||||||
|
// Также собрать ключи offerData чтобы видеть что доступно
|
||||||
|
return {
|
||||||
|
cian_id: offer.id || null,
|
||||||
|
price_rub,
|
||||||
|
price_changes: priceChanges,
|
||||||
|
_offerData_keys: Object.keys(offerData).slice(0, 25),
|
||||||
|
_offer_keys: Object.keys(offer).slice(0, 25),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_change_time(s):
|
||||||
|
"""Parse 'YYYY-MM-DDTHH:MM:SS' or ISO → timestamptz; return raw string if parse fails."""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
return s # psycopg позволяет передать строку → postgres парсит как timestamptz
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_changes(cur, listing_id: int, price_rub: int | None, changes: list) -> int:
|
||||||
|
"""Insert priceChanges array; return count inserted (ignoring conflicts)."""
|
||||||
|
if not changes:
|
||||||
|
return 0
|
||||||
|
rows = []
|
||||||
|
prev_price = None
|
||||||
|
for c in changes:
|
||||||
|
ct = c.get("changeTime") or c.get("date")
|
||||||
|
# priceData.price — основной формат 2026+; fallback на старые поля
|
||||||
|
pd = c.get("priceData") if isinstance(c.get("priceData"), dict) else None
|
||||||
|
pr = (pd.get("price") if pd else None) or c.get("price") or c.get("priceRub")
|
||||||
|
if not ct or not pr:
|
||||||
|
continue
|
||||||
|
diff_pct = None
|
||||||
|
if prev_price is not None and prev_price > 0:
|
||||||
|
diff_pct = round((pr - prev_price) / prev_price * 100, 2)
|
||||||
|
rows.append((listing_id, ct, pr, diff_pct, "cian"))
|
||||||
|
prev_price = pr
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
cur.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO offer_price_history (listing_id, change_time, price_rub, diff_percent, source)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (listing_id, change_time) DO NOTHING
|
||||||
|
""",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
return cur.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--batch", type=int, default=20)
|
||||||
|
ap.add_argument("--delay", type=float, default=2.0)
|
||||||
|
ap.add_argument("--max", type=int, default=5500)
|
||||||
|
ap.add_argument("--headed", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
dsn = os.environ.get("TRADEIN_DB_DSN")
|
||||||
|
if not dsn:
|
||||||
|
sys.exit("FATAL: TRADEIN_DB_DSN env required")
|
||||||
|
# psycopg native dsn (без +psycopg для sqlalchemy)
|
||||||
|
pg_dsn = dsn.replace("postgresql+psycopg://", "postgresql://")
|
||||||
|
|
||||||
|
interrupted = False
|
||||||
|
|
||||||
|
def on_sigint(s, f):
|
||||||
|
nonlocal interrupted
|
||||||
|
print("\n!! Ctrl+C — finishing current item")
|
||||||
|
interrupted = True
|
||||||
|
signal.signal(signal.SIGINT, on_sigint)
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
nonlocal interrupted
|
||||||
|
cum_ok = cum_fail = cum_changes = cum_with_history = 0
|
||||||
|
start = time.time()
|
||||||
|
last_id = 0 # paginate forward — избегаем loop когда changes=0
|
||||||
|
|
||||||
|
# Optional: load cookies из БД для авторизованной сессии
|
||||||
|
cookies_from_db: list = []
|
||||||
|
try:
|
||||||
|
with psycopg.connect(pg_dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
enc_key = os.environ.get("COOKIE_ENCRYPTION_KEY")
|
||||||
|
if enc_key:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT pgp_sym_decrypt(cookies_encrypted, %s)::text
|
||||||
|
FROM cian_session_cookies
|
||||||
|
WHERE expires_at_estimate > NOW() AND last_invalid_at IS NULL
|
||||||
|
ORDER BY uploaded_at DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(enc_key,),
|
||||||
|
)
|
||||||
|
r = cur.fetchone()
|
||||||
|
if r:
|
||||||
|
jar = json.loads(r[0])
|
||||||
|
for name, val in jar.items():
|
||||||
|
cookies_from_db.append({
|
||||||
|
"name": name, "value": val,
|
||||||
|
"domain": ".cian.ru", "path": "/",
|
||||||
|
"secure": True, "sameSite": "Lax",
|
||||||
|
})
|
||||||
|
print(f"[init] loaded {len(cookies_from_db)} cookies from DB")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[init] cookies load skipped: {exc}")
|
||||||
|
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch(headless=not args.headed)
|
||||||
|
ctx = await browser.new_context(
|
||||||
|
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
viewport={"width": 1366, "height": 900},
|
||||||
|
locale="ru-RU",
|
||||||
|
)
|
||||||
|
if cookies_from_db:
|
||||||
|
await ctx.add_cookies(cookies_from_db)
|
||||||
|
page = await ctx.new_page()
|
||||||
|
page.set_default_navigation_timeout(45_000)
|
||||||
|
page.set_default_timeout(45_000)
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
while not interrupted and processed < args.max:
|
||||||
|
# 1. Fetch batch — paginate id > last_id чтобы не зацикливаться
|
||||||
|
with psycopg.connect(pg_dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT l.id, l.source_url
|
||||||
|
FROM listings l
|
||||||
|
LEFT JOIN offer_price_history oph ON oph.listing_id = l.id
|
||||||
|
WHERE l.source = 'cian'
|
||||||
|
AND l.source_url IS NOT NULL
|
||||||
|
AND oph.listing_id IS NULL
|
||||||
|
AND l.id > %s
|
||||||
|
ORDER BY l.id
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(last_id, args.batch),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
if not rows:
|
||||||
|
print(f"\n[done] drained — no more listings")
|
||||||
|
break
|
||||||
|
print(f"\n[batch] {len(rows)} listings to process (cum: ok={cum_ok} fail={cum_fail} changes={cum_changes})")
|
||||||
|
|
||||||
|
# 2. Process each
|
||||||
|
with psycopg.connect(pg_dsn, autocommit=False) as conn:
|
||||||
|
for (lid, url) in rows:
|
||||||
|
if interrupted:
|
||||||
|
break
|
||||||
|
processed += 1
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
await page.goto(url, wait_until="domcontentloaded")
|
||||||
|
# wait for _cianConfig populated
|
||||||
|
await page.wait_for_function(
|
||||||
|
"() => window._cianConfig && window._cianConfig['frontend-offer-card'] && window._cianConfig['frontend-offer-card'].some(e => e.key === 'defaultState')",
|
||||||
|
timeout=15_000,
|
||||||
|
)
|
||||||
|
data = await page.evaluate(EXTRACT_JS)
|
||||||
|
if data.get("error"):
|
||||||
|
if data.get("captcha"):
|
||||||
|
print(f" [{lid}] CAPTCHA — please solve in browser (use --headed)")
|
||||||
|
interrupted = True
|
||||||
|
break
|
||||||
|
cum_fail += 1
|
||||||
|
print(f" [{lid}] {data['error']} {time.time() - t0:.1f}s")
|
||||||
|
continue
|
||||||
|
changes = data.get("price_changes") or []
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
inserted = upsert_changes(cur, lid, data.get("price_rub"), changes)
|
||||||
|
conn.commit()
|
||||||
|
cum_ok += 1
|
||||||
|
cum_changes += inserted
|
||||||
|
if len(changes) > 0:
|
||||||
|
cum_with_history += 1
|
||||||
|
print(f" [{lid}] OK cian_id={data.get('cian_id')} price={data.get('price_rub')} changes={len(changes)} inserted={inserted} {time.time() - t0:.1f}s")
|
||||||
|
# На первом обращении и затем каждые 50 — dump для diagnostics
|
||||||
|
if processed == 1 or processed % 50 == 0:
|
||||||
|
print(f" [diag] offerData_keys={data.get('_offerData_keys')}")
|
||||||
|
print(f" [diag] offer_keys={data.get('_offer_keys')}")
|
||||||
|
# Dump shape priceChanges первые 3 раза + при первом успехе
|
||||||
|
if (changes and processed <= 3) or (changes and inserted > 0 and cum_changes == inserted):
|
||||||
|
print(f" [diag] price_changes_sample={changes[0]}")
|
||||||
|
last_id = lid # advance pagination cursor even on 0-change result
|
||||||
|
except Exception as exc:
|
||||||
|
conn.rollback()
|
||||||
|
cum_fail += 1
|
||||||
|
print(f" [{lid}] ERR {type(exc).__name__}: {str(exc)[:120]} {time.time() - t0:.1f}s")
|
||||||
|
await asyncio.sleep(args.delay)
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"== Summary ==")
|
||||||
|
print(f" processed_ok={cum_ok} fail={cum_fail}")
|
||||||
|
print(f" listings_with_history={cum_with_history} price_changes_inserted={cum_changes}")
|
||||||
|
print(f" elapsed={time.time() - start:.1f}s finished={datetime.now(timezone.utc).isoformat(timespec='seconds')}")
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
423
tradein-mvp/scripts/local-cian/playwright_valuation.py
Normal file
423
tradein-mvp/scripts/local-cian/playwright_valuation.py
Normal file
|
|
@ -0,0 +1,423 @@
|
||||||
|
"""Cian Valuation backfill через real Chrome (Playwright) с auth cookies.
|
||||||
|
|
||||||
|
Iterates listings без свежей external_valuations записи, открывает Calculator URL
|
||||||
|
с реальным address+area+rooms, ждёт когда state.estimation.sale.data populated,
|
||||||
|
extract'ит sale/rent/chart/houseId, INSERT'ит в external_valuations.
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
pip install playwright "psycopg[binary]"
|
||||||
|
python -m playwright install chromium
|
||||||
|
SSH tunnel to tradein-postgres on localhost:15433
|
||||||
|
Env: TRADEIN_DB_DSN, COOKIE_ENCRYPTION_KEY
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python C:\\Users\\user\\cian-backfill\\playwright_valuation.py --batch 10 --delay 3 --wait 8
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
# JS — wait + extract когда valuation populated (или timeout)
|
||||||
|
EXTRACT_JS = """
|
||||||
|
() => {
|
||||||
|
const arr = window._cianConfig && window._cianConfig['valuation-for-agent-frontend'];
|
||||||
|
if (!arr) return { error: 'no MFE', captcha: location.href.includes('captcha') };
|
||||||
|
const entry = arr.find(e => e && e.key === 'initialState');
|
||||||
|
if (!entry || !entry.value) return { error: 'no initialState' };
|
||||||
|
const v = entry.value;
|
||||||
|
// unwrap state.data контейнеры в чистые объекты (Cian шаблон — { data, isFetching, isError })
|
||||||
|
const unwrap = (o) => (o && typeof o === 'object' && 'data' in o ? o.data : o);
|
||||||
|
return {
|
||||||
|
isAuth: v.user && v.user.isAuthenticated,
|
||||||
|
user_id_cian: v.user && v.user.userId,
|
||||||
|
isFetching: (v.estimation && v.estimation.sale && v.estimation.sale.isFetching) ||
|
||||||
|
(v.estimationChart && v.estimationChart.isFetching),
|
||||||
|
sale: v.estimation && v.estimation.sale && v.estimation.sale.data,
|
||||||
|
rent: v.estimation && v.estimation.rent && v.estimation.rent.data,
|
||||||
|
chart_obj: v.estimationChart && v.estimationChart.data,
|
||||||
|
house: unwrap(v.houseInfo),
|
||||||
|
management_company: unwrap(v.managementCompany),
|
||||||
|
nearby_houses_info: unwrap(v.nearbyHousesInfo),
|
||||||
|
market_trend: unwrap(v.marketTrand), // sic — typo в Cian API
|
||||||
|
offers_history: unwrap(v.offersHistory),
|
||||||
|
precise_filters: v.preciseFilters,
|
||||||
|
geocode: unwrap(v.geocode),
|
||||||
|
services: v.services,
|
||||||
|
house_id: v.filters && v.filters.houseId,
|
||||||
|
filters_address: v.filters && v.filters.address,
|
||||||
|
filters_house_id: v.filters && v.filters.houseId,
|
||||||
|
// FULL initialState — для raw_payload (полный snapshot для будущего парсинга)
|
||||||
|
full_state: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
WAIT_JS = """
|
||||||
|
() => {
|
||||||
|
const arr = window._cianConfig && window._cianConfig['valuation-for-agent-frontend'];
|
||||||
|
if (!arr) return false;
|
||||||
|
const entry = arr.find(e => e && e.key === 'initialState');
|
||||||
|
if (!entry || !entry.value) return false;
|
||||||
|
const v = entry.value;
|
||||||
|
const sale = v.estimation && v.estimation.sale;
|
||||||
|
// Готово когда либо есть data, либо isError, и не isFetching
|
||||||
|
if (sale && sale.data && sale.data.price) return true;
|
||||||
|
if (sale && sale.isError) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Cian floor enum
|
||||||
|
_FLOOR_SPECIAL = {1: "floorOne", 2: "floorTwo"}
|
||||||
|
|
||||||
|
|
||||||
|
def cian_floor_enum(floor: int, total_floors: int | None) -> str:
|
||||||
|
if floor in _FLOOR_SPECIAL:
|
||||||
|
return _FLOOR_SPECIAL[floor]
|
||||||
|
if total_floors and floor == total_floors:
|
||||||
|
return "floorLast"
|
||||||
|
return "floorOther"
|
||||||
|
|
||||||
|
|
||||||
|
def compute_cache_key(address, total_area, rooms, floor, repair, deal):
|
||||||
|
norm = f"{address.strip().lower()}|{total_area}|{rooms}|{floor}|{repair}|{deal}"
|
||||||
|
return hashlib.sha256(norm.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_change_pct(value_str: str) -> float | None:
|
||||||
|
"""'6,4%' / '-1.2%' → 6.4 / -1.2"""
|
||||||
|
if not value_str:
|
||||||
|
return None
|
||||||
|
m = re.search(r"-?\d+[\.,]?\d*", value_str)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return float(m.group(0).replace(",", "."))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_address(addr: str) -> str:
|
||||||
|
"""listings.address типа 'улица Машинная, 1В/4 · р-н Ленинский' → 'Екатеринбург, улица Машинная, 1В/4'.
|
||||||
|
|
||||||
|
1. отрезаем ' · р-н ...' postfix
|
||||||
|
2. подставляем 'Екатеринбург, ' если города нет в строке
|
||||||
|
"""
|
||||||
|
if not addr:
|
||||||
|
return addr
|
||||||
|
# cut at middle-dot (Cian district separator)
|
||||||
|
addr = re.split(r"\s*[·•]\s*", addr)[0].strip().rstrip(",")
|
||||||
|
if "екатеринбург" not in addr.lower():
|
||||||
|
addr = f"Екатеринбург, {addr}"
|
||||||
|
return addr
|
||||||
|
|
||||||
|
|
||||||
|
def build_calc_url(address: str, area: float, rooms: int, floor: int, total_floors: int | None) -> str:
|
||||||
|
params = {
|
||||||
|
"address": normalize_address(address),
|
||||||
|
"totalArea": str(area),
|
||||||
|
"roomsCount": str(rooms),
|
||||||
|
"valuationType": "sale",
|
||||||
|
"floor[0]": cian_floor_enum(floor, total_floors),
|
||||||
|
"repairType[0]": "repairTypeCosmetic",
|
||||||
|
}
|
||||||
|
return f"https://www.cian.ru/kalkulator-nedvizhimosti/?{urllib.parse.urlencode(params, safe='[]')}"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--batch", type=int, default=10)
|
||||||
|
ap.add_argument("--delay", type=float, default=3.0, help="seconds между listings")
|
||||||
|
ap.add_argument("--wait", type=float, default=8.0, help="max seconds wait until valuation data")
|
||||||
|
ap.add_argument("--max", type=int, default=5500)
|
||||||
|
ap.add_argument("--headed", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
dsn = os.environ.get("TRADEIN_DB_DSN")
|
||||||
|
if not dsn:
|
||||||
|
sys.exit("FATAL: TRADEIN_DB_DSN env required")
|
||||||
|
enc_key = os.environ.get("COOKIE_ENCRYPTION_KEY")
|
||||||
|
if not enc_key:
|
||||||
|
sys.exit("FATAL: COOKIE_ENCRYPTION_KEY env required")
|
||||||
|
pg_dsn = dsn.replace("postgresql+psycopg://", "postgresql://")
|
||||||
|
|
||||||
|
interrupted = False
|
||||||
|
|
||||||
|
def on_sigint(s, f):
|
||||||
|
nonlocal interrupted
|
||||||
|
print("\n!! Ctrl+C — finishing current item")
|
||||||
|
interrupted = True
|
||||||
|
signal.signal(signal.SIGINT, on_sigint)
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
nonlocal interrupted
|
||||||
|
cum_ok = cum_fail = cum_skipped = 0
|
||||||
|
start = time.time()
|
||||||
|
last_id = 0
|
||||||
|
|
||||||
|
def load_cookies() -> tuple[list, datetime | None]:
|
||||||
|
with psycopg.connect(pg_dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT pgp_sym_decrypt(cookies_encrypted, %s)::text, uploaded_at
|
||||||
|
FROM cian_session_cookies
|
||||||
|
WHERE expires_at_estimate > NOW() AND last_invalid_at IS NULL
|
||||||
|
ORDER BY uploaded_at DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(enc_key,),
|
||||||
|
)
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
return [], None
|
||||||
|
jar = json.loads(r[0])
|
||||||
|
out = []
|
||||||
|
for name, val in jar.items():
|
||||||
|
out.append({
|
||||||
|
"name": name, "value": val,
|
||||||
|
"domain": ".cian.ru", "path": "/",
|
||||||
|
"secure": True, "sameSite": "Lax",
|
||||||
|
})
|
||||||
|
return out, r[1]
|
||||||
|
|
||||||
|
cookies_from_db, cookies_uploaded_at = load_cookies()
|
||||||
|
if not cookies_from_db:
|
||||||
|
sys.exit("FATAL: no valid cookies in cian_session_cookies")
|
||||||
|
print(f"[init] loaded {len(cookies_from_db)} cookies (uploaded {cookies_uploaded_at})")
|
||||||
|
|
||||||
|
profile_dir = os.environ.get("CIAN_PROFILE_DIR", r"C:\Users\user\cian-backfill\chrome_profile")
|
||||||
|
os.makedirs(profile_dir, exist_ok=True)
|
||||||
|
|
||||||
|
async with async_playwright() as p:
|
||||||
|
ctx = await p.chromium.launch_persistent_context(
|
||||||
|
user_data_dir=profile_dir,
|
||||||
|
headless=not args.headed,
|
||||||
|
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
viewport={"width": 1366, "height": 900},
|
||||||
|
locale="ru-RU",
|
||||||
|
args=["--disable-blink-features=AutomationControlled"],
|
||||||
|
)
|
||||||
|
# First launch: inject cookies из БД. Subsequent launches: cookies уже в profile.
|
||||||
|
existing = await ctx.cookies("https://www.cian.ru")
|
||||||
|
has_auth = any(c.get("name") == "DMIR_AUTH" for c in existing)
|
||||||
|
if not has_auth and cookies_from_db:
|
||||||
|
await ctx.add_cookies(cookies_from_db)
|
||||||
|
print(f"[init] injected {len(cookies_from_db)} cookies into fresh profile")
|
||||||
|
else:
|
||||||
|
print(f"[init] reusing profile cookies ({len(existing)} present, DMIR_AUTH={'yes' if has_auth else 'no'})")
|
||||||
|
page = ctx.pages[0] if ctx.pages else await ctx.new_page()
|
||||||
|
page.set_default_navigation_timeout(45_000)
|
||||||
|
page.set_default_timeout(45_000)
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
while not interrupted and processed < args.max:
|
||||||
|
# 1. Fetch batch of listings WITH полный address + geo
|
||||||
|
with psycopg.connect(pg_dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT l.id, l.address, l.area_m2, l.rooms, l.floor, l.total_floors
|
||||||
|
FROM listings l
|
||||||
|
WHERE l.source = 'cian'
|
||||||
|
AND l.address IS NOT NULL
|
||||||
|
AND length(l.address) > 15 -- хотя бы город+улица+номер
|
||||||
|
AND l.area_m2 IS NOT NULL
|
||||||
|
AND l.rooms IS NOT NULL
|
||||||
|
AND l.floor IS NOT NULL
|
||||||
|
AND l.total_floors IS NOT NULL
|
||||||
|
AND l.id > %s
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM external_valuations ev
|
||||||
|
WHERE ev.source='cian_valuation'
|
||||||
|
AND ev.listing_id = l.id
|
||||||
|
AND ev.expires_at > NOW()
|
||||||
|
)
|
||||||
|
ORDER BY l.id
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(last_id, args.batch),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
if not rows:
|
||||||
|
print(f"\n[done] drained — no more listings")
|
||||||
|
break
|
||||||
|
print(f"\n[batch] {len(rows)} listings (cum: ok={cum_ok} fail={cum_fail} skipped={cum_skipped})")
|
||||||
|
|
||||||
|
with psycopg.connect(pg_dsn, autocommit=False) as conn:
|
||||||
|
for (lid, addr, area, rooms, floor, total_floors) in rows:
|
||||||
|
if interrupted:
|
||||||
|
break
|
||||||
|
processed += 1
|
||||||
|
last_id = lid
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
area_f = float(area)
|
||||||
|
rooms_i = int(rooms)
|
||||||
|
floor_i = int(floor)
|
||||||
|
total_floors_i = int(total_floors) if total_floors else None
|
||||||
|
url = build_calc_url(addr, area_f, rooms_i, floor_i, total_floors_i)
|
||||||
|
await page.goto(url, wait_until="domcontentloaded")
|
||||||
|
# Click submit button "Оценить" — без этого Cian не вычисляет
|
||||||
|
try:
|
||||||
|
btn = page.get_by_test_id("next-button")
|
||||||
|
await btn.click(timeout=5000)
|
||||||
|
except Exception:
|
||||||
|
pass # button может отсутствовать если URL params не валидны — fallback
|
||||||
|
try:
|
||||||
|
await page.wait_for_function(WAIT_JS, timeout=int(args.wait * 1000))
|
||||||
|
except Exception:
|
||||||
|
pass # fall through и попробуем extract — может data есть с задержкой
|
||||||
|
data = await page.evaluate(EXTRACT_JS)
|
||||||
|
if data.get("error"):
|
||||||
|
if data.get("captcha"):
|
||||||
|
print(f" [{lid}] CAPTCHA — STOP, please solve in --headed mode")
|
||||||
|
interrupted = True
|
||||||
|
break
|
||||||
|
cum_fail += 1
|
||||||
|
print(f" [{lid}] err={data['error']} {time.time() - t0:.1f}s")
|
||||||
|
continue
|
||||||
|
if not data.get("isAuth"):
|
||||||
|
# 1. Try fresher cookies из БД
|
||||||
|
fresh, uploaded = load_cookies()
|
||||||
|
if fresh and uploaded and (cookies_uploaded_at is None or uploaded > cookies_uploaded_at):
|
||||||
|
print(f" [{lid}] auth lost — reloading fresh cookies (uploaded {uploaded})")
|
||||||
|
await ctx.clear_cookies()
|
||||||
|
await ctx.add_cookies(fresh)
|
||||||
|
cookies_uploaded_at = uploaded
|
||||||
|
await page.goto(url, wait_until="domcontentloaded")
|
||||||
|
try:
|
||||||
|
await page.wait_for_function(WAIT_JS, timeout=int(args.wait * 1000))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
data = await page.evaluate(EXTRACT_JS)
|
||||||
|
# 2. Если headed — даём user'у залогиниться вручную в открытом окне
|
||||||
|
if not data.get("isAuth") and args.headed:
|
||||||
|
print(f"\n [{lid}] !! AUTH LOST !!")
|
||||||
|
print(f" → В открытом Chrome окне открой www.cian.ru, ЗАЛОГИНЬСЯ.")
|
||||||
|
print(f" → После успешного логина нажми Enter здесь чтобы продолжить.")
|
||||||
|
print(f" → Или Ctrl+C чтобы остановить.")
|
||||||
|
try:
|
||||||
|
# blocking input — пользователь логинится сам
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, input, " >>> press Enter when logged in: ")
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
interrupted = True
|
||||||
|
break
|
||||||
|
# retry same listing
|
||||||
|
await page.goto(url, wait_until="domcontentloaded")
|
||||||
|
try:
|
||||||
|
await page.wait_for_function(WAIT_JS, timeout=int(args.wait * 1000))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
data = await page.evaluate(EXTRACT_JS)
|
||||||
|
# 3. Headless и всё ещё no auth → STOP
|
||||||
|
if not data.get("isAuth"):
|
||||||
|
print(f" [{lid}] not auth — STOP. Запусти с --headed и login manually.")
|
||||||
|
interrupted = True
|
||||||
|
break
|
||||||
|
sale = data.get("sale")
|
||||||
|
if not sale or not sale.get("price"):
|
||||||
|
cum_skipped += 1
|
||||||
|
print(f" [{lid}] no sale data (address not matched?) {time.time() - t0:.1f}s")
|
||||||
|
continue
|
||||||
|
rent = data.get("rent") or {}
|
||||||
|
chart_obj = data.get("chart_obj") or {}
|
||||||
|
chart_points = (chart_obj.get("chartData") or {}).get("data") or []
|
||||||
|
chart_title = chart_obj.get("title") or {}
|
||||||
|
chart_change_dir = chart_title.get("change") # 'increase'/'decrease'/'neutral'
|
||||||
|
chart_change_pct = parse_change_pct(chart_title.get("changeValue") or "")
|
||||||
|
house_id = data.get("house_id")
|
||||||
|
cache_key = compute_cache_key(addr, area_f, rooms_i, floor_i, "cosmetic", "sale")
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO external_valuations (
|
||||||
|
source, cache_key, address,
|
||||||
|
total_area, rooms_count, floor, total_floors,
|
||||||
|
repair_type, deal_type,
|
||||||
|
price_rub, accuracy,
|
||||||
|
low_price, high_price,
|
||||||
|
house_id, listing_id,
|
||||||
|
sale_price_rub, sale_accuracy,
|
||||||
|
rent_price_rub, rent_accuracy,
|
||||||
|
chart, chart_change_pct, chart_change_direction,
|
||||||
|
external_house_id, filters_hash,
|
||||||
|
raw_payload, fetched_at, expires_at
|
||||||
|
) VALUES (
|
||||||
|
'cian_valuation', %s, %s,
|
||||||
|
CAST(%s AS numeric), %s, %s, %s,
|
||||||
|
'cosmetic', 'sale',
|
||||||
|
CAST(%s AS numeric), CAST(%s AS numeric),
|
||||||
|
CAST(%s AS numeric), CAST(%s AS numeric),
|
||||||
|
NULL, CAST(%s AS bigint),
|
||||||
|
CAST(%s AS numeric), CAST(%s AS numeric),
|
||||||
|
CAST(%s AS numeric), CAST(%s AS numeric),
|
||||||
|
CAST(%s AS jsonb), CAST(%s AS numeric), %s,
|
||||||
|
%s, NULL,
|
||||||
|
CAST(%s AS jsonb), NOW(), NOW() + INTERVAL '30 days'
|
||||||
|
)
|
||||||
|
ON CONFLICT (source, cache_key) DO UPDATE SET
|
||||||
|
price_rub = EXCLUDED.price_rub,
|
||||||
|
accuracy = EXCLUDED.accuracy,
|
||||||
|
low_price = EXCLUDED.low_price,
|
||||||
|
high_price = EXCLUDED.high_price,
|
||||||
|
listing_id = COALESCE(EXCLUDED.listing_id, external_valuations.listing_id),
|
||||||
|
sale_price_rub = EXCLUDED.sale_price_rub,
|
||||||
|
sale_accuracy = EXCLUDED.sale_accuracy,
|
||||||
|
rent_price_rub = EXCLUDED.rent_price_rub,
|
||||||
|
rent_accuracy = EXCLUDED.rent_accuracy,
|
||||||
|
chart = EXCLUDED.chart,
|
||||||
|
chart_change_pct = EXCLUDED.chart_change_pct,
|
||||||
|
chart_change_direction = EXCLUDED.chart_change_direction,
|
||||||
|
external_house_id = EXCLUDED.external_house_id,
|
||||||
|
raw_payload = EXCLUDED.raw_payload,
|
||||||
|
fetched_at = NOW(),
|
||||||
|
expires_at = NOW() + INTERVAL '30 days'
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
cache_key, addr,
|
||||||
|
area_f, rooms_i, floor_i, total_floors_i,
|
||||||
|
sale.get("price"), (sale.get("accuracy") or 0) / 100,
|
||||||
|
sale.get("priceFrom"), sale.get("priceTo"),
|
||||||
|
lid,
|
||||||
|
sale.get("price"), (sale.get("accuracy") or 0) / 100,
|
||||||
|
rent.get("price"), (rent.get("accuracy") or 0) / 100 if rent.get("accuracy") else None,
|
||||||
|
json.dumps(chart_points), chart_change_pct,
|
||||||
|
chart_change_dir if chart_change_dir in ("increase", "decrease", "neutral") else None,
|
||||||
|
house_id,
|
||||||
|
json.dumps(data.get("full_state") or {"sale": sale, "rent": rent, "chart": chart_obj, "house_id": house_id}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cum_ok += 1
|
||||||
|
print(f" [{lid}] OK sale={sale.get('price'):,} rent={rent.get('price')} chart={len(chart_points)} chg={chart_change_pct}%/{chart_change_dir} houseId={house_id} {time.time() - t0:.1f}s")
|
||||||
|
except Exception as exc:
|
||||||
|
conn.rollback()
|
||||||
|
cum_fail += 1
|
||||||
|
print(f" [{lid}] ERR {type(exc).__name__}: {str(exc)[:140]} {time.time() - t0:.1f}s")
|
||||||
|
await asyncio.sleep(args.delay)
|
||||||
|
|
||||||
|
await ctx.close()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"== Summary ==")
|
||||||
|
print(f" ok={cum_ok} fail={cum_fail} skipped_no_data={cum_skipped}")
|
||||||
|
print(f" elapsed={time.time() - start:.1f}s finished={datetime.now(timezone.utc).isoformat(timespec='seconds')}")
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
65
tradein-mvp/scripts/local-cian/upload_cookies_local.py
Normal file
65
tradein-mvp/scripts/local-cian/upload_cookies_local.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""Локальный upload Cian cookies в tradein-postgres через SSH tunnel.
|
||||||
|
|
||||||
|
Не требует docker exec на проде. Требует:
|
||||||
|
pip install "psycopg[binary]"
|
||||||
|
SSH tunnel поднят к tradein-postgres (см. README рядом)
|
||||||
|
Env: TRADEIN_DB_DSN, COOKIE_ENCRYPTION_KEY
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
dsn = os.environ.get("TRADEIN_DB_DSN")
|
||||||
|
key = os.environ.get("COOKIE_ENCRYPTION_KEY")
|
||||||
|
if not dsn:
|
||||||
|
sys.exit("FATAL: TRADEIN_DB_DSN env not set")
|
||||||
|
if not key:
|
||||||
|
sys.exit("FATAL: COOKIE_ENCRYPTION_KEY env not set")
|
||||||
|
|
||||||
|
cookies = {
|
||||||
|
"DMIR_AUTH": "<PASTE_FROM_BROWSER_DEVTOOLS>",
|
||||||
|
"_CIAN_GK": "<PASTE_FROM_BROWSER_DEVTOOLS>",
|
||||||
|
"_yasc": "<optional>",
|
||||||
|
"_ym_isad": "<optional>",
|
||||||
|
"uxfb_card_satisfaction": "<optional>",
|
||||||
|
}
|
||||||
|
|
||||||
|
# account_user_id — ваш Cian user ID (найти: DevTools → XHR → /v1/get-my-id/ → userId).
|
||||||
|
# Используется как ключ в cian_session_cookies. Для локального backfill
|
||||||
|
# достаточно любого уникального числа (например ваш реальный Cian userId).
|
||||||
|
ACCOUNT_USER_ID = 999999999 # TODO: замените на реальный Cian userId
|
||||||
|
|
||||||
|
with psycopg.connect(dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO cian_session_cookies (
|
||||||
|
account_user_id,
|
||||||
|
cookies_encrypted,
|
||||||
|
expires_at_estimate,
|
||||||
|
uploaded_at,
|
||||||
|
notes
|
||||||
|
) VALUES (
|
||||||
|
CAST(%s AS bigint),
|
||||||
|
pgp_sym_encrypt(%s, %s),
|
||||||
|
NOW() + INTERVAL '30 days',
|
||||||
|
NOW(),
|
||||||
|
'manual local upload via SSH tunnel'
|
||||||
|
)
|
||||||
|
ON CONFLICT (account_user_id) DO UPDATE SET
|
||||||
|
cookies_encrypted = EXCLUDED.cookies_encrypted,
|
||||||
|
expires_at_estimate = EXCLUDED.expires_at_estimate,
|
||||||
|
uploaded_at = NOW(),
|
||||||
|
last_invalid_at = NULL
|
||||||
|
""", (ACCOUNT_USER_ID, json.dumps(cookies), key))
|
||||||
|
conn.commit()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT account_user_id, uploaded_at, expires_at_estimate, "
|
||||||
|
"octet_length(cookies_encrypted) AS enc_bytes "
|
||||||
|
"FROM cian_session_cookies WHERE account_user_id = CAST(%s AS bigint)",
|
||||||
|
(ACCOUNT_USER_ID,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
print(f"OK saved: user_id={row[0]} uploaded={row[1]} expires={row[2]} "
|
||||||
|
f"enc_bytes={row[3]}")
|
||||||
Loading…
Add table
Reference in a new issue