scrape_aliyun_models.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908
  1. #!/usr/bin/env python3
  2. """
  3. Aliyun Model Price Scraper - Final Improved Version
  4. - 使用 Playwright 渲染页面并抓取"模型价格"区域内的价格信息
  5. - 支持单个模型页面 URL,或从文件读取多个 URL
  6. 改进要点:
  7. 1. 能够生成阶梯计费结构:{input: {tier1: {...}, tier2: {...}}, output: {...}}
  8. 2. 优惠标记正确处理:label只保留基础部分,优惠信息放入note字段
  9. 3. 强化过滤:完全排除工具调用价格(包括"千次调用"单位)
  10. 依赖:
  11. pip install playwright beautifulsoup4 lxml
  12. python -m playwright install
  13. 用法示例:
  14. python scrape_aliyun_models.py --url "https://bailian.console.aliyun.com/.../qwen3-max"
  15. python scrape_aliyun_models.py --file urls.txt
  16. 输出: JSON 到 stdout
  17. """
  18. import argparse
  19. import json
  20. import re
  21. import time
  22. import os
  23. from typing import List, Dict, Optional
  24. from bs4 import BeautifulSoup, FeatureNotFound
  25. from bs4.element import Tag
  26. from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
  27. TOOL_CALL_RE = re.compile(
  28. r"调用|工具|接口|api调用|api|次调用|千次调用|/千次|每千次|搜索策略|代码解释|文生图|数据增强|模型推理",
  29. re.I,
  30. )
  31. def _is_tool_call_item(label: str, raw: str, unit: str) -> bool:
  32. label_l = label.lower()
  33. raw_l = raw.lower()
  34. unit_l = unit.lower()
  35. if TOOL_CALL_RE.search(label_l) or TOOL_CALL_RE.search(raw_l) or TOOL_CALL_RE.search(unit_l):
  36. return True
  37. if "千次" in unit_l or "/千" in unit_l or "次调用" in unit_l:
  38. return True
  39. return False
  40. def _find_nearest_tier_label(lines: List[str], idx: int) -> Optional[str]:
  41. tier_re = re.compile(r"(输入|输出).*(<=|>=|<|>|\b\d+\s*k|\d+\s*万|\d+\s*千|\d+\s*tokens?)", re.I)
  42. for step in range(1, 6):
  43. for pos in (idx - step, idx + step):
  44. if pos < 0 or pos >= len(lines):
  45. continue
  46. candidate = lines[pos]
  47. if not candidate or re.search(r"([0-9]+(?:\.[0-9]+)?)\s*元", candidate, re.I):
  48. continue
  49. if tier_re.search(candidate):
  50. return candidate.strip()
  51. return None
  52. def _open_tier_dropdown(page) -> bool:
  53. try:
  54. try:
  55. selector = page.locator(".efm_ant-select-selector, .ant-select-selector").filter(has_text=re.compile(r"输入.*\d+\s*[kK]"))
  56. if selector.count() > 0:
  57. selector.first.click(timeout=3000)
  58. time.sleep(0.5)
  59. return True
  60. except Exception as e:
  61. pass
  62. ok = page.evaluate(
  63. """
  64. () => {
  65. const isVisible = (el) => {
  66. if (!el) return false;
  67. const rect = el.getBoundingClientRect();
  68. const style = window.getComputedStyle(el);
  69. return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
  70. };
  71. const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
  72. const tierRe = /输入.*\d+\s*[kK]/i;
  73. let clickEl = null;
  74. const selectors = Array.from(document.querySelectorAll(
  75. ".efm_ant-select-selector, .ant-select-selector"
  76. ));
  77. for (const el of selectors) {
  78. const txt = norm(el.innerText || el.textContent);
  79. if (tierRe.test(txt) && isVisible(el)) {
  80. clickEl = el;
  81. break;
  82. }
  83. }
  84. if (!clickEl) {
  85. const containers = Array.from(document.querySelectorAll(
  86. ".efm_ant-select, .ant-select"
  87. ));
  88. for (const el of containers) {
  89. const txt = norm(el.innerText || el.textContent);
  90. if (tierRe.test(txt) && isVisible(el)) {
  91. clickEl = el.querySelector(".efm_ant-select-selector, .ant-select-selector") || el;
  92. break;
  93. }
  94. }
  95. }
  96. if (!isVisible(clickEl)) return false;
  97. clickEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
  98. clickEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
  99. clickEl.click();
  100. return true;
  101. }
  102. """
  103. )
  104. time.sleep(0.5)
  105. return bool(ok)
  106. except Exception:
  107. return False
  108. def _normalize_tier_option(opt: str) -> str:
  109. if not opt:
  110. return "unknown"
  111. s = opt.replace('\u00a0', ' ')
  112. m = re.search(r"(\d+\s*k\s*<\s*输入\s*<=\s*\d+\s*k)", s, re.I)
  113. if not m:
  114. m = re.search(r"(输入\s*<=\s*\d+\s*k)", s, re.I)
  115. if not m:
  116. m = re.search(r"(\d+\s*k\s*<\s*输入)", s, re.I)
  117. if m:
  118. key = m.group(1)
  119. key = re.sub(r"\s+", "", key)
  120. key = key.replace("输入", "input").replace("输出", "output")
  121. return key
  122. if "输入" in s or "输出" in s:
  123. nums = re.findall(r"\d+\s*k", s, re.I)
  124. if nums:
  125. joined = "-".join([n.replace(' ', '') for n in nums])
  126. if "输入" in s:
  127. return f"input_{joined}"
  128. return f"output_{joined}"
  129. short = re.sub(r"\s+", " ", s).strip()
  130. return short[:60]
  131. def _get_tier_options(page) -> List[str]:
  132. if not _open_tier_dropdown(page):
  133. return []
  134. try:
  135. page.wait_for_selector(
  136. ".efm_ant-select-dropdown, .ant-select-dropdown",
  137. state="visible", timeout=3000
  138. )
  139. except Exception:
  140. pass
  141. options = []
  142. try:
  143. options = page.evaluate(
  144. """
  145. () => {
  146. const isVisible = (el) => {
  147. const r = el.getBoundingClientRect();
  148. const s = window.getComputedStyle(el);
  149. return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden';
  150. };
  151. const dropdown = Array.from(document.querySelectorAll(
  152. '.efm_ant-select-dropdown, .ant-select-dropdown'
  153. )).find(el => isVisible(el));
  154. if (!dropdown) return [];
  155. const leaves = Array.from(dropdown.querySelectorAll('*'))
  156. .filter(el => isVisible(el) && el.children.length === 0);
  157. const texts = leaves
  158. .map(el => (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim())
  159. .filter(t => t.length > 0 && t.length < 60);
  160. return Array.from(new Set(texts));
  161. }
  162. """
  163. )
  164. options = [t for t in options if re.search(r"输入", t) and re.search(r"\d+\s*[kK]", t)]
  165. except Exception:
  166. options = []
  167. if not options:
  168. try:
  169. options = page.evaluate(
  170. """
  171. () => {
  172. const isVisible = (el) => {
  173. const r = el.getBoundingClientRect();
  174. const s = window.getComputedStyle(el);
  175. return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden';
  176. };
  177. const texts = Array.from(document.querySelectorAll('*'))
  178. .filter(el => isVisible(el) && el.children.length === 0)
  179. .map(el => (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim())
  180. .filter(t => t.length < 60 && /输入/.test(t) && /\\d+\\s*[kK]/.test(t) && /<=|</.test(t));
  181. return Array.from(new Set(texts));
  182. }
  183. """
  184. )
  185. except Exception:
  186. options = []
  187. try:
  188. page.keyboard.press("Escape")
  189. except Exception:
  190. pass
  191. return list(dict.fromkeys(options))
  192. def _select_tier_option(page, option_text: str) -> bool:
  193. if not _open_tier_dropdown(page):
  194. return False
  195. try:
  196. page.wait_for_selector(
  197. ".efm_ant-select-dropdown, .ant-select-dropdown",
  198. state="visible", timeout=2000,
  199. )
  200. except Exception:
  201. return False
  202. try:
  203. try:
  204. option_loc = page.get_by_text(option_text, exact=True).first
  205. option_loc.click(timeout=3000, force=False)
  206. time.sleep(0.6)
  207. return True
  208. except Exception:
  209. pass
  210. clicked = page.evaluate(
  211. """
  212. (opt) => {
  213. const isVisible = (el) => {
  214. if (!el) return false;
  215. const rect = el.getBoundingClientRect();
  216. const style = window.getComputedStyle(el);
  217. return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
  218. };
  219. const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
  220. const nodes = Array.from(document.querySelectorAll(
  221. ".efm_ant-select-item-option-content, [role='option'], .efm_ant-select-item, .ant-select-item"
  222. ));
  223. const target = nodes.find((n) => norm(n.textContent) === opt && isVisible(n));
  224. if (!target) return false;
  225. const clickEl = target.closest(".efm_ant-select-item, [role='option']") || target;
  226. clickEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
  227. clickEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
  228. clickEl.click();
  229. return true;
  230. }
  231. """,
  232. option_text,
  233. )
  234. if clicked:
  235. time.sleep(0.6)
  236. return True
  237. return False
  238. except Exception:
  239. return False
  240. def _ensure_tiered_pricing(page) -> None:
  241. try:
  242. toggle = page.locator("text=阶梯计费").first
  243. if toggle.count() > 0:
  244. toggle.click()
  245. time.sleep(0.3)
  246. except Exception:
  247. pass
  248. def parse_prices_from_text(text: str) -> List[Dict]:
  249. lines = [ln.strip() for ln in text.splitlines()]
  250. lines = [ln for ln in lines if ln]
  251. # 百炼页面价格数字和"元"常在不同 HTML 元素中,被 \n 拆成两行
  252. # 如 "0.9" + "元/每秒" → 合并为 "0.9元/每秒"
  253. joined: list[str] = []
  254. _i = 0
  255. while _i < len(lines):
  256. ln = lines[_i]
  257. if (re.search(r"\d[\d.]*$", ln)
  258. and _i + 1 < len(lines)
  259. and re.match(r"元", lines[_i + 1])):
  260. ln = ln + lines[_i + 1]
  261. _i += 2
  262. else:
  263. _i += 1
  264. joined.append(ln)
  265. lines = joined
  266. items = []
  267. price_re = re.compile(r"([0-9]+(?:\.[0-9]+)?)\s*元", re.I)
  268. # 视频/图像规格标签模式,用于跨行关联(百炼页面规格和价格常在不同行)
  269. _spec_re = re.compile(r"[((]\s*\d+[PpKk]\s*[))]")
  270. _prev_spec_label: str | None = None
  271. for idx, ln in enumerate(lines):
  272. # 记录最近的规格行(如"视频生成(720P)"),即使本行没有价格
  273. if _spec_re.search(ln):
  274. _prev_spec_label = ln
  275. matches = price_re.findall(ln)
  276. if not matches:
  277. continue
  278. label = None
  279. first_m = price_re.search(ln)
  280. if first_m:
  281. before = ln[: first_m.start()].strip()
  282. if before:
  283. label = before
  284. if not label:
  285. for j in range(idx - 1, -1, -1):
  286. if lines[j] and not price_re.search(lines[j]):
  287. label = lines[j]
  288. break
  289. # 如果 label 不含视频规格,但前面有规格行,用规格行的文本作为 label
  290. if label and not _spec_re.search(label) and _prev_spec_label:
  291. label = _prev_spec_label
  292. if not label:
  293. label = f"price_{len(items) + 1}"
  294. if label == "原价":
  295. if items and matches:
  296. try:
  297. items[-1]["price_original"] = float(matches[0])
  298. except Exception:
  299. items[-1]["price_original"] = matches[0]
  300. items[-1].setdefault("note", "")
  301. if items[-1]["note"]:
  302. items[-1]["note"] += "; 原价显示"
  303. else:
  304. items[-1]["note"] = "原价显示"
  305. continue
  306. raw = ln
  307. if re.fullmatch(r"输入|输出", label.strip()):
  308. tier_label = _find_nearest_tier_label(lines, idx)
  309. if tier_label:
  310. label = tier_label
  311. entry: Dict = {"label": label.strip(), "raw": raw}
  312. try:
  313. nums = [float(x) for x in matches]
  314. if len(nums) == 1:
  315. entry["price"] = nums[0]
  316. else:
  317. fnums = sorted(nums)
  318. entry["price_current"] = fnums[0]
  319. entry["price_original"] = fnums[-1]
  320. except Exception:
  321. try:
  322. entry["price"] = float(matches[0])
  323. except Exception:
  324. entry["price"] = matches[0]
  325. unit = None
  326. if re.search(r"每千|每 1k|/千|/每千|tokens", raw, re.I):
  327. unit = "元/每千tokens"
  328. unit_m = re.search(r"元\s*/?\s*每[^\n,,;]*", raw)
  329. if unit_m:
  330. unit = unit_m.group(0)
  331. if unit:
  332. entry["unit"] = unit
  333. note = []
  334. if re.search(r"限时|折", raw):
  335. note.append("限时优惠")
  336. if re.search(r"原价", raw):
  337. note.append("原价显示")
  338. if note:
  339. entry["note"] = "; ".join(note)
  340. entry["currency"] = "CNY"
  341. items.append(entry)
  342. return items
  343. def extract_price_block_html(html: str) -> str:
  344. try:
  345. soup = BeautifulSoup(html, "lxml")
  346. except FeatureNotFound:
  347. soup = BeautifulSoup(html, "html.parser")
  348. # 跳过 script/style 标签内的文本节点
  349. node = None
  350. for n in soup.find_all(string=re.compile(r"模型价格")):
  351. if n.parent and n.parent.name in ("script", "style"):
  352. continue
  353. node = n
  354. break
  355. if not node:
  356. return soup.get_text(separator="\n")
  357. ancestor = node.parent
  358. for _ in range(6):
  359. txt = ancestor.get_text(separator="\n")
  360. if "元" in txt or re.search(r"\d", txt) or "tokens" in txt.lower():
  361. return txt
  362. if ancestor.parent:
  363. ancestor = ancestor.parent
  364. else:
  365. break
  366. return ancestor.get_text(separator="\n")
  367. def extract_price_items_from_html(html: str) -> List[Dict]:
  368. try:
  369. soup = BeautifulSoup(html, "lxml")
  370. except FeatureNotFound:
  371. soup = BeautifulSoup(html, "html.parser")
  372. node = None
  373. for n in soup.find_all(string=re.compile(r"模型价格")):
  374. if n.parent and n.parent.name in ("script", "style"):
  375. continue
  376. node = n
  377. break
  378. if not node:
  379. return []
  380. ancestor = node.parent
  381. container = ancestor
  382. for _ in range(6):
  383. txt = ancestor.get_text(separator="\n")
  384. if "元" in txt or re.search(r"\d", txt) or "tokens" in txt.lower():
  385. container = ancestor
  386. break
  387. if ancestor.parent:
  388. ancestor = ancestor.parent
  389. else:
  390. container = ancestor
  391. break
  392. price_re = re.compile(r"([0-9]+(?:\.[0-9]+)?)\s*元", re.I)
  393. items: List[Dict] = []
  394. container_text = container.get_text(separator="\n")
  395. items = parse_prices_from_text(container_text)
  396. # 如果解析出的条目 label 太泛(如 "price"),从更广的 HTML 范围搜索规格标签来填充
  397. _spec_re = re.compile(r"[((]\s*\d+[PpKk]\s*[))]")
  398. if items and any(not _spec_re.search(it.get("label", "")) for it in items):
  399. _wide_ancestor = node.parent
  400. for _ in range(10):
  401. if _wide_ancestor.parent:
  402. _wide_ancestor = _wide_ancestor.parent
  403. else:
  404. break
  405. _wide_text = _wide_ancestor.get_text(separator="\n")
  406. _spec_lines = [
  407. ln.strip() for ln in _wide_text.splitlines()
  408. if ln.strip() and _spec_re.search(ln.strip()) and not price_re.search(ln.strip())
  409. ]
  410. print(f"[DEBUG] found {_spec_lines!r} spec lines")
  411. if _spec_lines:
  412. _si = 0
  413. for it in items:
  414. lbl = it.get("label", "")
  415. if lbl == "price" or (lbl and not _spec_re.search(lbl)):
  416. if _si < len(_spec_lines):
  417. it["label"] = _spec_lines[_si]
  418. _si += 1
  419. def _postprocess_items(raw_items: List[Dict]) -> List[Dict]:
  420. filtered: List[Dict] = []
  421. for it in raw_items:
  422. raw = it.get("raw", "")
  423. label = it.get("label", "")
  424. unit = it.get("unit", "")
  425. if _is_tool_call_item(label, raw, unit):
  426. continue
  427. if "原价" in raw and filtered:
  428. if "price" in it:
  429. filtered[-1]["price_original"] = it["price"]
  430. elif "price_current" in it and "price_original" in it:
  431. filtered[-1]["price_original"] = it["price_original"]
  432. filtered[-1].setdefault("note", "")
  433. if filtered[-1]["note"]:
  434. filtered[-1]["note"] += "; 原价显示"
  435. else:
  436. filtered[-1]["note"] = "原价显示"
  437. continue
  438. notes = []
  439. discount_match = re.search(r"(限时)?([0-9.]+)\s*折", raw)
  440. if discount_match:
  441. discount = discount_match.group(2)
  442. notes.append(f"限时{discount}折")
  443. else:
  444. if re.search(r"限时|免费", raw) or re.search(r"限时|免费", label):
  445. if re.search(r"免费", raw):
  446. notes.append("限时免费")
  447. else:
  448. notes.append("限时优惠")
  449. if re.search(r"原价", raw):
  450. notes.append("原价显示")
  451. if notes:
  452. it["note"] = "; ".join(notes)
  453. if "unit" not in it:
  454. if re.search(r"每千|tokens|/千|/每千", raw, re.I):
  455. it["unit"] = "元/每千tokens"
  456. else:
  457. um = re.search(r"元\s*/?\s*每[^\n,,;]*", raw)
  458. if um:
  459. it["unit"] = um.group(0)
  460. # 清理 label 前先提取视频规格(如 720P、1080P、4K),避免被清理掉
  461. _video_spec = ""
  462. _spec_m = re.search(r"[((]\s*(\d+[PpKk])\s*[))]", label)
  463. if _spec_m:
  464. _video_spec = _spec_m.group(0) # 保留括号,如 "(720P)"
  465. cleaned_label = re.sub(r"限时[0-9.]*折|限时|免费|原价|\s*元.*", "", label).strip()
  466. cleaned_label = re.sub(r"\s+", " ", cleaned_label).strip()
  467. if not cleaned_label:
  468. cleaned_label = _video_spec or "price"
  469. elif _video_spec and _video_spec not in cleaned_label:
  470. # 清理后保留了部分文字但丢了视频规格,补回去
  471. cleaned_label = cleaned_label + _video_spec
  472. it["label"] = cleaned_label
  473. it["currency"] = "CNY"
  474. filtered.append(it)
  475. return filtered
  476. filtered = _postprocess_items(items)
  477. structured: List[Dict] = []
  478. grouped: Dict[str, Dict[str, Dict]] = {}
  479. for it in filtered:
  480. lbl = it.get("label", "")
  481. raw = it.get("raw", "")
  482. combined = lbl + " " + raw
  483. should_group = False
  484. group = None
  485. if re.search(r"输入", lbl):
  486. should_group = True
  487. group = "input"
  488. elif re.search(r"输出", lbl):
  489. should_group = True
  490. group = "output"
  491. if "tier" in it:
  492. tier_raw = it.get("tier") or ""
  493. tier_key = _normalize_tier_option(tier_raw)
  494. if not group:
  495. if "input" in tier_key.lower():
  496. group = "input"
  497. elif "output" in tier_key.lower():
  498. group = "output"
  499. else:
  500. group = "input"
  501. tier_data = {k: v for k, v in it.items() if k not in ("label", "tier")}
  502. grouped.setdefault(group, {})[tier_key] = tier_data
  503. elif should_group and group:
  504. key = lbl
  505. if group == "input":
  506. key = re.sub(r"^输入", "input", key)
  507. elif group == "output":
  508. key = re.sub(r"^输出", "output", key)
  509. tier_data = {k: v for k, v in it.items() if k not in ("label",)}
  510. grouped.setdefault(group, {})[key] = tier_data
  511. else:
  512. structured.append(it)
  513. for g, mapping in grouped.items():
  514. structured.append({"label": g, "tiers": mapping})
  515. items = structured
  516. if not items:
  517. try:
  518. price_nodes = []
  519. for el in soup.find_all(class_=re.compile(r"price", re.I)):
  520. text = el.get_text(" ", strip=True)
  521. if not re.search(r"[0-9]+(\.[0-9]+)?", text):
  522. continue
  523. price_nodes.append((el, text))
  524. seen = set()
  525. for el, text in price_nodes:
  526. if text in seen:
  527. continue
  528. seen.add(text)
  529. unit_el = el.find_next(class_=re.compile(r"unit", re.I))
  530. unit_text = unit_el.get_text(" ", strip=True) if unit_el else None
  531. label = None
  532. p = el
  533. for _ in range(4):
  534. sib_label = None
  535. parent = p.parent
  536. if parent:
  537. sib_label = parent.find(class_=re.compile(r"label", re.I))
  538. if sib_label and sib_label.get_text(strip=True):
  539. label = sib_label.get_text(" ", strip=True)
  540. break
  541. if parent is None:
  542. break
  543. p = parent
  544. if not label:
  545. prev = el.previous_sibling
  546. steps = 0
  547. while prev and steps < 6:
  548. candidate = None
  549. if isinstance(prev, str) and prev.strip():
  550. candidate = prev.strip()
  551. else:
  552. try:
  553. candidate = prev.get_text(" ", strip=True)
  554. except Exception:
  555. candidate = None
  556. # 允许视频规格文本(如"视频生成(720P)")作为 label,即使包含数字
  557. _is_video_spec = bool(re.search(r"[((]\s*\d+[PpKk]\s*[))]", candidate))
  558. if candidate and (not re.search(r"[0-9]", candidate) or _is_video_spec):
  559. label = candidate
  560. break
  561. prev = prev.previous_sibling
  562. steps += 1
  563. entry = {"label": label or "price", "raw": text, "currency": "CNY"}
  564. try:
  565. entry["price"] = float(re.search(r"([0-9]+(?:\.[0-9]+)?)", text).group(1))
  566. except Exception:
  567. entry["price"] = text
  568. if unit_text:
  569. entry["unit"] = unit_text
  570. items.append(entry)
  571. except Exception:
  572. pass
  573. if items:
  574. items = _postprocess_items(items)
  575. return items
  576. def extract_price_items_global(html: str) -> List[Dict]:
  577. try:
  578. soup = BeautifulSoup(html, "lxml")
  579. except FeatureNotFound:
  580. soup = BeautifulSoup(html, "html.parser")
  581. node = None
  582. for n in soup.find_all(string=re.compile(r"模型价格")):
  583. if n.parent and n.parent.name in ("script", "style"):
  584. continue
  585. node = n
  586. break
  587. if not node:
  588. return []
  589. ancestor = node.parent
  590. for _ in range(6):
  591. txt = ancestor.get_text(separator="\n")
  592. if "元" in txt or re.search(r"\d", txt) or "tokens" in txt.lower():
  593. return parse_prices_from_text(txt)
  594. if ancestor.parent:
  595. ancestor = ancestor.parent
  596. else:
  597. break
  598. return parse_prices_from_text(ancestor.get_text(separator="\n"))
  599. def scrape_model_price(url: str, headless: bool = True, timeout: int = 20000, executable_path: Optional[str] = None, api_key: Optional[str] = None, cookies_str: Optional[str] = None) -> Dict:
  600. result = {"url": url, "error": None, "items": []}
  601. with sync_playwright() as p:
  602. launch_kwargs = {"headless": headless}
  603. if executable_path:
  604. launch_kwargs["executable_path"] = executable_path
  605. extra_args_env = os.environ.get("PLAYWRIGHT_EXTRA_ARGS", "")
  606. extra_args = [a.strip() for a in extra_args_env.split(",") if a.strip()]
  607. if extra_args:
  608. launch_kwargs["args"] = extra_args
  609. browser = p.chromium.launch(**launch_kwargs)
  610. # 如果有 api_key,通过额外请求头传递
  611. context_kwargs = {}
  612. if api_key:
  613. context_kwargs["extra_http_headers"] = {"Authorization": f"Bearer {api_key}"}
  614. context = browser.new_context(**context_kwargs)
  615. # 注入登录 Cookie(避免被重定向到登录/免费试用页)
  616. _cookies_str = cookies_str or os.environ.get("ALIYUN_COOKIES", "").strip()
  617. if _cookies_str:
  618. cookies = []
  619. for part in _cookies_str.split(";"):
  620. part = part.strip()
  621. if not part or "=" not in part:
  622. continue
  623. name, _, value = part.partition("=")
  624. cookies.append({
  625. "name": name.strip(),
  626. "value": value.strip(),
  627. "domain": ".aliyun.com",
  628. "path": "/",
  629. })
  630. if cookies:
  631. context.add_cookies(cookies)
  632. print(f"[INFO][price] 已注入 {len(cookies)} 个 Cookie")
  633. page = context.new_page()
  634. network_hits = []
  635. console_logs = []
  636. def _on_console(msg):
  637. try:
  638. console_logs.append({"type": msg.type, "text": msg.text})
  639. except Exception:
  640. pass
  641. def _on_response(resp):
  642. try:
  643. url_r = resp.url
  644. ct = resp.headers.get("content-type", "")
  645. if "application/json" in ct or ct.startswith("text") or "json" in url_r.lower() or "price" in url_r.lower():
  646. try:
  647. body = resp.text()
  648. except Exception:
  649. body = None
  650. snippet = None
  651. if body:
  652. if "元" in body or "price" in body.lower() or "tokens" in body.lower() or "price" in url_r.lower():
  653. snippet = body[:2000]
  654. if snippet:
  655. network_hits.append({"url": url_r, "content_type": ct, "snippet": snippet})
  656. except Exception:
  657. pass
  658. page.on("console", _on_console)
  659. page.on("response", _on_response)
  660. try:
  661. page.goto(url, wait_until="domcontentloaded", timeout=timeout)
  662. except PlaywrightTimeoutError:
  663. try:
  664. page.goto(url, wait_until="load", timeout=timeout)
  665. except Exception as e:
  666. result["error"] = f"导航失败: {e}"
  667. browser.close()
  668. return result
  669. try:
  670. page.wait_for_load_state("networkidle", timeout=20000)
  671. except PlaywrightTimeoutError:
  672. pass
  673. try:
  674. page.wait_for_selector("text=模型价格", timeout=8000)
  675. except PlaywrightTimeoutError:
  676. pass
  677. time.sleep(1.2)
  678. html = page.content()
  679. items = []
  680. try:
  681. items = extract_price_items_from_html(html)
  682. except Exception:
  683. items = []
  684. tiered_items: List[Dict] = []
  685. try:
  686. _ensure_tiered_pricing(page)
  687. tier_options = _get_tier_options(page)
  688. for opt in tier_options:
  689. if not _select_tier_option(page, opt):
  690. continue
  691. html = page.content()
  692. try:
  693. tier_items = extract_price_items_from_html(html)
  694. except Exception:
  695. tier_items = []
  696. for it in tier_items:
  697. it["tier"] = opt
  698. tiered_items.extend(tier_items)
  699. except Exception:
  700. tiered_items = []
  701. if tiered_items:
  702. items = tiered_items
  703. if not items:
  704. try:
  705. page.wait_for_selector("text=/[0-9]+(\\.[0-9]+)?\\s*元/", timeout=8000)
  706. except PlaywrightTimeoutError:
  707. pass
  708. try:
  709. page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
  710. time.sleep(1.0)
  711. html = page.content()
  712. items = extract_price_items_from_html(html)
  713. except Exception:
  714. items = []
  715. if not items:
  716. text_block = extract_price_block_html(html)
  717. if not text_block:
  718. result["error"] = "未找到包含 '模型价格' 的区域,可能需要登录或页面结构不同。"
  719. browser.close()
  720. return result
  721. items = parse_prices_from_text(text_block)
  722. def _build_price_map(parsed_items: List[Dict]) -> Dict:
  723. price_map: Dict = {}
  724. for it in parsed_items:
  725. if isinstance(it, dict) and it.get("tiers") and isinstance(it.get("tiers"), dict):
  726. for tier_key, tier_val in it["tiers"].items():
  727. k = _normalize_tier_option(tier_key)
  728. price_map.setdefault(k, {})
  729. sub_label = tier_val.get("label") or tier_val.get("raw") or k
  730. price_map[k][sub_label] = {k2: v for k2, v in tier_val.items() if k2 not in ("tier", "tiers", "label")}
  731. continue
  732. if it.get("tier"):
  733. tk = _normalize_tier_option(it.get("tier"))
  734. price_map.setdefault(tk, {})
  735. sub_label = it.get("label") or it.get("raw") or tk
  736. price_map[tk][sub_label] = {k: v for k, v in it.items() if k not in ("tier", "label")}
  737. continue
  738. lbl = it.get("label") or it.get("raw") or "price"
  739. if lbl in price_map and not isinstance(price_map[lbl], list):
  740. price_map[lbl] = [price_map[lbl]]
  741. if isinstance(price_map.get(lbl), list):
  742. price_map[lbl].append({k: v for k, v in it.items() if k != "label"})
  743. else:
  744. price_map[lbl] = {k: v for k, v in it.items() if k != "label"}
  745. return price_map
  746. price_map = _build_price_map(items)
  747. result = {"url": url, "error": result.get("error"), "prices": price_map}
  748. browser.close()
  749. return result
  750. def main():
  751. ap = argparse.ArgumentParser(description="爬取阿里云模型市场页面的模型价格(基于 Playwright)")
  752. group = ap.add_mutually_exclusive_group(required=True)
  753. group.add_argument("--url", help="单个模型页面 URL")
  754. group.add_argument("--file", help="包含多个 URL(每行一个)的文件路径")
  755. ap.add_argument("--headful", action="store_true", help="以有头模式打开浏览器(方便调试)")
  756. ap.add_argument("--timeout", type=int, default=20000, help="导航超时(毫秒),默认20000")
  757. ap.add_argument("--browser-path", help="浏览器可执行文件完整路径")
  758. args = ap.parse_args()
  759. urls: List[str] = []
  760. if args.url:
  761. urls = [args.url]
  762. else:
  763. with open(args.file, "r", encoding="utf-8") as f:
  764. urls = [ln.strip() for ln in f if ln.strip()]
  765. exec_path = None
  766. if args.browser_path:
  767. exec_path = args.browser_path
  768. else:
  769. exec_path = os.environ.get("PLAYWRIGHT_EXECUTABLE")
  770. headless = not args.headful
  771. if os.environ.get("PLAYWRIGHT_HEADLESS", "").lower() == "false":
  772. headless = False
  773. results = []
  774. for u in urls:
  775. print(f"抓取: {u}")
  776. res = scrape_model_price(u, headless=headless, timeout=args.timeout, executable_path=exec_path)
  777. results.append(res)
  778. print(json.dumps(results, ensure_ascii=False, indent=2))
  779. if __name__ == "__main__":
  780. main()