feat(ai-studio): read shopee source image urls
This commit is contained in:
+226
-1
@@ -9,11 +9,12 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from . import appconfig, db
|
||||
from . import accounts, appconfig, chrome, db, editor
|
||||
from .config import make_slug
|
||||
|
||||
|
||||
PROJECT_STATUS_ACTIVE = "active"
|
||||
ASSET_KIND_ORIGINAL = "original"
|
||||
ASSET_STATUS_AVAILABLE = "available"
|
||||
ASSET_STATUS_MISSING = "missing"
|
||||
ASSET_STATUSES = {ASSET_STATUS_AVAILABLE, ASSET_STATUS_MISSING}
|
||||
@@ -90,6 +91,10 @@ class ImageStudioSelection:
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ImageStudioError(RuntimeError):
|
||||
"""Raised when the AI image studio service cannot complete an operation."""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
@@ -141,6 +146,83 @@ def _task_key(project_id):
|
||||
return f"image-studio-{int(project_id)}-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def _db_path(path=None, config=None) -> str:
|
||||
return path or appconfig.db_path(config)
|
||||
|
||||
|
||||
def _normalize_item_id(item_id) -> str:
|
||||
text = str(item_id or "").strip()
|
||||
if not text:
|
||||
raise ImageStudioError("商品ID不能为空")
|
||||
return text
|
||||
|
||||
|
||||
def _notify_step(callback, step, result="start", detail=None):
|
||||
if callback is None:
|
||||
return
|
||||
payload = {"step": step, "result": result}
|
||||
if detail:
|
||||
payload["detail"] = str(detail)
|
||||
try:
|
||||
callback(payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _is_definitive_logged_out(status):
|
||||
reason = str((status or {}).get("reason") or "").strip()
|
||||
url = str((status or {}).get("url") or "").lower()
|
||||
return reason.startswith("LOGIN_PAGE") or (
|
||||
"accounts.shopee." in url and "/seller/login" in url
|
||||
)
|
||||
|
||||
|
||||
def _login_status_detail(status):
|
||||
status = status or {}
|
||||
reason = status.get("reason") or "未知原因"
|
||||
url = status.get("url") or "未知URL"
|
||||
cookie_names = [str(name) for name in (status.get("cookie_names") or []) if name]
|
||||
cookie_text = ",".join(sorted(cookie_names)) if cookie_names else "未读到登录Cookie"
|
||||
return f"原因={reason},URL={url},Cookie名称={cookie_text}"
|
||||
|
||||
|
||||
def _ensure_account_ready_for_read(account, *, path=None, config=None, login_timeout=8, on_step=None):
|
||||
port = accounts.normalize_debug_port(_get(account, "debug_port"))
|
||||
alias = str(_get(account, "alias") or "").strip()
|
||||
_notify_step(on_step, "ensure_chrome", "start", f"账号 {alias} debug_port={port}")
|
||||
if chrome.is_running(port):
|
||||
chrome_info = {
|
||||
"action": "reused",
|
||||
"reused": True,
|
||||
"launched": False,
|
||||
"debug_port": port,
|
||||
}
|
||||
_notify_step(on_step, "ensure_chrome", "reused", f"账号 {alias} Chrome 已打开")
|
||||
else:
|
||||
try:
|
||||
chrome_info = accounts.launch_for_login(account, path=path, config=config)
|
||||
except Exception as exc:
|
||||
raise ImageStudioError(f"账号 {alias} Chrome 启动失败:{exc}") from exc
|
||||
action = "launched" if chrome_info.get("launched") else "reused"
|
||||
_notify_step(on_step, "ensure_chrome", action, f"账号 {alias} Chrome 已{ '启动' if action == 'launched' else '复用' }")
|
||||
|
||||
_notify_step(on_step, "login_check", "start", f"账号 {alias}")
|
||||
try:
|
||||
status = accounts.detect_login(account, timeout=login_timeout, path=path, config=config)
|
||||
except Exception as exc:
|
||||
status = {"logged_in": False, "reason": f"LOGIN_CHECK_FAILED: {exc}"}
|
||||
if status.get("logged_in"):
|
||||
_notify_step(on_step, "login_check", "success", f"账号 {alias} 已登录")
|
||||
return {**chrome_info, "login_status": status, "login_uncertain": False}
|
||||
if _is_definitive_logged_out(status):
|
||||
detail = _login_status_detail(status)
|
||||
_notify_step(on_step, "login_check", "blocked", detail)
|
||||
raise ImageStudioError(f"账号 {alias} 未登录:{detail}")
|
||||
detail = _login_status_detail(status)
|
||||
_notify_step(on_step, "login_check", "uncertain", f"账号 {alias} 登录状态暂不稳定,继续尝试读取商品:{detail}")
|
||||
return {**chrome_info, "login_status": status, "login_uncertain": True}
|
||||
|
||||
|
||||
def _ensure_assets_belong_to_project(database, project_id, asset_ids):
|
||||
ids = [int(asset_id) for asset_id in asset_ids if asset_id is not None]
|
||||
if not ids:
|
||||
@@ -370,6 +452,91 @@ def list_assets(project_id, kind=None, include_missing=True, path=None, conn=Non
|
||||
return _fetch_all(database, sql, params, ImageStudioAsset)
|
||||
|
||||
|
||||
def sync_original_asset_urls(project_id, image_urls, path=None, conn=None):
|
||||
"""Store the read-only Shopee main image URL snapshot as remote-only assets."""
|
||||
|
||||
normalized = []
|
||||
seen_urls = set()
|
||||
for position, item in enumerate(image_urls or [], start=1):
|
||||
if isinstance(item, dict):
|
||||
src = str(item.get("src") or "").strip()
|
||||
source_order = int(item.get("index") or position)
|
||||
else:
|
||||
src = str(item or "").strip()
|
||||
source_order = position
|
||||
if not src or src in seen_urls:
|
||||
continue
|
||||
seen_urls.add(src)
|
||||
normalized.append({"src": src, "source_order": source_order})
|
||||
|
||||
now = _now()
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
existing_rows = database.execute(
|
||||
"""
|
||||
SELECT * FROM image_studio_assets
|
||||
WHERE project_id = ? AND kind = ?
|
||||
""",
|
||||
(int(project_id), ASSET_KIND_ORIGINAL),
|
||||
).fetchall()
|
||||
by_url = {
|
||||
str(row["remote_url"]): row
|
||||
for row in existing_rows
|
||||
if row["remote_url"]
|
||||
}
|
||||
active_ids = set()
|
||||
for item in normalized:
|
||||
row = by_url.get(item["src"])
|
||||
if row is not None:
|
||||
active_ids.add(int(row["id"]))
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE image_studio_assets
|
||||
SET source_order = ?,
|
||||
status = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
int(item["source_order"]),
|
||||
ASSET_STATUS_AVAILABLE,
|
||||
now,
|
||||
int(row["id"]),
|
||||
),
|
||||
)
|
||||
else:
|
||||
cursor = database.execute(
|
||||
"""
|
||||
INSERT INTO image_studio_assets
|
||||
(project_id, kind, remote_url, local_path, status,
|
||||
source_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, NULL, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(project_id),
|
||||
ASSET_KIND_ORIGINAL,
|
||||
item["src"],
|
||||
ASSET_STATUS_AVAILABLE,
|
||||
int(item["source_order"]),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
active_ids.add(int(cursor.lastrowid))
|
||||
missing_ids = [int(row["id"]) for row in existing_rows if int(row["id"]) not in active_ids]
|
||||
if missing_ids:
|
||||
placeholders = ",".join("?" for _ in missing_ids)
|
||||
database.execute(
|
||||
f"""
|
||||
UPDATE image_studio_assets
|
||||
SET status = ?, updated_at = ?
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
[ASSET_STATUS_MISSING, now, *missing_ids],
|
||||
)
|
||||
return list_assets(project_id, kind=ASSET_KIND_ORIGINAL, conn=database)
|
||||
|
||||
|
||||
def mark_asset_status(asset_id, status, path=None, conn=None):
|
||||
if str(status) not in ASSET_STATUSES:
|
||||
raise db.DbError("AI工场资产状态必须是 available 或 missing")
|
||||
@@ -563,3 +730,61 @@ def list_selections(project_id, selection_type=None, path=None, conn=None):
|
||||
sql += " ORDER BY selection_type, position"
|
||||
with _connection(conn, path) as database:
|
||||
return _fetch_all(database, sql, params, ImageStudioSelection)
|
||||
|
||||
|
||||
def pull_remote_main_image_urls(
|
||||
account_or_alias,
|
||||
item_id,
|
||||
*,
|
||||
path=None,
|
||||
config=None,
|
||||
login_timeout=8,
|
||||
on_step=None,
|
||||
):
|
||||
"""Create/open an AI studio project and read Shopee main image URLs without editing."""
|
||||
|
||||
cfg = appconfig.load_config() if config is None else config
|
||||
database_path = _db_path(path, cfg)
|
||||
db.init_db(database_path)
|
||||
item = _normalize_item_id(item_id)
|
||||
try:
|
||||
account = accounts.resolve_account(account_or_alias, path=database_path, config=cfg)
|
||||
except Exception as exc:
|
||||
raise ImageStudioError(f"AI工场账号不可用:{exc}") from exc
|
||||
|
||||
project = create_or_get_project(account, item_id=item, path=database_path)
|
||||
readiness = _ensure_account_ready_for_read(
|
||||
account,
|
||||
path=database_path,
|
||||
config=cfg,
|
||||
login_timeout=login_timeout,
|
||||
on_step=on_step,
|
||||
)
|
||||
cdp = None
|
||||
try:
|
||||
_notify_step(on_step, "open_product", "start", f"商品 {item}")
|
||||
cdp = editor.open_product(account, item, on_step=on_step, bring_to_front=False)
|
||||
_notify_step(on_step, "open_product", "success", f"商品 {item}")
|
||||
_notify_step(on_step, "read_main_images", "start", f"商品 {item}")
|
||||
images = editor.read_product_image_urls(cdp)
|
||||
if not images:
|
||||
raise ImageStudioError("未读取到蝦皮商品主图 URL")
|
||||
assets = sync_original_asset_urls(project.id, images, path=database_path)
|
||||
_notify_step(on_step, "read_main_images", "success", f"读取 {len(images)} 张主图 URL")
|
||||
project = get_project(project.id, path=database_path)
|
||||
return {
|
||||
"project": project,
|
||||
"images": images,
|
||||
"assets": assets,
|
||||
"account": account,
|
||||
"readiness": readiness,
|
||||
}
|
||||
except ImageStudioError:
|
||||
raise
|
||||
except editor.EditorError as exc:
|
||||
raise ImageStudioError(str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise ImageStudioError(f"读取蝦皮原主图失败:{exc}") from exc
|
||||
finally:
|
||||
if cdp is not None:
|
||||
editor.close_readonly_product(cdp)
|
||||
|
||||
Reference in New Issue
Block a user