aukeys-opscli 0.0.6__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.
Files changed (92) hide show
  1. aukeys_opscli-0.0.6.dist-info/METADATA +15 -0
  2. aukeys_opscli-0.0.6.dist-info/RECORD +92 -0
  3. aukeys_opscli-0.0.6.dist-info/WHEEL +4 -0
  4. aukeys_opscli-0.0.6.dist-info/entry_points.txt +2 -0
  5. opscli/__init__.py +9 -0
  6. opscli/amazon/__init__.py +13 -0
  7. opscli/amazon/cli.py +5 -0
  8. opscli/amazon/client.py +5 -0
  9. opscli/amazon/commands/__init__.py +1 -0
  10. opscli/amazon/commands/cli.py +200 -0
  11. opscli/amazon/domain/__init__.py +25 -0
  12. opscli/amazon/domain/exceptions.py +73 -0
  13. opscli/amazon/domain/models.py +74 -0
  14. opscli/amazon/exceptions.py +21 -0
  15. opscli/amazon/manager.py +5 -0
  16. opscli/amazon/models.py +5 -0
  17. opscli/amazon/parser.py +5 -0
  18. opscli/amazon/scraper.py +5 -0
  19. opscli/amazon/scraping/__init__.py +12 -0
  20. opscli/amazon/scraping/parser.py +66 -0
  21. opscli/amazon/scraping/scraper.py +315 -0
  22. opscli/amazon/services/__init__.py +5 -0
  23. opscli/amazon/services/manager.py +105 -0
  24. opscli/amazon/transport/__init__.py +5 -0
  25. opscli/amazon/transport/client.py +77 -0
  26. opscli/auth/__init__.py +79 -0
  27. opscli/auth/cli.py +255 -0
  28. opscli/auth/commands/__init__.py +1 -0
  29. opscli/auth/commands/cli.py +5 -0
  30. opscli/auth/config.py +55 -0
  31. opscli/auth/core/__init__.py +1 -0
  32. opscli/auth/core/device_flow.py +80 -0
  33. opscli/auth/core/system_registry.py +89 -0
  34. opscli/auth/core/token_manager.py +167 -0
  35. opscli/auth/domain/__init__.py +23 -0
  36. opscli/auth/domain/exceptions.py +33 -0
  37. opscli/auth/exceptions.py +23 -0
  38. opscli/auth/storage/__init__.py +1 -0
  39. opscli/auth/storage/credential_store.py +163 -0
  40. opscli/auth/storage/crypto.py +67 -0
  41. opscli/cli.py +36 -0
  42. opscli/config.py +3 -0
  43. opscli/query/__init__.py +6 -0
  44. opscli/query/cli.py +5 -0
  45. opscli/query/client.py +5 -0
  46. opscli/query/commands/__init__.py +1 -0
  47. opscli/query/commands/cli.py +140 -0
  48. opscli/query/domain/__init__.py +23 -0
  49. opscli/query/domain/exceptions.py +73 -0
  50. opscli/query/domain/models.py +21 -0
  51. opscli/query/exceptions.py +21 -0
  52. opscli/query/manager.py +5 -0
  53. opscli/query/models.py +5 -0
  54. opscli/query/services/__init__.py +5 -0
  55. opscli/query/services/manager.py +373 -0
  56. opscli/query/transport/__init__.py +5 -0
  57. opscli/query/transport/client.py +57 -0
  58. opscli/skills/__init__.py +37 -0
  59. opscli/skills/cli.py +5 -0
  60. opscli/skills/commands/__init__.py +1 -0
  61. opscli/skills/commands/cli.py +389 -0
  62. opscli/skills/detector.py +5 -0
  63. opscli/skills/discovery/__init__.py +5 -0
  64. opscli/skills/discovery/detector.py +225 -0
  65. opscli/skills/domain/__init__.py +23 -0
  66. opscli/skills/domain/exceptions.py +51 -0
  67. opscli/skills/domain/models.py +144 -0
  68. opscli/skills/exceptions.py +5 -0
  69. opscli/skills/manager.py +5 -0
  70. opscli/skills/models.py +19 -0
  71. opscli/skills/services/__init__.py +5 -0
  72. opscli/skills/services/manager.py +276 -0
  73. opscli/skills/sync/__init__.py +5 -0
  74. opscli/skills/sync/updater.py +274 -0
  75. opscli/skills/templates/ops-amazon/SKILL.md +181 -0
  76. opscli/skills/templates/ops-amazon/data/VERSION.json +4 -0
  77. opscli/skills/templates/ops-auth/SKILL.md +466 -0
  78. opscli/skills/templates/ops-auth/data/VERSION.json +4 -0
  79. opscli/skills/templates/ops-dataset-query/SKILL.md +691 -0
  80. opscli/skills/templates/ops-dataset-query/data/VERSION.json +4 -0
  81. opscli/skills/templates/ops-dataset-query/data/dataset_fields.csv +1 -0
  82. opscli/skills/templates/ops-dataset-query/data/datasets.csv +1 -0
  83. opscli/skills/templates/ops-dataset-query/data/query_metadata.json +4 -0
  84. opscli/skills/templates/ops-dataset-query/references//346/225/260/346/215/256/346/237/245/350/257/242/346/234/215/345/212/241/345/274/200/345/217/221/350/257/264/346/230/216/346/226/207/346/241/243.md +1126 -0
  85. opscli/skills/templates/ops-dataset-query/scripts/core.py +140 -0
  86. opscli/skills/templates/ops-dataset-query/scripts/query.py +145 -0
  87. opscli/skills/templates/ops-dataset-query/scripts/search.py +36 -0
  88. opscli/skills/templates/ops-dataset-query/scripts/updater.py +106 -0
  89. opscli/skills/templates/ops-skills/SKILL.md +494 -0
  90. opscli/skills/templates/ops-skills/data/VERSION.json +4 -0
  91. opscli/skills/updater.py +5 -0
  92. opscli/version.py +19 -0
