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.
- aukeys_opscli-0.0.6.dist-info/METADATA +15 -0
- aukeys_opscli-0.0.6.dist-info/RECORD +92 -0
- aukeys_opscli-0.0.6.dist-info/WHEEL +4 -0
- aukeys_opscli-0.0.6.dist-info/entry_points.txt +2 -0
- opscli/__init__.py +9 -0
- opscli/amazon/__init__.py +13 -0
- opscli/amazon/cli.py +5 -0
- opscli/amazon/client.py +5 -0
- opscli/amazon/commands/__init__.py +1 -0
- opscli/amazon/commands/cli.py +200 -0
- opscli/amazon/domain/__init__.py +25 -0
- opscli/amazon/domain/exceptions.py +73 -0
- opscli/amazon/domain/models.py +74 -0
- opscli/amazon/exceptions.py +21 -0
- opscli/amazon/manager.py +5 -0
- opscli/amazon/models.py +5 -0
- opscli/amazon/parser.py +5 -0
- opscli/amazon/scraper.py +5 -0
- opscli/amazon/scraping/__init__.py +12 -0
- opscli/amazon/scraping/parser.py +66 -0
- opscli/amazon/scraping/scraper.py +315 -0
- opscli/amazon/services/__init__.py +5 -0
- opscli/amazon/services/manager.py +105 -0
- opscli/amazon/transport/__init__.py +5 -0
- opscli/amazon/transport/client.py +77 -0
- opscli/auth/__init__.py +79 -0
- opscli/auth/cli.py +255 -0
- opscli/auth/commands/__init__.py +1 -0
- opscli/auth/commands/cli.py +5 -0
- opscli/auth/config.py +55 -0
- opscli/auth/core/__init__.py +1 -0
- opscli/auth/core/device_flow.py +80 -0
- opscli/auth/core/system_registry.py +89 -0
- opscli/auth/core/token_manager.py +167 -0
- opscli/auth/domain/__init__.py +23 -0
- opscli/auth/domain/exceptions.py +33 -0
- opscli/auth/exceptions.py +23 -0
- opscli/auth/storage/__init__.py +1 -0
- opscli/auth/storage/credential_store.py +163 -0
- opscli/auth/storage/crypto.py +67 -0
- opscli/cli.py +36 -0
- opscli/config.py +3 -0
- opscli/query/__init__.py +6 -0
- opscli/query/cli.py +5 -0
- opscli/query/client.py +5 -0
- opscli/query/commands/__init__.py +1 -0
- opscli/query/commands/cli.py +140 -0
- opscli/query/domain/__init__.py +23 -0
- opscli/query/domain/exceptions.py +73 -0
- opscli/query/domain/models.py +21 -0
- opscli/query/exceptions.py +21 -0
- opscli/query/manager.py +5 -0
- opscli/query/models.py +5 -0
- opscli/query/services/__init__.py +5 -0
- opscli/query/services/manager.py +373 -0
- opscli/query/transport/__init__.py +5 -0
- opscli/query/transport/client.py +57 -0
- opscli/skills/__init__.py +37 -0
- opscli/skills/cli.py +5 -0
- opscli/skills/commands/__init__.py +1 -0
- opscli/skills/commands/cli.py +389 -0
- opscli/skills/detector.py +5 -0
- opscli/skills/discovery/__init__.py +5 -0
- opscli/skills/discovery/detector.py +225 -0
- opscli/skills/domain/__init__.py +23 -0
- opscli/skills/domain/exceptions.py +51 -0
- opscli/skills/domain/models.py +144 -0
- opscli/skills/exceptions.py +5 -0
- opscli/skills/manager.py +5 -0
- opscli/skills/models.py +19 -0
- opscli/skills/services/__init__.py +5 -0
- opscli/skills/services/manager.py +276 -0
- opscli/skills/sync/__init__.py +5 -0
- opscli/skills/sync/updater.py +274 -0
- opscli/skills/templates/ops-amazon/SKILL.md +181 -0
- opscli/skills/templates/ops-amazon/data/VERSION.json +4 -0
- opscli/skills/templates/ops-auth/SKILL.md +466 -0
- opscli/skills/templates/ops-auth/data/VERSION.json +4 -0
- opscli/skills/templates/ops-dataset-query/SKILL.md +691 -0
- opscli/skills/templates/ops-dataset-query/data/VERSION.json +4 -0
- opscli/skills/templates/ops-dataset-query/data/dataset_fields.csv +1 -0
- opscli/skills/templates/ops-dataset-query/data/datasets.csv +1 -0
- opscli/skills/templates/ops-dataset-query/data/query_metadata.json +4 -0
- 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
- opscli/skills/templates/ops-dataset-query/scripts/core.py +140 -0
- opscli/skills/templates/ops-dataset-query/scripts/query.py +145 -0
- opscli/skills/templates/ops-dataset-query/scripts/search.py +36 -0
- opscli/skills/templates/ops-dataset-query/scripts/updater.py +106 -0
- opscli/skills/templates/ops-skills/SKILL.md +494 -0
- opscli/skills/templates/ops-skills/data/VERSION.json +4 -0
- opscli/skills/updater.py +5 -0
- 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,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,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
|
opscli/auth/__init__.py
ADDED
|
@@ -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"]
|