scrape_aliyun_models.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934
  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. # 过滤 Batch 相关行(Batch File / Batch Chat),只保留普通聊天价格
  272. _batch_re = re.compile(r"batch", re.I)
  273. lines = [ln for ln in lines if not _batch_re.search(ln)]
  274. # 记住最近看到的"输入"/"输出"标签行,用于跨干扰行关联
  275. _last_io_label: str | None = None
  276. _io_re = re.compile(r"^(输入|输出)$")
  277. for idx, ln in enumerate(lines):
  278. # 记录最近的规格行(如"视频生成(720P)"),即使本行没有价格
  279. if _spec_re.search(ln):
  280. _prev_spec_label = ln
  281. # 记住最近的纯"输入"或"输出"标签行
  282. if _io_re.match(ln.strip()):
  283. _last_io_label = ln.strip()
  284. matches = price_re.findall(ln)
  285. if not matches:
  286. continue
  287. label = None
  288. first_m = price_re.search(ln)
  289. if first_m:
  290. before = ln[: first_m.start()].strip()
  291. if before:
  292. label = before
  293. if not label:
  294. # 回溯时跳过"原价"、"限时N折"等干扰行,找最近的 io 标签
  295. for j in range(idx - 1, -1, -1):
  296. prev = lines[j].strip()
  297. if not prev or price_re.search(lines[j]):
  298. continue
  299. if prev in ("原价",) or re.match(r"限时\d*\.?\d*折", prev):
  300. continue
  301. label = prev
  302. break
  303. # 如果回溯没找到有效 label,但有记住的 io 标签,用它
  304. if not label and _last_io_label:
  305. label = _last_io_label
  306. # 如果 label 不含视频规格,但前面有规格行,用规格行的文本作为 label
  307. if label and not _spec_re.search(label) and _prev_spec_label:
  308. label = _prev_spec_label
  309. if not label:
  310. label = f"price_{len(items) + 1}"
  311. if label == "原价":
  312. if items and matches:
  313. try:
  314. items[-1]["price_original"] = float(matches[0])
  315. except Exception:
  316. items[-1]["price_original"] = matches[0]
  317. items[-1].setdefault("note", "")
  318. if items[-1]["note"]:
  319. items[-1]["note"] += "; 原价显示"
  320. else:
  321. items[-1]["note"] = "原价显示"
  322. continue
  323. raw = ln
  324. if re.fullmatch(r"输入|输出", label.strip()):
  325. tier_label = _find_nearest_tier_label(lines, idx)
  326. if tier_label:
  327. label = tier_label
  328. entry: Dict = {"label": label.strip(), "raw": raw}
  329. try:
  330. nums = [float(x) for x in matches]
  331. if len(nums) == 1:
  332. entry["price"] = nums[0]
  333. else:
  334. fnums = sorted(nums)
  335. entry["price_current"] = fnums[0]
  336. entry["price_original"] = fnums[-1]
  337. except Exception:
  338. try:
  339. entry["price"] = float(matches[0])
  340. except Exception:
  341. entry["price"] = matches[0]
  342. unit = None
  343. if re.search(r"每千|每 1k|/千|/每千|tokens", raw, re.I):
  344. unit = "元/每千tokens"
  345. unit_m = re.search(r"元\s*/?\s*每[^\n,,;]*", raw)
  346. if unit_m:
  347. unit = unit_m.group(0)
  348. if unit:
  349. entry["unit"] = unit
  350. note = []
  351. if re.search(r"限时|折", raw):
  352. note.append("限时优惠")
  353. if re.search(r"原价", raw):
  354. note.append("原价显示")
  355. if note:
  356. entry["note"] = "; ".join(note)
  357. entry["currency"] = "CNY"
  358. items.append(entry)
  359. return items
  360. def extract_price_block_html(html: str) -> str:
  361. try:
  362. soup = BeautifulSoup(html, "lxml")
  363. except FeatureNotFound:
  364. soup = BeautifulSoup(html, "html.parser")
  365. # 跳过 script/style 标签内的文本节点
  366. node = None
  367. for n in soup.find_all(string=re.compile(r"模型价格")):
  368. if n.parent and n.parent.name in ("script", "style"):
  369. continue
  370. node = n
  371. break
  372. if not node:
  373. return soup.get_text(separator="\n")
  374. ancestor = node.parent
  375. container = ancestor
  376. _has_price_data = re.compile(r"(原价|限时|折|\d+元|元\s*/|每百万|每千)", re.I)
  377. for _ in range(15):
  378. txt = ancestor.get_text(separator="\n")
  379. if _has_price_data.search(txt):
  380. container = ancestor
  381. if ancestor.parent:
  382. ancestor = ancestor.parent
  383. else:
  384. break
  385. return container.get_text(separator="\n")
  386. def extract_price_items_from_html(html: str) -> List[Dict]:
  387. try:
  388. soup = BeautifulSoup(html, "lxml")
  389. except FeatureNotFound:
  390. soup = BeautifulSoup(html, "html.parser")
  391. node = None
  392. for n in soup.find_all(string=re.compile(r"模型价格")):
  393. if n.parent and n.parent.name in ("script", "style"):
  394. continue
  395. node = n
  396. break
  397. if not node:
  398. return []
  399. ancestor = node.parent
  400. container = ancestor
  401. # 向上遍历,找到最深的(范围最大的)包含价格数据的祖先
  402. # 不能碰到第一个匹配就停——pricingHeader 也含 "tokens" 但只是标题栏
  403. _has_price_data = re.compile(r"(原价|限时|折|\d+元|元\s*/|每百万|每千)", re.I)
  404. for _ in range(15):
  405. txt = ancestor.get_text(separator="\n")
  406. if _has_price_data.search(txt):
  407. container = ancestor # 持续更新,取最深的匹配
  408. if ancestor.parent:
  409. ancestor = ancestor.parent
  410. else:
  411. break
  412. price_re = re.compile(r"([0-9]+(?:\.[0-9]+)?)\s*元", re.I)
  413. items: List[Dict] = []
  414. container_text = container.get_text(separator="\n")
  415. items = parse_prices_from_text(container_text)
  416. # 如果解析出的条目 label 太泛(如 "price"),从更广的 HTML 范围搜索规格标签来填充
  417. _spec_re = re.compile(r"[((]\s*\d+[PpKk]\s*[))]")
  418. if items and any(not _spec_re.search(it.get("label", "")) for it in items):
  419. _wide_ancestor = node.parent
  420. for _ in range(10):
  421. if _wide_ancestor.parent:
  422. _wide_ancestor = _wide_ancestor.parent
  423. else:
  424. break
  425. _wide_text = _wide_ancestor.get_text(separator="\n")
  426. _spec_lines = [
  427. ln.strip() for ln in _wide_text.splitlines()
  428. if ln.strip() and _spec_re.search(ln.strip()) and not price_re.search(ln.strip())
  429. ]
  430. if _spec_lines:
  431. _si = 0
  432. for it in items:
  433. lbl = it.get("label", "")
  434. if lbl == "price" or (lbl and not _spec_re.search(lbl)):
  435. if _si < len(_spec_lines):
  436. it["label"] = _spec_lines[_si]
  437. _si += 1
  438. def _postprocess_items(raw_items: List[Dict]) -> List[Dict]:
  439. filtered: List[Dict] = []
  440. for it in raw_items:
  441. raw = it.get("raw", "")
  442. label = it.get("label", "")
  443. unit = it.get("unit", "")
  444. if _is_tool_call_item(label, raw, unit):
  445. continue
  446. # 过滤 Batch File / Batch Chat 相关条目
  447. if re.search(r"batch", raw, re.I) or re.search(r"batch", label, re.I):
  448. continue
  449. if "原价" in raw and filtered:
  450. if "price" in it:
  451. filtered[-1]["price_original"] = it["price"]
  452. elif "price_current" in it and "price_original" in it:
  453. filtered[-1]["price_original"] = it["price_original"]
  454. filtered[-1].setdefault("note", "")
  455. if filtered[-1]["note"]:
  456. filtered[-1]["note"] += "; 原价显示"
  457. else:
  458. filtered[-1]["note"] = "原价显示"
  459. continue
  460. notes = []
  461. discount_match = re.search(r"(限时)?([0-9.]+)\s*折", raw)
  462. if discount_match:
  463. discount = discount_match.group(2)
  464. notes.append(f"限时{discount}折")
  465. else:
  466. if re.search(r"限时|免费", raw) or re.search(r"限时|免费", label):
  467. if re.search(r"免费", raw):
  468. notes.append("限时免费")
  469. else:
  470. notes.append("限时优惠")
  471. if re.search(r"原价", raw):
  472. notes.append("原价显示")
  473. if notes:
  474. it["note"] = "; ".join(notes)
  475. if "unit" not in it:
  476. if re.search(r"每千|tokens|/千|/每千", raw, re.I):
  477. it["unit"] = "元/每千tokens"
  478. else:
  479. um = re.search(r"元\s*/?\s*每[^\n,,;]*", raw)
  480. if um:
  481. it["unit"] = um.group(0)
  482. # 清理 label 前先提取视频规格(如 720P、1080P、4K),避免被清理掉
  483. _video_spec = ""
  484. _spec_m = re.search(r"[((]\s*(\d+[PpKk])\s*[))]", label)
  485. if _spec_m:
  486. _video_spec = _spec_m.group(0) # 保留括号,如 "(720P)"
  487. cleaned_label = re.sub(r"限时[0-9.]*折|限时|免费|原价|\s*元.*", "", label).strip()
  488. cleaned_label = re.sub(r"\s+", " ", cleaned_label).strip()
  489. if not cleaned_label:
  490. cleaned_label = _video_spec or "price"
  491. elif _video_spec and _video_spec not in cleaned_label:
  492. # 清理后保留了部分文字但丢了视频规格,补回去
  493. cleaned_label = cleaned_label + _video_spec
  494. it["label"] = cleaned_label
  495. it["currency"] = "CNY"
  496. filtered.append(it)
  497. return filtered
  498. filtered = _postprocess_items(items)
  499. structured: List[Dict] = []
  500. grouped: Dict[str, Dict[str, Dict]] = {}
  501. for it in filtered:
  502. lbl = it.get("label", "")
  503. raw = it.get("raw", "")
  504. combined = lbl + " " + raw
  505. should_group = False
  506. group = None
  507. if re.search(r"输入", lbl):
  508. should_group = True
  509. group = "input"
  510. elif re.search(r"输出", lbl):
  511. should_group = True
  512. group = "output"
  513. if "tier" in it:
  514. tier_raw = it.get("tier") or ""
  515. tier_key = _normalize_tier_option(tier_raw)
  516. if not group:
  517. if "input" in tier_key.lower():
  518. group = "input"
  519. elif "output" in tier_key.lower():
  520. group = "output"
  521. else:
  522. group = "input"
  523. tier_data = {k: v for k, v in it.items() if k not in ("label", "tier")}
  524. grouped.setdefault(group, {})[tier_key] = tier_data
  525. elif should_group and group:
  526. key = lbl
  527. if group == "input":
  528. key = re.sub(r"^输入", "input", key)
  529. elif group == "output":
  530. key = re.sub(r"^输出", "output", key)
  531. tier_data = {k: v for k, v in it.items() if k not in ("label",)}
  532. grouped.setdefault(group, {})[key] = tier_data
  533. else:
  534. structured.append(it)
  535. for g, mapping in grouped.items():
  536. structured.append({"label": g, "tiers": mapping})
  537. items = structured
  538. if not items:
  539. try:
  540. price_nodes = []
  541. for el in soup.find_all(class_=re.compile(r"price", re.I)):
  542. text = el.get_text(" ", strip=True)
  543. if not re.search(r"[0-9]+(\.[0-9]+)?", text):
  544. continue
  545. price_nodes.append((el, text))
  546. seen = set()
  547. for el, text in price_nodes:
  548. if text in seen:
  549. continue
  550. seen.add(text)
  551. unit_el = el.find_next(class_=re.compile(r"unit", re.I))
  552. unit_text = unit_el.get_text(" ", strip=True) if unit_el else None
  553. label = None
  554. p = el
  555. for _ in range(4):
  556. sib_label = None
  557. parent = p.parent
  558. if parent:
  559. sib_label = parent.find(class_=re.compile(r"label", re.I))
  560. if sib_label and sib_label.get_text(strip=True):
  561. label = sib_label.get_text(" ", strip=True)
  562. break
  563. if parent is None:
  564. break
  565. p = parent
  566. if not label:
  567. prev = el.previous_sibling
  568. steps = 0
  569. while prev and steps < 6:
  570. candidate = None
  571. if isinstance(prev, str) and prev.strip():
  572. candidate = prev.strip()
  573. else:
  574. try:
  575. candidate = prev.get_text(" ", strip=True)
  576. except Exception:
  577. candidate = None
  578. # 允许视频规格文本(如"视频生成(720P)")作为 label,即使包含数字
  579. _is_video_spec = bool(re.search(r"[((]\s*\d+[PpKk]\s*[))]", candidate))
  580. if candidate and (not re.search(r"[0-9]", candidate) or _is_video_spec):
  581. label = candidate
  582. break
  583. prev = prev.previous_sibling
  584. steps += 1
  585. entry = {"label": label or "price", "raw": text, "currency": "CNY"}
  586. try:
  587. entry["price"] = float(re.search(r"([0-9]+(?:\.[0-9]+)?)", text).group(1))
  588. except Exception:
  589. entry["price"] = text
  590. if unit_text:
  591. entry["unit"] = unit_text
  592. items.append(entry)
  593. except Exception:
  594. pass
  595. if items:
  596. items = _postprocess_items(items)
  597. return items
  598. def extract_price_items_global(html: str) -> List[Dict]:
  599. try:
  600. soup = BeautifulSoup(html, "lxml")
  601. except FeatureNotFound:
  602. soup = BeautifulSoup(html, "html.parser")
  603. node = None
  604. for n in soup.find_all(string=re.compile(r"模型价格")):
  605. if n.parent and n.parent.name in ("script", "style"):
  606. continue
  607. node = n
  608. break
  609. if not node:
  610. return []
  611. ancestor = node.parent
  612. for _ in range(6):
  613. txt = ancestor.get_text(separator="\n")
  614. if "元" in txt or re.search(r"\d", txt) or "tokens" in txt.lower():
  615. return parse_prices_from_text(txt)
  616. if ancestor.parent:
  617. ancestor = ancestor.parent
  618. else:
  619. break
  620. return parse_prices_from_text(ancestor.get_text(separator="\n"))
  621. 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:
  622. result = {"url": url, "error": None, "items": []}
  623. with sync_playwright() as p:
  624. launch_kwargs = {"headless": headless}
  625. if executable_path:
  626. launch_kwargs["executable_path"] = executable_path
  627. extra_args_env = os.environ.get("PLAYWRIGHT_EXTRA_ARGS", "")
  628. extra_args = [a.strip() for a in extra_args_env.split(",") if a.strip()]
  629. if extra_args:
  630. launch_kwargs["args"] = extra_args
  631. browser = p.chromium.launch(**launch_kwargs)
  632. # 如果有 api_key,通过额外请求头传递
  633. context_kwargs = {}
  634. if api_key:
  635. context_kwargs["extra_http_headers"] = {"Authorization": f"Bearer {api_key}"}
  636. context = browser.new_context(**context_kwargs)
  637. # 注入登录 Cookie(避免被重定向到登录/免费试用页)
  638. _cookies_str = cookies_str or os.environ.get("ALIYUN_COOKIES", "").strip()
  639. if _cookies_str:
  640. cookies = []
  641. for part in _cookies_str.split(";"):
  642. part = part.strip()
  643. if not part or "=" not in part:
  644. continue
  645. name, _, value = part.partition("=")
  646. cookies.append({
  647. "name": name.strip(),
  648. "value": value.strip(),
  649. "domain": ".aliyun.com",
  650. "path": "/",
  651. })
  652. if cookies:
  653. context.add_cookies(cookies)
  654. print(f"[INFO][price] 已注入 {len(cookies)} 个 Cookie")
  655. page = context.new_page()
  656. network_hits = []
  657. console_logs = []
  658. def _on_console(msg):
  659. try:
  660. console_logs.append({"type": msg.type, "text": msg.text})
  661. except Exception:
  662. pass
  663. def _on_response(resp):
  664. try:
  665. url_r = resp.url
  666. ct = resp.headers.get("content-type", "")
  667. if "application/json" in ct or ct.startswith("text") or "json" in url_r.lower() or "price" in url_r.lower():
  668. try:
  669. body = resp.text()
  670. except Exception:
  671. body = None
  672. snippet = None
  673. if body:
  674. if "元" in body or "price" in body.lower() or "tokens" in body.lower() or "price" in url_r.lower():
  675. snippet = body[:2000]
  676. if snippet:
  677. network_hits.append({"url": url_r, "content_type": ct, "snippet": snippet})
  678. except Exception:
  679. pass
  680. page.on("console", _on_console)
  681. page.on("response", _on_response)
  682. try:
  683. page.goto(url, wait_until="domcontentloaded", timeout=timeout)
  684. except PlaywrightTimeoutError:
  685. try:
  686. page.goto(url, wait_until="load", timeout=timeout)
  687. except Exception as e:
  688. result["error"] = f"导航失败: {e}"
  689. browser.close()
  690. return result
  691. try:
  692. page.wait_for_load_state("networkidle", timeout=20000)
  693. except PlaywrightTimeoutError:
  694. pass
  695. try:
  696. page.wait_for_selector("text=模型价格", timeout=8000)
  697. except PlaywrightTimeoutError:
  698. pass
  699. time.sleep(1.2)
  700. html = page.content()
  701. items = []
  702. try:
  703. items = extract_price_items_from_html(html)
  704. except Exception:
  705. items = []
  706. tiered_items: List[Dict] = []
  707. try:
  708. _ensure_tiered_pricing(page)
  709. tier_options = _get_tier_options(page)
  710. for opt in tier_options:
  711. if not _select_tier_option(page, opt):
  712. continue
  713. html = page.content()
  714. try:
  715. tier_items = extract_price_items_from_html(html)
  716. except Exception:
  717. tier_items = []
  718. for it in tier_items:
  719. it["tier"] = opt
  720. tiered_items.extend(tier_items)
  721. except Exception:
  722. tiered_items = []
  723. if tiered_items:
  724. items = tiered_items
  725. if not items:
  726. try:
  727. page.wait_for_selector("text=/[0-9]+(\\.[0-9]+)?\\s*元/", timeout=8000)
  728. except PlaywrightTimeoutError:
  729. pass
  730. try:
  731. page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
  732. time.sleep(1.0)
  733. html = page.content()
  734. items = extract_price_items_from_html(html)
  735. except Exception:
  736. items = []
  737. if not items:
  738. text_block = extract_price_block_html(html)
  739. if not text_block:
  740. result["error"] = "未找到包含 '模型价格' 的区域,可能需要登录或页面结构不同。"
  741. browser.close()
  742. return result
  743. items = parse_prices_from_text(text_block)
  744. def _build_price_map(parsed_items: List[Dict]) -> Dict:
  745. price_map: Dict = {}
  746. for it in parsed_items:
  747. if isinstance(it, dict) and it.get("tiers") and isinstance(it.get("tiers"), dict):
  748. for tier_key, tier_val in it["tiers"].items():
  749. k = _normalize_tier_option(tier_key)
  750. price_map.setdefault(k, {})
  751. sub_label = tier_val.get("label") or tier_val.get("raw") or k
  752. price_map[k][sub_label] = {k2: v for k2, v in tier_val.items() if k2 not in ("tier", "tiers", "label")}
  753. continue
  754. if it.get("tier"):
  755. tk = _normalize_tier_option(it.get("tier"))
  756. price_map.setdefault(tk, {})
  757. sub_label = it.get("label") or it.get("raw") or tk
  758. price_map[tk][sub_label] = {k: v for k, v in it.items() if k not in ("tier", "label")}
  759. continue
  760. lbl = it.get("label") or it.get("raw") or "price"
  761. if lbl in price_map and not isinstance(price_map[lbl], list):
  762. price_map[lbl] = [price_map[lbl]]
  763. if isinstance(price_map.get(lbl), list):
  764. price_map[lbl].append({k: v for k, v in it.items() if k != "label"})
  765. else:
  766. price_map[lbl] = {k: v for k, v in it.items() if k != "label"}
  767. return price_map
  768. price_map = _build_price_map(items)
  769. result = {"url": url, "error": result.get("error"), "prices": price_map}
  770. browser.close()
  771. return result
  772. def main():
  773. ap = argparse.ArgumentParser(description="爬取阿里云模型市场页面的模型价格(基于 Playwright)")
  774. group = ap.add_mutually_exclusive_group(required=True)
  775. group.add_argument("--url", help="单个模型页面 URL")
  776. group.add_argument("--file", help="包含多个 URL(每行一个)的文件路径")
  777. ap.add_argument("--headful", action="store_true", help="以有头模式打开浏览器(方便调试)")
  778. ap.add_argument("--timeout", type=int, default=20000, help="导航超时(毫秒),默认20000")
  779. ap.add_argument("--browser-path", help="浏览器可执行文件完整路径")
  780. args = ap.parse_args()
  781. urls: List[str] = []
  782. if args.url:
  783. urls = [args.url]
  784. else:
  785. with open(args.file, "r", encoding="utf-8") as f:
  786. urls = [ln.strip() for ln in f if ln.strip()]
  787. exec_path = None
  788. if args.browser_path:
  789. exec_path = args.browser_path
  790. else:
  791. exec_path = os.environ.get("PLAYWRIGHT_EXECUTABLE")
  792. headless = not args.headful
  793. if os.environ.get("PLAYWRIGHT_HEADLESS", "").lower() == "false":
  794. headless = False
  795. results = []
  796. for u in urls:
  797. print(f"抓取: {u}")
  798. res = scrape_model_price(u, headless=headless, timeout=args.timeout, executable_path=exec_path)
  799. results.append(res)
  800. print(json.dumps(results, ensure_ascii=False, indent=2))
  801. if __name__ == "__main__":
  802. main()