@@ -0,0 +1,315 @@
1
+ """amazon 模块抓取器。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from datetime import datetime
7
+ from urllib.parse import quote_plus
8
+
9
+ from opscli.amazon.domain.exceptions import InvalidAsinError, ScraperDependencyError
10
+ from opscli.amazon.domain.models import AmazonProductSnapshot, AmazonSearchResult
11
+ from opscli.amazon.scraping.parser import normalize_text, parse_price, parse_rating, parse_review_count
12
+
13
+
14
+ class AmazonScraper:
15
+ """基于 Playwright 的 Amazon 抓取器。"""
16
+
17
+ PRODUCT_URL_TEMPLATE = "https://www.amazon.com/dp/{asin}"
18
+ SEARCH_URL_TEMPLATE = "https://www.amazon.com/s?k={keyword}&s=review-rank"
19
+ DEFAULT_TIMEOUT_MS = 120000
20
+ DEFAULT_WAIT_MS = 3000
21
+
22
+ def __init__(self, *, headless: bool = True, timeout_ms: int = DEFAULT_TIMEOUT_MS) -> None:
23
+ self.headless = headless
24
+ self.timeout_ms = timeout_ms
25
+
26
+ def scrape_product(self, asin: str, zip_code: str = "10001") -> AmazonProductSnapshot:
27
+ """同步抓取单个商品。"""
28
+ return asyncio.run(self.scrape_product_async(asin, zip_code))
29
+
30
+ def search_products(
31
+ self,
32
+ keyword: str,
33
+ *,
34
+ max_results: int = 10,
35
+ zip_code: str = "10001",
36
+ ) -> list[AmazonSearchResult]:
37
+ """同步抓取搜索结果。"""
38
+ return asyncio.run(self.search_products_async(keyword, max_results=max_results, zip_code=zip_code))
39
+
40
+ async def scrape_product_async(self, asin: str, zip_code: str = "10001") -> AmazonProductSnapshot:
41
+ """异步抓取单个商品。"""
42
+ self._validate_asin(asin)
43
+ async with self._browser_page() as page:
44
+ page_url = self.PRODUCT_URL_TEMPLATE.format(asin=asin)
45
+ await page.goto(page_url, wait_until="domcontentloaded", timeout=self.timeout_ms)
46
+ await page.wait_for_timeout(self.DEFAULT_WAIT_MS)
47
+ await self._apply_zip_code(page, zip_code)
48
+
49
+ page_title = await page.title()
50
+ if self._looks_invalid(page_title):
51
+ return AmazonProductSnapshot(
52
+ asin=asin,
53
+ zip_code=zip_code,
54
+ marketplace="amazon.com",
55
+ page_url=page_url,
56
+ page_title=page_title,
57
+ product_name="",
58
+ price_text="",
59
+ price_amount=None,
60
+ currency=None,
61
+ rating_text="",
62
+ rating_value=None,
63
+ review_count_text="",
64
+ review_count_value=None,
65
+ location="",
66
+ collected_at=self._now(),
67
+ valid=False,
68
+ error="链接失效或页面不存在",
69
+ )
70
+
71
+ product_name = await self._get_text(page, ["#productTitle", "#title"])
72
+ if len(product_name) < 50 and page_title:
73
+ product_name = self._choose_better_title(product_name, page_title)
74
+
75
+ price_text = await self._get_text(
76
+ page,
77
+ [
78
+ "#corePriceDisplay_desktop_feature_div .a-price .a-offscreen",
79
+ ".a-price .a-offscreen",
80
+ ".reinventPricePriceToPayMargin .a-offscreen",
81
+ ],
82
+ )
83
+ rating_text = await self._get_text(
84
+ page,
85
+ [
86
+ "#acrPopover .a-icon-alt",
87
+ "[data-hook='rating-out-of-text']",
88
+ ],
89
+ )
90
+ review_count_text = await self._get_text(
91
+ page,
92
+ [
93
+ "#acrCustomerReviewText",
94
+ "[data-hook='total-review-count']",
95
+ "#acrCustomerReviewLink",
96
+ ],
97
+ )
98
+ location = await self._get_text(page, ["#glow-ingress-line2"])
99
+
100
+ price_amount, currency = parse_price(price_text)
101
+ rating_value = parse_rating(rating_text)
102
+ review_count_value = parse_review_count(review_count_text)
103
+
104
+ return AmazonProductSnapshot(
105
+ asin=asin,
106
+ zip_code=zip_code,
107
+ marketplace="amazon.com",
108
+ page_url=page_url,
109
+ page_title=page_title,
110
+ product_name=product_name,
111
+ price_text=price_text,
112
+ price_amount=price_amount,
113
+ currency=currency,
114
+ rating_text=rating_text,
115
+ rating_value=rating_value,
116
+ review_count_text=review_count_text,
117
+ review_count_value=review_count_value,
118
+ location=location,
119
+ collected_at=self._now(),
120
+ valid=True,
121
+ raw={
122
+ "productName": product_name,
123
+ "price": price_text,
124
+ "rating": rating_text,
125
+ "reviewCount": review_count_text,
126
+ "location": location,
127
+ },
128
+ )
129
+
130
+ async def search_products_async(
131
+ self,
132
+ keyword: str,
133
+ *,
134
+ max_results: int = 10,
135
+ zip_code: str = "10001",
136
+ ) -> list[AmazonSearchResult]:
137
+ """异步抓取搜索结果。"""
138
+ async with self._browser_page() as page:
139
+ search_url = self.SEARCH_URL_TEMPLATE.format(keyword=quote_plus(keyword))
140
+ await page.goto(search_url, wait_until="domcontentloaded", timeout=self.timeout_ms)
141
+ await page.wait_for_timeout(self.DEFAULT_WAIT_MS)
142
+ await self._apply_zip_code(page, zip_code)
143
+
144
+ items = await page.query_selector_all('[data-component-type="s-search-result"], .s-result-item')
145
+ results: list[AmazonSearchResult] = []
146
+ for item in items:
147
+ asin = normalize_text(await item.get_attribute("data-asin"))
148
+ if not asin:
149
+ continue
150
+
151
+ title = await self._get_text_from_scope(
152
+ item,
153
+ [
154
+ "h2 .a-link-normal span",
155
+ ".a-size-medium.a-color-base.a-text-normal",
156
+ ],
157
+ )
158
+ price_text = await self._get_text_from_scope(item, [".a-price .a-offscreen", ".a-price-whole"])
159
+ rating_text = await self._get_text_from_scope(item, [".a-icon-alt"])
160
+ review_count_text = await self._get_review_count_from_scope(item)
161
+ badge = await item.query_selector("[aria-label*='Best Seller']")
162
+
163
+ price_amount, _ = parse_price(price_text)
164
+ rating_value = parse_rating(rating_text)
165
+ review_count_value = parse_review_count(review_count_text)
166
+
167
+ results.append(
168
+ AmazonSearchResult(
169
+ asin=asin,
170
+ keyword=keyword,
171
+ zip_code=zip_code,
172
+ rank=len(results) + 1,
173
+ title=title,
174
+ price_text=price_text,
175
+ price_amount=price_amount,
176
+ rating_text=rating_text,
177
+ rating_value=rating_value,
178
+ review_count_text=review_count_text,
179
+ review_count_value=review_count_value,
180
+ is_best_seller=badge is not None,
181
+ )
182
+ )
183
+ if len(results) >= max_results:
184
+ break
185
+
186
+ return results
187
+
188
+ async def _apply_zip_code(self, page, zip_code: str) -> None:
189
+ """设置邮编以稳定价格口径。"""
190
+ if not zip_code:
191
+ return
192
+ try:
193
+ location_btn = await page.query_selector("#glow-ingress-line2")
194
+ if location_btn:
195
+ await location_btn.click()
196
+ await page.wait_for_timeout(1500)
197
+
198
+ zip_input = await page.query_selector("#GLUXZipUpdateInput")
199
+ if not zip_input:
200
+ return
201
+
202
+ await zip_input.fill(zip_code)
203
+ await page.wait_for_timeout(600)
204
+
205
+ submit_btn = await page.query_selector("#GLUXZipUpdate")
206
+ if submit_btn:
207
+ await submit_btn.click()
208
+ await page.wait_for_timeout(2500)
209
+ await page.reload(wait_until="domcontentloaded", timeout=self.timeout_ms)
210
+ await page.wait_for_timeout(1500)
211
+ except Exception:
212
+ return
213
+
214
+ async def _get_text(self, page, selectors: list[str]) -> str:
215
+ """从 page 上按优先级读取文本。"""
216
+ for selector in selectors:
217
+ handle = await page.query_selector(selector)
218
+ if handle:
219
+ value = normalize_text(await handle.inner_text())
220
+ if value:
221
+ return value
222
+ return ""
223
+
224
+ async def _get_text_from_scope(self, scope, selectors: list[str]) -> str:
225
+ """从局部作用域按优先级读取文本。"""
226
+ for selector in selectors:
227
+ handle = await scope.query_selector(selector)
228
+ if handle:
229
+ value = normalize_text(await handle.inner_text())
230
+ if value:
231
+ return value
232
+ return ""
233
+
234
+ async def _get_review_count_from_scope(self, scope) -> str:
235
+ """优先从评论链接中读取评论数,避免误取评分文本。"""
236
+ selectors = [
237
+ "a[href*='customerReviews'] span.a-size-base",
238
+ "a[href*='customerReviews'] .a-size-base",
239
+ "a[href*='customerReviews']",
240
+ "span.a-size-base.s-underline-text",
241
+ ]
242
+ for selector in selectors:
243
+ handles = await scope.query_selector_all(selector)
244
+ for handle in handles:
245
+ for value in (
246
+ normalize_text(await handle.inner_text()),
247
+ normalize_text(await handle.get_attribute("aria-label")),
248
+ ):
249
+ if parse_review_count(value) is not None:
250
+ return value
251
+ return ""
252
+
253
+ def _validate_asin(self, asin: str) -> None:
254
+ """校验 ASIN。"""
255
+ normalized = normalize_text(asin).upper()
256
+ if len(normalized) != 10 or not normalized.isalnum():
257
+ raise InvalidAsinError("ASIN 必须是 10 位字母或数字")
258
+
259
+ def _looks_invalid(self, page_title: str) -> bool:
260
+ """根据标题粗略判断页面是否失效。"""
261
+ title = normalize_text(page_title).lower()
262
+ return "page not found" in title or "sorry" in title and "amazon" in title
263
+
264
+ def _choose_better_title(self, product_name: str, page_title: str) -> str:
265
+ """当 DOM 标题过短时,回退使用页面标题。"""
266
+ candidate = normalize_text(page_title.split(":", 1)[0])
267
+ if len(candidate) > len(product_name):
268
+ return candidate
269
+ return product_name
270
+
271
+ def _now(self) -> str:
272
+ """返回统一的抓取时间。"""
273
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
274
+
275
+ def _load_playwright(self):
276
+ """延迟导入 Playwright,避免未安装时影响其他模块。"""
277
+ try:
278
+ from playwright.async_api import async_playwright
279
+ except ModuleNotFoundError as exc:
280
+ raise ScraperDependencyError(
281
+ "缺少 playwright 依赖,请安装 `pip install 'opscli[amazon]'` 并执行 `playwright install chromium`"
282
+ ) from exc
283
+ return async_playwright
284
+
285
+ def _browser_page(self):
286
+ """创建 page 上下文。"""
287
+ async_playwright = self._load_playwright()
288
+
289
+ class _PageContext:
290
+ def __init__(self, outer: AmazonScraper):
291
+ self.outer = outer
292
+ self.playwright = None
293
+ self.browser = None
294
+ self.context = None
295
+ self.page = None
296
+
297
+ async def __aenter__(self):
298
+ self.playwright = await async_playwright().start()
299
+ self.browser = await self.playwright.chromium.launch(headless=self.outer.headless)
300
+ self.context = await self.browser.new_context(
301
+ locale="en-US",
302
+ extra_http_headers={"Accept-Language": "en-US,en;q=0.9"},
303
+ )
304
+ self.page = await self.context.new_page()
305
+ return self.page
306
+
307
+ async def __aexit__(self, exc_type, exc, tb):
308
+ if self.context is not None:
309
+ await self.context.close()
310
+ if self.browser is not None:
311
+ await self.browser.close()
312
+ if self.playwright is not None:
313
+ await self.playwright.stop()
314
+
315
+ return _PageContext(self)
@@ -0,0 +1,5 @@
1
+ """amazon 服务层。"""
2
+
3
+ from opscli.amazon.services.manager import AmazonManager
4
+
5
+ __all__ = ["AmazonManager"]
@@ -0,0 +1,105 @@
1
+ """amazon 模块业务编排层。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from opscli.amazon.domain.models import AmazonCollectResult, AmazonProductSnapshot, AmazonSearchResult
9
+ from opscli.amazon.scraping.scraper import AmazonScraper
10
+ from opscli.amazon.transport.client import AmazonOpsClient
11
+ from opscli.config import CONFIG_DIR
12
+
13
+
14
+ class AmazonManager:
15
+ """协调抓取、本地落盘和远端提交。"""
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ base_dir: Path | None = None,
21
+ scraper: AmazonScraper | None = None,
22
+ client: AmazonOpsClient | None = None,
23
+ ) -> None:
24
+ self.base_dir = Path(base_dir or CONFIG_DIR)
25
+ self.data_dir = self.base_dir / "amazon"
26
+ self.history_dir = self.data_dir / "history"
27
+ self.scraper = scraper or AmazonScraper()
28
+ self.client = client or AmazonOpsClient()
29
+
30
+ def scrape_product(
31
+ self,
32
+ *,
33
+ asin: str,
34
+ zip_code: str = "10001",
35
+ save_history: bool = True,
36
+ ) -> AmazonCollectResult:
37
+ """抓取单个商品并可选写历史。"""
38
+ snapshot = self.scraper.scrape_product(asin, zip_code)
39
+ history_path = self._append_history(snapshot) if save_history else None
40
+ return AmazonCollectResult(snapshot=snapshot, history_path=history_path)
41
+
42
+ def scrape_and_submit(
43
+ self,
44
+ *,
45
+ asin: str,
46
+ zip_code: str = "10001",
47
+ endpoint: str | None = None,
48
+ save_history: bool = True,
49
+ ) -> AmazonCollectResult:
50
+ """抓取后直接提交到 ops。"""
51
+ result = self.scrape_product(asin=asin, zip_code=zip_code, save_history=save_history)
52
+ result.submit_result = self.client.submit_snapshot(result.snapshot, endpoint=endpoint)
53
+ return result
54
+
55
+ def build_submit_payload(self, snapshot: AmazonProductSnapshot) -> dict:
56
+ """构造未来提交到 ops 的标准 payload。"""
57
+ return {
58
+ "source": "opscli.amazon",
59
+ "snapshot": snapshot.to_dict(include_raw=True),
60
+ }
61
+
62
+ def scrape_payload(
63
+ self,
64
+ *,
65
+ asin: str,
66
+ zip_code: str = "10001",
67
+ save_history: bool = True,
68
+ ) -> dict:
69
+ """抓取商品并输出未来用于提交 ops 的 payload。"""
70
+ result = self.scrape_product(asin=asin, zip_code=zip_code, save_history=save_history)
71
+ return {
72
+ "payload": self.build_submit_payload(result.snapshot),
73
+ "history_path": str(result.history_path) if result.history_path else None,
74
+ }
75
+
76
+ def search_products(
77
+ self,
78
+ *,
79
+ keyword: str,
80
+ zip_code: str = "10001",
81
+ limit: int = 10,
82
+ ) -> list[AmazonSearchResult]:
83
+ """搜索竞品列表。"""
84
+ return self.scraper.search_products(keyword, max_results=limit, zip_code=zip_code)
85
+
86
+ def load_history(self, asin: str) -> list[dict]:
87
+ """读取本地历史快照。"""
88
+ history_path = self.history_dir / f"{asin.upper()}.jsonl"
89
+ if not history_path.exists():
90
+ return []
91
+ records: list[dict] = []
92
+ for line in history_path.read_text(encoding="utf-8").splitlines():
93
+ if not line.strip():
94
+ continue
95
+ records.append(json.loads(line))
96
+ return records
97
+
98
+ def _append_history(self, snapshot: AmazonProductSnapshot) -> Path:
99
+ """将快照附加写入本地历史文件。"""
100
+ self.history_dir.mkdir(parents=True, exist_ok=True)
101
+ history_path = self.history_dir / f"{snapshot.asin.upper()}.jsonl"
102
+ with history_path.open("a", encoding="utf-8") as fh:
103
+ fh.write(json.dumps(snapshot.to_dict(include_raw=True), ensure_ascii=False))
104
+ fh.write("\n")
105
+ return history_path
@@ -0,0 +1,5 @@
1
+ """amazon 远端传输层。"""
2
+
3
+ from opscli.amazon.transport.client import AmazonOpsClient
4
+
5
+ __all__ = ["AmazonOpsClient"]
@@ -0,0 +1,77 @@
1
+ """amazon 模块远端提交客户端。
2
+
3
+ 当前阶段仅做 API 预留,默认不在 CLI 主流程中使用。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import httpx
9
+
10
+ from opscli.amazon.domain.exceptions import BadRemoteJsonError, RemoteBusinessError, RemoteHttpError, SubmissionConfigError
11
+ from opscli.amazon.domain.models import AmazonProductSnapshot
12
+ from opscli.auth import AuthClient, OPS_URL
13
+ from opscli.auth.config import get_amazon_submit_endpoint
14
+
15
+
16
+ class AmazonOpsClient:
17
+ """统一封装 Amazon 数据提交预留能力。"""
18
+
19
+ def __init__(self, auth_client: AuthClient | None = None) -> None:
20
+ self.auth_client = auth_client or AuthClient()
21
+ self.ops_url = OPS_URL.rstrip("/")
22
+ self.default_submit_endpoint = get_amazon_submit_endpoint()
23
+
24
+ def submit_snapshot(self, snapshot: AmazonProductSnapshot, *, endpoint: str | None = None) -> dict:
25
+ """将抓取结果提交到 ops。
26
+
27
+ 该能力已就绪,但本期默认仅保留作为接口预留。
28
+ """
29
+ submit_endpoint = (endpoint or self.default_submit_endpoint or "").strip()
30
+ if not submit_endpoint:
31
+ raise SubmissionConfigError(
32
+ "未配置 Amazon 提交 endpoint,请在 config.ini 的 [systems] 段配置 amazon_submit_endpoint,或通过 --endpoint 指定"
33
+ )
34
+ if not submit_endpoint.startswith("/"):
35
+ raise SubmissionConfigError("提交 endpoint 必须以 / 开头,并相对 OPS_URL 解析")
36
+
37
+ headers, cookies = self.auth_client.build_request_auth("ops")
38
+ response = httpx.post(
39
+ f"{self.ops_url}{submit_endpoint}",
40
+ json={
41
+ "source": "opscli.amazon",
42
+ "snapshot": snapshot.to_dict(include_raw=True),
43
+ },
44
+ headers=headers,
45
+ cookies=cookies,
46
+ timeout=10,
47
+ )
48
+ return self._parse_response(response)
49
+
50
+ def _parse_response(self, response: httpx.Response) -> dict:
51
+ """统一解析远端响应。"""
52
+ try:
53
+ payload = response.json()
54
+ except Exception as exc:
55
+ raise BadRemoteJsonError("远端返回了无法解析的 JSON") from exc
56
+
57
+ if response.status_code >= 400:
58
+ message = self._extract_message(payload) or f"远端请求失败,HTTP {response.status_code}"
59
+ raise RemoteHttpError(response.status_code, message)
60
+
61
+ if isinstance(payload, dict):
62
+ business_code = payload.get("code")
63
+ if business_code not in (None, 0, 200):
64
+ message = self._extract_message(payload) or "远端业务执行失败"
65
+ raise RemoteBusinessError(business_code, message)
66
+
67
+ if not isinstance(payload, dict):
68
+ raise BadRemoteJsonError("远端返回结构不是 JSON 对象")
69
+ return payload
70
+
71
+ def _extract_message(self, payload: dict) -> str | None:
72
+ """从远端返回中提取错误信息。"""
73
+ for key in ("msg", "message", "error"):
74
+ value = payload.get(key)
75
+ if isinstance(value, str) and value.strip():
76
+ return value.strip()
77
+ return None
@@ -0,0 +1,79 @@
1
+ from pathlib import Path
2
+ from opscli.auth.storage.credential_store import CredentialStore
3
+ from opscli.auth.core.system_registry import SystemRegistry
4
+ from opscli.auth.core.token_manager import TokenManager
5
+ from opscli.auth.config import get_builtin_systems, get_ops_url
6
+
7
+ # 从配置文件读取,不存在则使用默认值(生产环境)
8
+ BUILTIN_SYSTEMS = get_builtin_systems()
9
+ OPS_URL = get_ops_url()
10
+
11
+
12
+ class AuthClient:
13
+ """供 Skill/Python 代码调用的 SDK 入口"""
14
+
15
+ def __init__(self, base_dir: Path | None = None):
16
+ self._store = CredentialStore(base_dir=base_dir)
17
+ self._registry = SystemRegistry(
18
+ base_dir=base_dir,
19
+ builtin_systems=BUILTIN_SYSTEMS,
20
+ )
21
+ self._tm = TokenManager(store=self._store, registry=self._registry)
22
+
23
+ def get_token(self, alias: str) -> str:
24
+ """获取指定系统的有效 JWT(自动刷新)"""
25
+ return self._tm.get_token(alias)
26
+
27
+ def get_session(self, alias: str | None = None) -> str:
28
+ """获取当前登录态对应的 session_id。
29
+
30
+ 参数 `alias` 当前仅用于保持调用语义与 `get_token(alias)` 一致,
31
+ session 本身为全局登录态,不区分具体业务系统。
32
+ """
33
+ _ = alias
34
+ return self._tm.get_session_id()
35
+
36
+ def get_device_code(self) -> str | None:
37
+ """获取当前登录态对应的 device_code。"""
38
+ data = self._store.load() or {}
39
+ return data.get("device_code")
40
+
41
+ def build_request_auth(self, alias: str) -> tuple[dict[str, str], dict[str, str]]:
42
+ """构造统一请求认证参数。
43
+
44
+ 当前统一返回:
45
+ - Authorization Bearer JWT
46
+ - polarisUserToken session cookie
47
+ - opscliDeviceCode cookie(若本地已保存)
48
+ """
49
+ token = self.get_token(alias)
50
+ session_id = self.get_session(alias)
51
+ headers = {"Authorization": f"Bearer {token}"}
52
+ cookies = {"polarisUserToken": session_id}
53
+ device_code = self.get_device_code()
54
+ if device_code:
55
+ cookies["opscliDeviceCode"] = device_code
56
+ return headers, cookies
57
+
58
+ def build_session_headers(self, alias: str | None = None) -> dict[str, str]:
59
+ """构造基于 session 的请求头。"""
60
+ return {"X-Session-Id": self.get_session(alias)}
61
+
62
+ def check_token(self, alias: str) -> dict:
63
+ """检测指定系统 token 是否有效,返回 {valid, expires_in}"""
64
+ return self._tm.check_token(alias)
65
+
66
+ def is_authenticated(self) -> bool:
67
+ """是否已登录(session_id 存在且未过期)"""
68
+ try:
69
+ self._tm.get_session_id()
70
+ return True
71
+ except Exception:
72
+ return False
73
+
74
+ def refresh_token(self, alias: str) -> str:
75
+ """强制刷新指定系统 JWT"""
76
+ return self._tm.refresh_token(alias)
77
+
78
+
79
+ __all__ = ["AuthClient", "BUILTIN_SYSTEMS", "OPS_URL"]