yaoex_snapshot_crawl.py 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509
  1. import base64
  2. import hashlib
  3. import json
  4. import random
  5. import signal
  6. import socket
  7. import sys
  8. import time
  9. from pathlib import Path
  10. from urllib.parse import quote
  11. import requests
  12. from Crypto.Cipher import AESimport base64
  13. import hashlib
  14. import json
  15. import random
  16. import signal
  17. import socket
  18. import sys
  19. import time
  20. from pathlib import Path
  21. from urllib.parse import quote
  22. import requests
  23. from Crypto.Cipher import AES
  24. from DrissionPage import ChromiumPage, ChromiumOptions
  25. from commons.Logger import get_spider_logger
  26. from commons.scheduler import CrawlerScheduler
  27. from commons.config import YYC_ACCOUNT, YYC_DEVICE_ID
  28. from oss_upload.oss_upload import AliyunOSSUploader
  29. from pipelines.drug_pipelines import DrugPipeline
  30. from area_info.city_name_to_id import get_city
  31. from Crypto.Util.Padding import unpad
  32. logger = get_spider_logger("yaoex_snapshot")
  33. # 平台常量(API 调度用)
  34. PLATFORM_ID = 6
  35. HEARTBEAT_INTERVAL_SECONDS = 60
  36. TASK_API_BASE = "http://192.168.2.246:8080/api/collect_task"
  37. TASK_API_TOKEN = "zhijiayun_crawler_2026"
  38. TASK_REPORT_URL = TASK_API_BASE + "/report"
  39. CAPTCHA_TOKEN = "zPzmt1mG1ouCU6GTzsZN2Lmm8pdZypapPcLJTBRETco"
  40. CAPTCHA_API_URL = "http://api.jfbym.com/api/YmServer/customApi"
  41. chrome_path = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
  42. # 项目根目录 → spiders/yaoex(与从哪执行脚本无关)
  43. PROJECT_ROOT = Path(__file__).resolve().parents[2]
  44. YAOEX_SPIDER_DIR = PROJECT_ROOT / "spiders" / "yaoex"
  45. SLIDER_OFFSET_FIX = 10
  46. DETAIL_GET_TIMEOUT = 15
  47. DETAIL_URL_WAIT = 10
  48. DETAIL_DOM_WAIT = 8
  49. DETAIL_NAV_RETRIES = 3
  50. DETAIL_CONTENT_XPATH = "xpath://div[contains(@class,'yaoex-product-detail__content')]"
  51. REQUEST_RETRY_COUNT = 3
  52. REQUEST_TIMEOUT_SEC = 20
  53. NOT_PRODUCT_BREAK = 15
  54. headers = {
  55. "Accept": "application/json, text/plain, */*",
  56. "Accept-Language": "zh-CN,zh;q=0.9",
  57. "Connection": "keep-alive",
  58. "Content-Type": "application/x-www-form-urlencoded",
  59. "Origin": "https://mall.yaoex.com",
  60. "Referer": "https://mall.yaoex.com/",
  61. "Sec-Fetch-Dest": "empty",
  62. "Sec-Fetch-Mode": "cors",
  63. "Sec-Fetch-Site": "cross-site",
  64. "User-Agent": (
  65. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
  66. "AppleWebKit/537.36 (KHTML, like Gecko) "
  67. "Chrome/146.0.0.0 Safari/537.36"
  68. ),
  69. "X-Request-Agent": "Axios",
  70. "X-Requested-With": "XMLHttpRequest",
  71. "sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
  72. "sec-ch-ua-mobile": "?0",
  73. "sec-ch-ua-platform": '"Windows"',
  74. }
  75. class YaoexSnapshotCrawl:
  76. def __init__(self, drug_dict=None):
  77. self.driver = None
  78. self.platform = 6
  79. self.pipeline = DrugPipeline("yaoex")
  80. self.task_dict = drug_dict or {}
  81. self.ossuploader = AliyunOSSUploader()
  82. self.start_page = 1
  83. self.end_page = 1
  84. self.account_name = YYC_ACCOUNT.get("username", "yyc_default")
  85. self._shop_cache = {}
  86. self._register_signal_handler()
  87. if self.task_dict:
  88. self.get_product_data()
  89. self.success = True
  90. self.is_not_product = 0
  91. self.user_id = YYC_ACCOUNT["user_id"]
  92. self.token = YYC_ACCOUNT["token"]
  93. self.scheduler = CrawlerScheduler(YYC_DEVICE_ID, str(PLATFORM_ID), heartbeat_interval=HEARTBEAT_INTERVAL_SECONDS)
  94. def get_product_data(self):
  95. self.task_id = self.task_dict["id"]
  96. self.company_id = self.task_dict["company_id"]
  97. self.product = self.task_dict["product_name"]
  98. self.product_desc = self.task_dict.get("product_specs", "")
  99. self.brand = self.task_dict.get("product_brand", "")
  100. self.product_keyword = self.task_dict.get("product_keyword", "")
  101. self.collect_task_id = self.task_dict.get("collect_task_id", "")
  102. self.sampling_cycle = self.task_dict.get("sampling_cycle", "")
  103. self.sampling_start_time = self.task_dict.get("sampling_start_time", "")
  104. self.sampling_end_time = self.task_dict.get("sampling_end_time", "")
  105. self.collect_equipment_id = self.task_dict.get("collect_equipment_id", "")
  106. self.account_id = self.task_dict.get("collect_equipment_account_id", "")
  107. self.collect_region_id = self.task_dict.get("collect_region_id", "")
  108. self.collect_round = self.task_dict.get("collect_round", 1)
  109. self.start_page = self._parse_page(self.task_dict.get("start_page"), 1)
  110. self.end_page = self._parse_page(self.task_dict.get("end_page"), 100)
  111. # ---------- API report helpers ----------
  112. def _send_report(self, params: dict, log_msg: str):
  113. try:
  114. resp = requests.post(
  115. TASK_REPORT_URL, json=params, timeout=10,
  116. headers={'X-Crawler-Token': TASK_API_TOKEN},
  117. )
  118. if resp.status_code == 200:
  119. try:
  120. data = resp.json()
  121. if data.get("code") == "success":
  122. logger.info("✅ 回告成功:%s", log_msg)
  123. return "success"
  124. else:
  125. msg = data.get("msg", "")
  126. if "已达平台限额" in msg or "限额" in msg:
  127. logger.warning("⛔ 回告接口返回限额错误:code=%s, msg=%s", data.get('code'), msg)
  128. return "limit_reached"
  129. else:
  130. logger.warning("⚠️ 回告接口返回错误:code=%s, msg=%s", data.get('code'), msg)
  131. return "error"
  132. except ValueError:
  133. logger.warning("⚠️ 回告响应非 JSON:%s", resp.text[:200])
  134. return "error"
  135. else:
  136. logger.warning("⚠️ 回告 HTTP %s", resp.status_code)
  137. return "error"
  138. except Exception as e:
  139. logger.error("❌ 回告失败:%s,错误:%s", log_msg, str(e))
  140. return "error"
  141. def _report_start(self):
  142. payload = {
  143. "task_id": self.task_id,
  144. "platform": str(self.platform),
  145. "username": YYC_DEVICE_ID,
  146. "current_page": 0,
  147. "total_pages": 0,
  148. "is_finished": 0,
  149. }
  150. logger.info("上报开始 task_id=%s", self.task_id)
  151. return self._send_report(payload, "开始上报(is_finished=0)")
  152. def _report_progress(self, current_page: int, total_pages: int = 0):
  153. payload = {
  154. "task_id": self.task_id,
  155. "platform": str(self.platform),
  156. "username": YYC_DEVICE_ID,
  157. "current_page": current_page,
  158. "total_pages": total_pages,
  159. "is_finished": 0,
  160. }
  161. return self._send_report(payload, f"进度上报 page={current_page}")
  162. def _report_end(self, success: bool, real_count: int, page_no: int = 0, total_pages: int = 0):
  163. payload = {
  164. "task_id": self.task_id,
  165. "platform": str(self.platform),
  166. "username": YYC_DEVICE_ID,
  167. "current_page": page_no,
  168. "total_pages": total_pages if total_pages > 0 else page_no,
  169. "crawled_count": real_count,
  170. "is_finished": 1 if success else 0,
  171. }
  172. logger.info("上报结束 task_id=%s payload=%s", self.task_id, payload)
  173. self._send_report(payload, "结束上报(is_finished=1)")
  174. @staticmethod
  175. def _parse_page(value, default=1):
  176. try:
  177. page = int(value)
  178. return page if page >= 1 else default
  179. except (TypeError, ValueError):
  180. return default
  181. def _register_signal_handler(self):
  182. def handler(signum, frame):
  183. logger.info("收到退出信号,正在关闭浏览器...")
  184. self._quit_browser()
  185. sys.exit(0)
  186. signal.signal(signal.SIGINT, handler)
  187. if hasattr(signal, "SIGTERM"):
  188. signal.signal(signal.SIGTERM, handler)
  189. @staticmethod
  190. def _timestamp_ms() -> str:
  191. return str(int(time.time() * 1000))
  192. def _quit_browser(self):
  193. if self.driver:
  194. try:
  195. self.driver.quit()
  196. except Exception:
  197. pass
  198. self.driver = None
  199. @staticmethod
  200. def _get_free_port():
  201. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  202. s.bind(("127.0.0.1", 0))
  203. return s.getsockname()[1]
  204. def _resolve_browser_profile_dir(self):
  205. """浏览器数据目录: <项目根>/spiders/yaoex/<账号>"""
  206. profile_dir = YAOEX_SPIDER_DIR / self.account_name
  207. # 仅兼容历史误路径,新建不再使用 chrome_profile
  208. legacy_nested = YAOEX_SPIDER_DIR / "spiders" / "yaoex" / self.account_name
  209. legacy_chrome_profile = YAOEX_SPIDER_DIR / "chrome_profile" / self.account_name
  210. for candidate in (profile_dir, legacy_nested, legacy_chrome_profile):
  211. if (candidate / "Default").is_dir() or (candidate / "Local State").is_file():
  212. logger.info("使用已有浏览器配置目录: %s", candidate)
  213. return candidate
  214. profile_dir.mkdir(parents=True, exist_ok=True)
  215. logger.info("新建浏览器配置目录: %s", profile_dir)
  216. return profile_dir
  217. def init_browser(self):
  218. co = ChromiumOptions().set_browser_path(chrome_path)
  219. debug_port = self._get_free_port()
  220. profile_dir = self._resolve_browser_profile_dir()
  221. profile_dir.mkdir(parents=True, exist_ok=True)
  222. co.set_user_data_path(str(profile_dir))
  223. logger.info("浏览器用户目录(绝对路径): %s", profile_dir.resolve())
  224. co.set_local_port(debug_port)
  225. co.set_argument(f"--remote-debugging-port={debug_port}")
  226. co.set_argument("--remote-debugging-address=127.0.0.1")
  227. co.set_argument("--disable-dev-shm-usage")
  228. co.set_argument("--start-maximized")
  229. co.set_argument("--no-first-run")
  230. co.set_argument("--no-default-browser-check")
  231. self.driver = ChromiumPage(co)
  232. def _is_logged_in(self):
  233. return bool(self.driver.ele("xpath=//a[@id='logout']", timeout=5))
  234. def _call_captcha_api(self, image_bytes):
  235. try:
  236. b64 = base64.b64encode(image_bytes).decode()
  237. resp = requests.post(
  238. CAPTCHA_API_URL,
  239. json={"token": CAPTCHA_TOKEN, "type": "22222", "image": b64},
  240. headers={"Content-Type": "application/json"},
  241. timeout=15,
  242. ).json()
  243. logger.info("验证码 API 返回: %s", resp)
  244. return resp["data"]["data"]
  245. except Exception as e:
  246. logger.exception("验证码识别失败: %s", e)
  247. return None
  248. @staticmethod
  249. def _generate_human_track(distance):
  250. tracks = []
  251. current = 0
  252. mid = distance * 0.7
  253. t = 0.2
  254. v = 0
  255. move_points = []
  256. while current < mid:
  257. a = random.uniform(2, 4)
  258. v0 = v
  259. v = v0 + a * t
  260. move = v0 * t + 0.5 * a * t * t
  261. current += move
  262. move_points.append(move)
  263. while current < distance:
  264. a = -random.uniform(0.5, 1.5)
  265. v0 = v
  266. v = v0 + a * t
  267. if v < 0.5:
  268. v = 0.5
  269. move = v0 * t + 0.5 * a * t * t
  270. current += move
  271. move_points.append(move)
  272. total_points = len(move_points)
  273. for i, move in enumerate(move_points):
  274. y_offset = random.randint(-2, 2) if i % random.randint(2, 4) == 0 else 0
  275. if i < total_points * 0.3:
  276. duration = random.uniform(0.01, 0.03)
  277. elif i > total_points * 0.7:
  278. duration = random.uniform(0.03, 0.08)
  279. else:
  280. duration = random.uniform(0.02, 0.05)
  281. if random.random() < 0.05:
  282. duration += random.uniform(0.05, 0.1)
  283. tracks.append((move, y_offset, duration))
  284. if random.random() < 0.7:
  285. tracks.append((-random.randint(1, 3), 0, 0.05))
  286. return tracks
  287. def _simulate_slider_drag(self, slider_element, target_distance):
  288. self.driver.actions.move_to(slider_element).hold()
  289. for offset_x, offset_y, duration in self._generate_human_track(target_distance):
  290. self.driver.actions.move(offset_x, offset_y, duration=duration / 1000)
  291. self.driver.actions.release()
  292. def _solve_slider_if_present(self):
  293. modal = self.driver.ele("xpath://div[@class='yidun_modal']", timeout=3)
  294. if not modal:
  295. return True
  296. logger.info("检测到滑块验证码,开始处理")
  297. jpg_bytes = modal.get_screenshot(as_bytes="jpg")
  298. distance = self._call_captcha_api(jpg_bytes)
  299. if distance is None:
  300. return False
  301. slider = self.driver.ele(
  302. "xpath://div[contains(@class,'yidun_slider--hover')]", timeout=5
  303. )
  304. if not slider:
  305. logger.error("未找到滑块元素")
  306. return False
  307. self._simulate_slider_drag(slider, float(distance) + SLIDER_OFFSET_FIX)
  308. time.sleep(3)
  309. return True
  310. def login(self):
  311. self.driver.get("https://mall.yaoex.com/login", timeout=15)
  312. self.driver.wait.doc_loaded(timeout=10)
  313. input_name = self.driver.ele("xpath://input[@name='username']", timeout=5)
  314. if not input_name:
  315. logger.error("未找到用户名输入框")
  316. return False
  317. input_name.input(YYC_ACCOUNT["username"])
  318. time.sleep(random.uniform(1.2, 2.0))
  319. input_pass = self.driver.ele("xpath://input[@name='password']", timeout=5)
  320. if not input_pass:
  321. logger.error("未找到密码输入框")
  322. return False
  323. input_pass.input(YYC_ACCOUNT["password"])
  324. time.sleep(random.uniform(1.2, 2.0))
  325. geetest_click = self.driver.ele(
  326. "xpath=//div[contains(@class,'geetest_btn_click')]", timeout=3
  327. )
  328. if geetest_click:
  329. geetest_click.click()
  330. time.sleep(1.5)
  331. login_button = self.driver.ele("xpath://input[@id='login-btn']", timeout=5)
  332. if not login_button:
  333. logger.error("未找到登录按钮")
  334. return False
  335. login_button.click()
  336. self.driver.wait.doc_loaded(timeout=10)
  337. time.sleep(2)
  338. if not self._solve_slider_if_present():
  339. return False
  340. return self._is_logged_in()
  341. def decrypt_price(self, ciphertext_b64):
  342. if not ciphertext_b64 or not str(ciphertext_b64).strip():
  343. return ""
  344. _KEY_FIXED = "GDLSAUO1KUMIIBCE"
  345. if not self.user_id:
  346. key = _KEY_FIXED.encode("utf-8")
  347. else:
  348. uid = str(self.user_id)[:6].rjust(6, "0")
  349. key = (_KEY_FIXED[:10] + uid).encode("utf-8")
  350. raw = base64.b64decode(ciphertext_b64.strip())
  351. cipher = AES.new(key, AES.MODE_ECB)
  352. plain = unpad(cipher.decrypt(raw), AES.block_size)
  353. return plain.decode("utf-8")
  354. def _post_with_retry(self, url, payload, retries=REQUEST_RETRY_COUNT, timeout=REQUEST_TIMEOUT_SEC):
  355. last_err = None
  356. for attempt in range(1, retries + 1):
  357. try:
  358. resp = requests.post(
  359. url,
  360. headers=headers,
  361. data=payload,
  362. timeout=timeout,
  363. )
  364. resp.raise_for_status()
  365. return resp
  366. except Exception as e:
  367. last_err = e
  368. if attempt < retries:
  369. logger.warning("请求失败,第%s/%s次重试: %s", attempt, retries, e)
  370. time.sleep(min(2 * attempt, 5))
  371. else:
  372. logger.error("请求失败,已达最大重试次数(%s): %s", retries, e)
  373. raise last_err
  374. def _shop_payload(self, enterprise_id):
  375. return {
  376. "traderName": "yaoex_pc",
  377. "trader": "pc",
  378. "closesignature": "yes",
  379. "signature_method": "md5",
  380. "signature": "****",
  381. "timestamp": self._timestamp_ms(),
  382. "token": self.token,
  383. "userToken": self.token,
  384. "enterpriseId": enterprise_id,
  385. }
  386. def _list_payload(self, keyword, page):
  387. return {
  388. "traderName": "yaoex_pc",
  389. "trader": "pc",
  390. "closesignature": "yes",
  391. "signature_method": "md5",
  392. "signature": "****",
  393. "timestamp": self._timestamp_ms(),
  394. "token": self.token,
  395. "userToken": self.token,
  396. "userId": self.user_id,
  397. "roleId": "101",
  398. "userType": "下游客户",
  399. "buyerCode": self.user_id,
  400. "nowPage": str(page),
  401. "per": "20",
  402. "keyword": keyword,
  403. "catSearchId": "",
  404. "specs": "",
  405. "factoryIds": "",
  406. "sellerCodes": "",
  407. "sellerFilterMode": "0",
  408. "sortColumn": "default",
  409. "sortMode": "default",
  410. "ver": "1",
  411. "stock_mode": "1",
  412. "showExtendCard": "true",
  413. "needDinnerPrice": "true",
  414. "limitStart": "",
  415. "limitEnd": "",
  416. "deadLineStart": "",
  417. "deadLineEnd": "",
  418. "filterDtos": "",
  419. "showWholePurchase": "true",
  420. }
  421. def fetch_list_page(self, keyword, page):
  422. list_url = "https://gateway-b2b.fangkuaiyi.com/home/search/homeSearchList"
  423. resp = self._post_with_retry(list_url, self._list_payload(keyword, page))
  424. data = resp.json()
  425. recall_status = data.get("data", {}).get("recallStatus", 0)
  426. data_block = data.get("data", {}) or {}
  427. # 尝试提取总页数(常见字段名依次尝试)
  428. total_pages = 0
  429. for key in ("totalPage", "totalPages", "pageCount", "total", "totalCount", "totalNum", "totalSize"):
  430. val = data_block.get(key)
  431. if val is not None and int(val) > 0:
  432. total_pages = int(val)
  433. break
  434. if total_pages:
  435. logger.info("📊 列表 API 检测到总页数:%s (page=%s)", total_pages, page)
  436. else:
  437. logger.debug("列表 API 未返回总页数字段,data keys: %s", list(data_block.keys())[:20])
  438. if int(recall_status) == 1:
  439. return data_block.get("shopProducts", []) or [], total_pages
  440. else:
  441. return [], 0
  442. def fetch_shop(self, seller_code):
  443. detail_url = "https://gateway-b2b.fangkuaiyi.com/ycapp/shop/enterpriseQualification"
  444. resp = self._post_with_retry(detail_url, self._shop_payload(seller_code))
  445. shop_res = resp.json().get("data", {})
  446. base_info = shop_res.get("baseInfo", {})
  447. return base_info.get("address", ""), base_info.get("enterpriseName", "")
  448. def _get_shop_info(self, seller_code):
  449. if seller_code in self._shop_cache:
  450. return self._shop_cache[seller_code]
  451. try:
  452. shop_info = self.fetch_shop(seller_code)
  453. except Exception as e:
  454. logger.warning("fetch_shop 失败 seller_code=%s: %s", seller_code, e)
  455. shop_info = ("", "")
  456. self._shop_cache[seller_code] = shop_info
  457. return shop_info
  458. def _current_url(self):
  459. try:
  460. return self.driver.url or ""
  461. except Exception:
  462. return ""
  463. def _url_has_product(self, spu_code, seller_code):
  464. url = self._current_url()
  465. spu_code = str(spu_code or "")
  466. seller_code = str(seller_code or "")
  467. if spu_code and seller_code:
  468. return spu_code in url and seller_code in url
  469. return bool(spu_code and spu_code in url)
  470. def _wait_detail_ready(self, spu_code, seller_code, timeout=DETAIL_URL_WAIT):
  471. deadline = time.time() + timeout
  472. while time.time() < deadline:
  473. if self._url_has_product(spu_code, seller_code):
  474. if self.driver.ele(DETAIL_CONTENT_XPATH, timeout=1):
  475. time.sleep(0.3)
  476. return True
  477. time.sleep(0.4)
  478. return False
  479. def _build_detail_url(self, item):
  480. if not item.get("productId") and item.get("groupBuyProductDto"):
  481. item = item.get("groupBuyProductDto") or {}
  482. spu_code = item.get("spuCode", "")
  483. seller_code = item.get("sellerCode", "")
  484. group_buying_id = item.get("groupBuyingId", "")
  485. p_json = json.dumps(
  486. {"id": group_buying_id, "s": seller_code, "sp": spu_code},
  487. separators=(",", ":"),
  488. )
  489. detail_url = (
  490. f"https://mall.yaoex.com/groupBuying/#/productDetail?p={quote(p_json)}"
  491. )
  492. else:
  493. seller_code = item.get("sellerCode")
  494. spu_code = item.get("spuCode")
  495. detail_url = (
  496. f"https://mall.yaoex.com/v2/product/#/spuCode/{spu_code}/sellerCode/{seller_code}"
  497. )
  498. return item, detail_url, spu_code, seller_code
  499. def _goto_detail_page(self, detail_url, spu_code, seller_code):
  500. """get 后 refresh 一次,让 SPA 按当前 URL 重新渲染详情。"""
  501. for attempt in range(1, DETAIL_NAV_RETRIES + 1):
  502. try:
  503. self.driver.get(detail_url, timeout=DETAIL_GET_TIMEOUT)
  504. time.sleep(0.5)
  505. self.driver.refresh()
  506. time.sleep(2)
  507. ele = self.driver.ele("xpath=//div[@class='yaoex-product-detail__product-detail']")
  508. if ele:
  509. return True
  510. else:
  511. continue
  512. except Exception as e:
  513. logger.warning(
  514. "跳转详情异常 spu=%s seller=%s attempt=%s: %s",
  515. spu_code, seller_code, attempt, e,
  516. )
  517. time.sleep(random.uniform(0.8, 1.5))
  518. return False
  519. def _take_snapshot(self, upload_key):
  520. time.sleep(1)
  521. try:
  522. detail_ele = self.driver.ele(DETAIL_CONTENT_XPATH, timeout=2)
  523. if detail_ele:
  524. jpg_bytes = detail_ele.get_screenshot(as_bytes="jpg")
  525. else:
  526. jpg_bytes = self.driver.get_screenshot(as_bytes="jpg")
  527. if not jpg_bytes:
  528. logger.warning("截图为空 upload_key=%s", upload_key)
  529. return ""
  530. img_url = self.ossuploader.upload_from_bytes(jpg_bytes, str(upload_key))
  531. except Exception:
  532. logger.exception("截图或 OSS 上传失败 upload_key=%s", upload_key)
  533. return ""
  534. if not img_url:
  535. logger.warning("OSS 未返回有效地址 upload_key=%s", upload_key)
  536. return ""
  537. logger.info("截图上传完成 upload_key=%s url=%s", upload_key, img_url)
  538. time.sleep(random.uniform(1, 2))
  539. return img_url
  540. def parse_product(self, item, detail_url, snap_url):
  541. seller_code = item.get("sellerCode")
  542. spu_code = item.get("spuCode")
  543. name_part = (item.get("productName") or "").strip()
  544. short_part = (item.get("shortName") or "").strip()
  545. product_name = f"{name_part} {short_part}".strip()
  546. shop_url = f"https://mall.yaoex.com/v2/store/#/detail/{seller_code}/home"
  547. company_address, company_name = self._get_shop_info(seller_code)
  548. address = item.get("cityName", "")
  549. city_id = province_id = city = province = ""
  550. if address:
  551. city_id, province_id, city, province = get_city(address.split("市")[0])
  552. price = self.decrypt_price(item.get("price"))
  553. hash_text = f"{seller_code}_{spu_code}_{price}"
  554. item_id = hashlib.md5(hash_text.encode("utf-8")).hexdigest()
  555. is_sold_out = 1 if "商品已售罄" in (item.get("statusDescription") or "") else 0
  556. shop_name = item.get("storeName") or item.get("shopName")
  557. anonymous_store_name = ""
  558. if shop_name == "预约配送中心":
  559. anonymous_store_name = item.get("supplyName", "")
  560. inventory = item.get("currentInventory") or item.get("stockCount")
  561. now = time.strftime("%Y-%m-%d %H:%M:%S")
  562. return {
  563. "platform": self.platform,
  564. "item_id": item_id,
  565. "enterprise_id": self.company_id,
  566. "product_name": product_name,
  567. "spec": item.get("spec"),
  568. "one_price": "",
  569. "detail_url": detail_url,
  570. "shop_name": shop_name,
  571. "anonymous_store_name": anonymous_store_name,
  572. "shop_url": shop_url,
  573. "city_name": city,
  574. "city_id": city_id,
  575. "province_name": province,
  576. "province_id": province_id,
  577. "shipment_city_name": "",
  578. "shipment_city_id": "",
  579. "shipment_province_name": "",
  580. "shipment_province_id": "",
  581. "area_info": company_address or "",
  582. "factory_name": item.get("factoryName"),
  583. "scrape_date": time.strftime("%Y-%m-%d"),
  584. "price": price,
  585. "sales": "",
  586. "stock_count": inventory,
  587. "snapshot_url": snap_url,
  588. "approval_num": item.get("approvalNum"),
  589. "produced_time": item.get("productionTime"),
  590. "deadline": item.get("deadLine"),
  591. "update_time": now,
  592. "insert_time": now,
  593. "number": 1,
  594. "product_brand": self.brand or "",
  595. "collect_task_id": self.collect_task_id,
  596. "search_name": self.product,
  597. "company_name": company_name,
  598. "collect_config_info": json.dumps(
  599. {
  600. "sampling_cycle": self.sampling_cycle,
  601. "sampling_start_time": self.sampling_start_time,
  602. "sampling_end_time": self.sampling_end_time,
  603. }
  604. ),
  605. "account_id": self.account_id,
  606. "collect_region_id": self.collect_region_id,
  607. "collect_round": self.collect_round,
  608. "is_sold_out": is_sold_out,
  609. }
  610. def search(self):
  611. self.driver.get("https://mall.yaoex.com/", timeout=15)
  612. self.driver.wait.doc_loaded(timeout=10)
  613. if not self._is_logged_in():
  614. if not self.login():
  615. logger.error("登录失败")
  616. return False
  617. keyword = self.product
  618. if self.brand:
  619. keyword = (self.brand + " " + self.product).strip()
  620. if self.product_desc:
  621. keyword = (keyword + " " + self.product_desc).strip()
  622. for page in range(self.start_page, self.end_page + 1):
  623. if self.scheduler.end:
  624. logger.warning("收到停止信号,终止采集")
  625. break
  626. logger.info("正在爬取 %s %s,第%s页", self.brand, self.product, page)
  627. page_items, api_total_pages = self.fetch_list_page(keyword=keyword, page=page)
  628. if not page_items:
  629. logger.info("第%s页无数据,停止", page)
  630. break
  631. for item in page_items:
  632. if self.scheduler.end:
  633. logger.warning("收到停止信号,终止当前页面采集")
  634. break
  635. item, detail_url, spu_code, seller_code = self._build_detail_url(item)
  636. name_part = (item.get("productName") or "").strip()
  637. short_part = (item.get("shortName") or "").strip()
  638. product_name = f"{name_part} {short_part}".strip()
  639. if self.product not in product_name:
  640. self.is_not_product += 1
  641. continue
  642. if self.brand not in product_name:
  643. self.is_not_product += 1
  644. continue
  645. self.is_not_product = 0
  646. if not self._goto_detail_page(detail_url, spu_code, seller_code):
  647. logger.warning(
  648. "详情页跳转失败,跳过 spu=%s seller=%s url=%s",
  649. spu_code, seller_code, detail_url,
  650. )
  651. continue
  652. upload_key = hashlib.md5(detail_url.encode("utf-8")).hexdigest()
  653. snap_url = self._take_snapshot(upload_key)
  654. product = self.parse_product(item, detail_url, snap_url)
  655. if not product.get("item_id"):
  656. continue
  657. try:
  658. self.pipeline.storge_data(product)
  659. logger.info("%s", json.dumps(product, ensure_ascii=False, default=str))
  660. except Exception as e:
  661. logger.exception("写入数据库失败: %s", e)
  662. time.sleep(random.uniform(1, 2))
  663. if self.is_not_product > NOT_PRODUCT_BREAK:
  664. logger.info("连续不匹配商品过多,停止搜索")
  665. break
  666. # 每页结束上报进度,检查限额
  667. self._page_no = page
  668. prog_status = self._report_progress(page, total_pages=api_total_pages)
  669. if prog_status == "limit_reached":
  670. logger.warning("⛔ 进度上报检测到限额,通知停止")
  671. self.scheduler.set_flag(True)
  672. time.sleep(random.uniform(1, 3))
  673. return True
  674. def run(self):
  675. self._page_no = 0
  676. try:
  677. self.scheduler.start()
  678. self._report_start()
  679. self.init_browser()
  680. self.search()
  681. except Exception as e:
  682. logger.exception("运行异常: %s", e)
  683. self.success = False
  684. finally:
  685. self.scheduler.stop()
  686. try:
  687. self._report_end(
  688. success=self.success,
  689. real_count=self.pipeline.crawl_count,
  690. page_no=self._page_no,
  691. )
  692. except Exception:
  693. pass
  694. self._quit_browser()
  695. logger.info("爬取总数%s", self.pipeline.crawl_count)
  696. return self.pipeline.crawl_count, self.success
  697. /collect_task"
  698. TASK_API_TOKEN = "zhijiayun_crawler_2026"
  699. TASK_REPORT_URL = TASK_API_BASE + "/report"
  700. CAPTCHA_TOKEN = "zPzmt1mG1ouCU6GTzsZN2Lmm8pdZypapPcLJTBRETco"
  701. CAPTCHA_API_URL = "http://api.jfbym.com/api/YmServer/customApi"
  702. chrome_path = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
  703. # 项目根目录 → spiders/yaoex(与从哪执行脚本无关)
  704. PROJECT_ROOT = Path(__file__).resolve().parents[2]
  705. YAOEX_SPIDER_DIR = PROJECT_ROOT / "spiders" / "yaoex"
  706. SLIDER_OFFSET_FIX = 10
  707. DETAIL_GET_TIMEOUT = 15
  708. DETAIL_URL_WAIT = 10
  709. DETAIL_DOM_WAIT = 8
  710. DETAIL_NAV_RETRIES = 3
  711. DETAIL_CONTENT_XPATH = "xpath://div[contains(@class,'yaoex-product-detail__content')]"
  712. REQUEST_RETRY_COUNT = 3
  713. REQUEST_TIMEOUT_SEC = 20
  714. NOT_PRODUCT_BREAK = 15
  715. headers = {
  716. "Accept": "application/json, text/plain, */*",
  717. "Accept-Language": "zh-CN,zh;q=0.9",
  718. "Connection": "keep-alive",
  719. "Content-Type": "application/x-www-form-urlencoded",
  720. "Origin": "https://mall.yaoex.com",
  721. "Referer": "https://mall.yaoex.com/",
  722. "Sec-Fetch-Dest": "empty",
  723. "Sec-Fetch-Mode": "cors",
  724. "Sec-Fetch-Site": "cross-site",
  725. "User-Agent": (
  726. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
  727. "AppleWebKit/537.36 (KHTML, like Gecko) "
  728. "Chrome/146.0.0.0 Safari/537.36"
  729. ),
  730. "X-Request-Agent": "Axios",
  731. "X-Requested-With": "XMLHttpRequest",
  732. "sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
  733. "sec-ch-ua-mobile": "?0",
  734. "sec-ch-ua-platform": '"Windows"',
  735. }
  736. class YaoexSnapshotCrawl:
  737. def __init__(self, drug_dict=None):
  738. self.driver = None
  739. self.platform = 6
  740. self.pipeline = DrugPipeline("yaoex")
  741. self.task_dict = drug_dict or {}
  742. self.ossuploader = AliyunOSSUploader()
  743. self.start_page = 1
  744. self.end_page = 1
  745. self.account_name = YYC_ACCOUNT.get("username", "yyc_default")
  746. self._shop_cache = {}
  747. self._register_signal_handler()
  748. if self.task_dict:
  749. self.get_product_data()
  750. self.success = True
  751. self.is_not_product = 0
  752. self.user_id = YYC_ACCOUNT["user_id"]
  753. self.token = YYC_ACCOUNT["token"]
  754. self.scheduler = CrawlerScheduler(YYC_DEVICE_ID, str(PLATFORM_ID), heartbeat_interval=HEARTBEAT_INTERVAL_SECONDS)
  755. def get_product_data(self):
  756. self.task_id = self.task_dict["id"]
  757. self.company_id = self.task_dict["company_id"]
  758. self.product = self.task_dict["product_name"]
  759. self.product_desc = self.task_dict.get("product_specs", "")
  760. self.brand = self.task_dict.get("product_brand", "")
  761. self.product_keyword = self.task_dict.get("product_keyword", "")
  762. self.collect_task_id = self.task_dict.get("collect_task_id", "")
  763. self.sampling_cycle = self.task_dict.get("sampling_cycle", "")
  764. self.sampling_start_time = self.task_dict.get("sampling_start_time", "")
  765. self.sampling_end_time = self.task_dict.get("sampling_end_time", "")
  766. self.collect_equipment_id = self.task_dict.get("collect_equipment_id", "")
  767. self.account_id = self.task_dict.get("collect_equipment_account_id", "")
  768. self.collect_region_id = self.task_dict.get("collect_region_id", "")
  769. self.collect_round = self.task_dict.get("collect_round", 1)
  770. self.start_page = self._parse_page(self.task_dict.get("start_page"), 1)
  771. self.end_page = self._parse_page(self.task_dict.get("end_page"), 100)
  772. # ---------- API report helpers ----------
  773. def _send_report(self, params: dict, log_msg: str):
  774. try:
  775. resp = requests.post(
  776. TASK_REPORT_URL, json=params, timeout=10,
  777. headers={'X-Crawler-Token': TASK_API_TOKEN},
  778. )
  779. if resp.status_code == 200:
  780. try:
  781. data = resp.json()
  782. if data.get("code") == "success":
  783. logger.info("✅ 回告成功:%s", log_msg)
  784. return "success"
  785. else:
  786. msg = data.get("msg", "")
  787. if "已达平台限额" in msg or "限额" in msg:
  788. logger.warning("⛔ 回告接口返回限额错误:code=%s, msg=%s", data.get('code'), msg)
  789. return "limit_reached"
  790. else:
  791. logger.warning("⚠️ 回告接口返回错误:code=%s, msg=%s", data.get('code'), msg)
  792. return "error"
  793. except ValueError:
  794. logger.warning("⚠️ 回告响应非 JSON:%s", resp.text[:200])
  795. return "error"
  796. else:
  797. logger.warning("⚠️ 回告 HTTP %s", resp.status_code)
  798. return "error"
  799. except Exception as e:
  800. logger.error("❌ 回告失败:%s,错误:%s", log_msg, str(e))
  801. return "error"
  802. def _report_start(self):
  803. payload = {
  804. "task_id": self.task_id,
  805. "platform": str(self.platform),
  806. "username": YYC_DEVICE_ID,
  807. "current_page": 0,
  808. "total_pages": 0,
  809. "is_finished": 0,
  810. }
  811. logger.info("上报开始 task_id=%s", self.task_id)
  812. return self._send_report(payload, "开始上报(is_finished=0)")
  813. def _report_progress(self, current_page: int, total_pages: int = 0):
  814. payload = {
  815. "task_id": self.task_id,
  816. "platform": str(self.platform),
  817. "username": YYC_DEVICE_ID,
  818. "current_page": current_page,
  819. "total_pages": total_pages,
  820. "is_finished": 0,
  821. }
  822. return self._send_report(payload, f"进度上报 page={current_page}")
  823. def _report_end(self, success: bool, real_count: int, page_no: int = 0, total_pages: int = 0):
  824. payload = {
  825. "task_id": self.task_id,
  826. "platform": str(self.platform),
  827. "username": YYC_DEVICE_ID,
  828. "current_page": page_no,
  829. "total_pages": total_pages if total_pages > 0 else page_no,
  830. "crawled_count": real_count,
  831. "is_finished": 1 if success else 0,
  832. }
  833. logger.info("上报结束 task_id=%s payload=%s", self.task_id, payload)
  834. self._send_report(payload, "结束上报(is_finished=1)")
  835. @staticmethod
  836. def _parse_page(value, default=1):
  837. try:
  838. page = int(value)
  839. return page if page >= 1 else default
  840. except (TypeError, ValueError):
  841. return default
  842. def _register_signal_handler(self):
  843. def handler(signum, frame):
  844. logger.info("收到退出信号,正在关闭浏览器...")
  845. self._quit_browser()
  846. sys.exit(0)
  847. signal.signal(signal.SIGINT, handler)
  848. if hasattr(signal, "SIGTERM"):
  849. signal.signal(signal.SIGTERM, handler)
  850. @staticmethod
  851. def _timestamp_ms() -> str:
  852. return str(int(time.time() * 1000))
  853. def _quit_browser(self):
  854. if self.driver:
  855. try:
  856. self.driver.quit()
  857. except Exception:
  858. pass
  859. self.driver = None
  860. @staticmethod
  861. def _get_free_port():
  862. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  863. s.bind(("127.0.0.1", 0))
  864. return s.getsockname()[1]
  865. def _resolve_browser_profile_dir(self):
  866. """浏览器数据目录: <项目根>/spiders/yaoex/<账号>"""
  867. profile_dir = YAOEX_SPIDER_DIR / self.account_name
  868. # 仅兼容历史误路径,新建不再使用 chrome_profile
  869. legacy_nested = YAOEX_SPIDER_DIR / "spiders" / "yaoex" / self.account_name
  870. legacy_chrome_profile = YAOEX_SPIDER_DIR / "chrome_profile" / self.account_name
  871. for candidate in (profile_dir, legacy_nested, legacy_chrome_profile):
  872. if (candidate / "Default").is_dir() or (candidate / "Local State").is_file():
  873. logger.info("使用已有浏览器配置目录: %s", candidate)
  874. return candidate
  875. profile_dir.mkdir(parents=True, exist_ok=True)
  876. logger.info("新建浏览器配置目录: %s", profile_dir)
  877. return profile_dir
  878. def init_browser(self):
  879. co = ChromiumOptions().set_browser_path(chrome_path)
  880. debug_port = self._get_free_port()
  881. profile_dir = self._resolve_browser_profile_dir()
  882. profile_dir.mkdir(parents=True, exist_ok=True)
  883. co.set_user_data_path(str(profile_dir))
  884. logger.info("浏览器用户目录(绝对路径): %s", profile_dir.resolve())
  885. co.set_local_port(debug_port)
  886. co.set_argument(f"--remote-debugging-port={debug_port}")
  887. co.set_argument("--remote-debugging-address=127.0.0.1")
  888. co.set_argument("--disable-dev-shm-usage")
  889. co.set_argument("--start-maximized")
  890. co.set_argument("--no-first-run")
  891. co.set_argument("--no-default-browser-check")
  892. self.driver = ChromiumPage(co)
  893. def _is_logged_in(self):
  894. return bool(self.driver.ele("xpath=//a[@id='logout']", timeout=5))
  895. def _call_captcha_api(self, image_bytes):
  896. try:
  897. b64 = base64.b64encode(image_bytes).decode()
  898. resp = requests.post(
  899. CAPTCHA_API_URL,
  900. json={"token": CAPTCHA_TOKEN, "type": "22222", "image": b64},
  901. headers={"Content-Type": "application/json"},
  902. timeout=15,
  903. ).json()
  904. logger.info("验证码 API 返回: %s", resp)
  905. return resp["data"]["data"]
  906. except Exception as e:
  907. logger.exception("验证码识别失败: %s", e)
  908. return None
  909. @staticmethod
  910. def _generate_human_track(distance):
  911. tracks = []
  912. current = 0
  913. mid = distance * 0.7
  914. t = 0.2
  915. v = 0
  916. move_points = []
  917. while current < mid:
  918. a = random.uniform(2, 4)
  919. v0 = v
  920. v = v0 + a * t
  921. move = v0 * t + 0.5 * a * t * t
  922. current += move
  923. move_points.append(move)
  924. while current < distance:
  925. a = -random.uniform(0.5, 1.5)
  926. v0 = v
  927. v = v0 + a * t
  928. if v < 0.5:
  929. v = 0.5
  930. move = v0 * t + 0.5 * a * t * t
  931. current += move
  932. move_points.append(move)
  933. total_points = len(move_points)
  934. for i, move in enumerate(move_points):
  935. y_offset = random.randint(-2, 2) if i % random.randint(2, 4) == 0 else 0
  936. if i < total_points * 0.3:
  937. duration = random.uniform(0.01, 0.03)
  938. elif i > total_points * 0.7:
  939. duration = random.uniform(0.03, 0.08)
  940. else:
  941. duration = random.uniform(0.02, 0.05)
  942. if random.random() < 0.05:
  943. duration += random.uniform(0.05, 0.1)
  944. tracks.append((move, y_offset, duration))
  945. if random.random() < 0.7:
  946. tracks.append((-random.randint(1, 3), 0, 0.05))
  947. return tracks
  948. def _simulate_slider_drag(self, slider_element, target_distance):
  949. self.driver.actions.move_to(slider_element).hold()
  950. for offset_x, offset_y, duration in self._generate_human_track(target_distance):
  951. self.driver.actions.move(offset_x, offset_y, duration=duration / 1000)
  952. self.driver.actions.release()
  953. def _solve_slider_if_present(self):
  954. modal = self.driver.ele("xpath://div[@class='yidun_modal']", timeout=3)
  955. if not modal:
  956. return True
  957. logger.info("检测到滑块验证码,开始处理")
  958. jpg_bytes = modal.get_screenshot(as_bytes="jpg")
  959. distance = self._call_captcha_api(jpg_bytes)
  960. if distance is None:
  961. return False
  962. slider = self.driver.ele(
  963. "xpath://div[contains(@class,'yidun_slider--hover')]", timeout=5
  964. )
  965. if not slider:
  966. logger.error("未找到滑块元素")
  967. return False
  968. self._simulate_slider_drag(slider, float(distance) + SLIDER_OFFSET_FIX)
  969. time.sleep(3)
  970. return True
  971. def login(self):
  972. self.driver.get("https://mall.yaoex.com/login", timeout=15)
  973. self.driver.wait.doc_loaded(timeout=10)
  974. input_name = self.driver.ele("xpath://input[@name='username']", timeout=5)
  975. if not input_name:
  976. logger.error("未找到用户名输入框")
  977. return False
  978. input_name.input(YYC_ACCOUNT["username"])
  979. time.sleep(random.uniform(1.2, 2.0))
  980. input_pass = self.driver.ele("xpath://input[@name='password']", timeout=5)
  981. if not input_pass:
  982. logger.error("未找到密码输入框")
  983. return False
  984. input_pass.input(YYC_ACCOUNT["password"])
  985. time.sleep(random.uniform(1.2, 2.0))
  986. geetest_click = self.driver.ele(
  987. "xpath=//div[contains(@class,'geetest_btn_click')]", timeout=3
  988. )
  989. if geetest_click:
  990. geetest_click.click()
  991. time.sleep(1.5)
  992. login_button = self.driver.ele("xpath://input[@id='login-btn']", timeout=5)
  993. if not login_button:
  994. logger.error("未找到登录按钮")
  995. return False
  996. login_button.click()
  997. self.driver.wait.doc_loaded(timeout=10)
  998. time.sleep(2)
  999. if not self._solve_slider_if_present():
  1000. return False
  1001. return self._is_logged_in()
  1002. def decrypt_price(self, ciphertext_b64):
  1003. if not ciphertext_b64 or not str(ciphertext_b64).strip():
  1004. return ""
  1005. _KEY_FIXED = "GDLSAUO1KUMIIBCE"
  1006. if not self.user_id:
  1007. key = _KEY_FIXED.encode("utf-8")
  1008. else:
  1009. uid = str(self.user_id)[:6].rjust(6, "0")
  1010. key = (_KEY_FIXED[:10] + uid).encode("utf-8")
  1011. raw = base64.b64decode(ciphertext_b64.strip())
  1012. cipher = AES.new(key, AES.MODE_ECB)
  1013. plain = unpad(cipher.decrypt(raw), AES.block_size)
  1014. return plain.decode("utf-8")
  1015. def _post_with_retry(self, url, payload, retries=REQUEST_RETRY_COUNT, timeout=REQUEST_TIMEOUT_SEC):
  1016. last_err = None
  1017. for attempt in range(1, retries + 1):
  1018. try:
  1019. resp = requests.post(
  1020. url,
  1021. headers=headers,
  1022. data=payload,
  1023. timeout=timeout,
  1024. )
  1025. resp.raise_for_status()
  1026. return resp
  1027. except Exception as e:
  1028. last_err = e
  1029. if attempt < retries:
  1030. logger.warning("请求失败,第%s/%s次重试: %s", attempt, retries, e)
  1031. time.sleep(min(2 * attempt, 5))
  1032. else:
  1033. logger.error("请求失败,已达最大重试次数(%s): %s", retries, e)
  1034. raise last_err
  1035. def _shop_payload(self, enterprise_id):
  1036. return {
  1037. "traderName": "yaoex_pc",
  1038. "trader": "pc",
  1039. "closesignature": "yes",
  1040. "signature_method": "md5",
  1041. "signature": "****",
  1042. "timestamp": self._timestamp_ms(),
  1043. "token": self.token,
  1044. "userToken": self.token,
  1045. "enterpriseId": enterprise_id,
  1046. }
  1047. def _list_payload(self, keyword, page):
  1048. return {
  1049. "traderName": "yaoex_pc",
  1050. "trader": "pc",
  1051. "closesignature": "yes",
  1052. "signature_method": "md5",
  1053. "signature": "****",
  1054. "timestamp": self._timestamp_ms(),
  1055. "token": self.token,
  1056. "userToken": self.token,
  1057. "userId": self.user_id,
  1058. "roleId": "101",
  1059. "userType": "下游客户",
  1060. "buyerCode": self.user_id,
  1061. "nowPage": str(page),
  1062. "per": "20",
  1063. "keyword": keyword,
  1064. "catSearchId": "",
  1065. "specs": "",
  1066. "factoryIds": "",
  1067. "sellerCodes": "",
  1068. "sellerFilterMode": "0",
  1069. "sortColumn": "default",
  1070. "sortMode": "default",
  1071. "ver": "1",
  1072. "stock_mode": "1",
  1073. "showExtendCard": "true",
  1074. "needDinnerPrice": "true",
  1075. "limitStart": "",
  1076. "limitEnd": "",
  1077. "deadLineStart": "",
  1078. "deadLineEnd": "",
  1079. "filterDtos": "",
  1080. "showWholePurchase": "true",
  1081. }
  1082. def fetch_list_page(self, keyword, page):
  1083. list_url = "https://gateway-b2b.fangkuaiyi.com/home/search/homeSearchList"
  1084. resp = self._post_with_retry(list_url, self._list_payload(keyword, page))
  1085. data = resp.json()
  1086. recall_status = data.get("data", {}).get("recallStatus", 0)
  1087. data_block = data.get("data", {}) or {}
  1088. # 尝试提取总页数(常见字段名依次尝试)
  1089. total_pages = 0
  1090. for key in ("totalPage", "totalPages", "pageCount", "total", "totalCount", "totalNum", "totalSize"):
  1091. val = data_block.get(key)
  1092. if val is not None and int(val) > 0:
  1093. total_pages = int(val)
  1094. break
  1095. if total_pages:
  1096. logger.info("📊 列表 API 检测到总页数:%s (page=%s)", total_pages, page)
  1097. else:
  1098. logger.debug("列表 API 未返回总页数字段,data keys: %s", list(data_block.keys())[:20])
  1099. if int(recall_status) == 1:
  1100. return data_block.get("shopProducts", []) or [], total_pages
  1101. else:
  1102. return [], 0
  1103. def fetch_shop(self, seller_code):
  1104. detail_url = "https://gateway-b2b.fangkuaiyi.com/ycapp/shop/enterpriseQualification"
  1105. resp = self._post_with_retry(detail_url, self._shop_payload(seller_code))
  1106. shop_res = resp.json().get("data", {})
  1107. base_info = shop_res.get("baseInfo", {})
  1108. return base_info.get("address", ""), base_info.get("enterpriseName", "")
  1109. def _get_shop_info(self, seller_code):
  1110. if seller_code in self._shop_cache:
  1111. return self._shop_cache[seller_code]
  1112. try:
  1113. shop_info = self.fetch_shop(seller_code)
  1114. except Exception as e:
  1115. logger.warning("fetch_shop 失败 seller_code=%s: %s", seller_code, e)
  1116. shop_info = ("", "")
  1117. self._shop_cache[seller_code] = shop_info
  1118. return shop_info
  1119. def _current_url(self):
  1120. try:
  1121. return self.driver.url or ""
  1122. except Exception:
  1123. return ""
  1124. def _url_has_product(self, spu_code, seller_code):
  1125. url = self._current_url()
  1126. spu_code = str(spu_code or "")
  1127. seller_code = str(seller_code or "")
  1128. if spu_code and seller_code:
  1129. return spu_code in url and seller_code in url
  1130. return bool(spu_code and spu_code in url)
  1131. def _wait_detail_ready(self, spu_code, seller_code, timeout=DETAIL_URL_WAIT):
  1132. deadline = time.time() + timeout
  1133. while time.time() < deadline:
  1134. if self._url_has_product(spu_code, seller_code):
  1135. if self.driver.ele(DETAIL_CONTENT_XPATH, timeout=1):
  1136. time.sleep(0.3)
  1137. return True
  1138. time.sleep(0.4)
  1139. return False
  1140. def _build_detail_url(self, item):
  1141. if not item.get("productId") and item.get("groupBuyProductDto"):
  1142. item = item.get("groupBuyProductDto") or {}
  1143. spu_code = item.get("spuCode", "")
  1144. seller_code = item.get("sellerCode", "")
  1145. group_buying_id = item.get("groupBuyingId", "")
  1146. p_json = json.dumps(
  1147. {"id": group_buying_id, "s": seller_code, "sp": spu_code},
  1148. separators=(",", ":"),
  1149. )
  1150. detail_url = (
  1151. f"https://mall.yaoex.com/groupBuying/#/productDetail?p={quote(p_json)}"
  1152. )
  1153. else:
  1154. seller_code = item.get("sellerCode")
  1155. spu_code = item.get("spuCode")
  1156. detail_url = (
  1157. f"https://mall.yaoex.com/v2/product/#/spuCode/{spu_code}/sellerCode/{seller_code}"
  1158. )
  1159. return item, detail_url, spu_code, seller_code
  1160. def _goto_detail_page(self, detail_url, spu_code, seller_code):
  1161. """get 后 refresh 一次,让 SPA 按当前 URL 重新渲染详情。"""
  1162. for attempt in range(1, DETAIL_NAV_RETRIES + 1):
  1163. try:
  1164. self.driver.get(detail_url, timeout=DETAIL_GET_TIMEOUT)
  1165. time.sleep(0.5)
  1166. self.driver.refresh()
  1167. time.sleep(2)
  1168. ele = self.driver.ele("xpath=//div[@class='yaoex-product-detail__product-detail']")
  1169. if ele:
  1170. return True
  1171. else:
  1172. continue
  1173. except Exception as e:
  1174. logger.warning(
  1175. "跳转详情异常 spu=%s seller=%s attempt=%s: %s",
  1176. spu_code, seller_code, attempt, e,
  1177. )
  1178. time.sleep(random.uniform(0.8, 1.5))
  1179. return False
  1180. def _take_snapshot(self, upload_key):
  1181. time.sleep(1)
  1182. try:
  1183. detail_ele = self.driver.ele(DETAIL_CONTENT_XPATH, timeout=2)
  1184. if detail_ele:
  1185. jpg_bytes = detail_ele.get_screenshot(as_bytes="jpg")
  1186. else:
  1187. jpg_bytes = self.driver.get_screenshot(as_bytes="jpg")
  1188. if not jpg_bytes:
  1189. logger.warning("截图为空 upload_key=%s", upload_key)
  1190. return ""
  1191. img_url = self.ossuploader.upload_from_bytes(jpg_bytes, str(upload_key))
  1192. except Exception:
  1193. logger.exception("截图或 OSS 上传失败 upload_key=%s", upload_key)
  1194. return ""
  1195. if not img_url:
  1196. logger.warning("OSS 未返回有效地址 upload_key=%s", upload_key)
  1197. return ""
  1198. logger.info("截图上传完成 upload_key=%s url=%s", upload_key, img_url)
  1199. time.sleep(random.uniform(1, 2))
  1200. return img_url
  1201. def parse_product(self, item, detail_url, snap_url):
  1202. seller_code = item.get("sellerCode")
  1203. spu_code = item.get("spuCode")
  1204. name_part = (item.get("productName") or "").strip()
  1205. short_part = (item.get("shortName") or "").strip()
  1206. product_name = f"{name_part} {short_part}".strip()
  1207. shop_url = f"https://mall.yaoex.com/v2/store/#/detail/{seller_code}/home"
  1208. company_address, company_name = self._get_shop_info(seller_code)
  1209. address = item.get("cityName", "")
  1210. city_id = province_id = city = province = ""
  1211. if address:
  1212. city_id, province_id, city, province = get_city(address.split("市")[0])
  1213. price = self.decrypt_price(item.get("price"))
  1214. hash_text = f"{seller_code}_{spu_code}_{price}"
  1215. item_id = hashlib.md5(hash_text.encode("utf-8")).hexdigest()
  1216. is_sold_out = 1 if "商品已售罄" in (item.get("statusDescription") or "") else 0
  1217. shop_name = item.get("storeName") or item.get("shopName")
  1218. anonymous_store_name = ""
  1219. if shop_name == "预约配送中心":
  1220. anonymous_store_name = item.get("supplyName", "")
  1221. inventory = item.get("currentInventory") or item.get("stockCount")
  1222. now = time.strftime("%Y-%m-%d %H:%M:%S")
  1223. return {
  1224. "platform": self.platform,
  1225. "item_id": item_id,
  1226. "enterprise_id": self.company_id,
  1227. "product_name": product_name,
  1228. "spec": item.get("spec"),
  1229. "one_price": "",
  1230. "detail_url": detail_url,
  1231. "shop_name": shop_name,
  1232. "anonymous_store_name": anonymous_store_name,
  1233. "shop_url": shop_url,
  1234. "city_name": city,
  1235. "city_id": city_id,
  1236. "province_name": province,
  1237. "province_id": province_id,
  1238. "shipment_city_name": "",
  1239. "shipment_city_id": "",
  1240. "shipment_province_name": "",
  1241. "shipment_province_id": "",
  1242. "area_info": company_address or "",
  1243. "factory_name": item.get("factoryName"),
  1244. "scrape_date": time.strftime("%Y-%m-%d"),
  1245. "price": price,
  1246. "sales": "",
  1247. "stock_count": inventory,
  1248. "snapshot_url": snap_url,
  1249. "approval_num": item.get("approvalNum"),
  1250. "produced_time": item.get("productionTime"),
  1251. "deadline": item.get("deadLine"),
  1252. "update_time": now,
  1253. "insert_time": now,
  1254. "number": 1,
  1255. "product_brand": self.brand or "",
  1256. "collect_task_id": self.collect_task_id,
  1257. "search_name": self.product,
  1258. "company_name": company_name,
  1259. "collect_config_info": json.dumps(
  1260. {
  1261. "sampling_cycle": self.sampling_cycle,
  1262. "sampling_start_time": self.sampling_start_time,
  1263. "sampling_end_time": self.sampling_end_time,
  1264. }
  1265. ),
  1266. "account_id": self.account_id,
  1267. "collect_region_id": self.collect_region_id,
  1268. "collect_round": self.collect_round,
  1269. "is_sold_out": is_sold_out,
  1270. }
  1271. def search(self):
  1272. self.driver.get("https://mall.yaoex.com/", timeout=15)
  1273. self.driver.wait.doc_loaded(timeout=10)
  1274. if not self._is_logged_in():
  1275. if not self.login():
  1276. logger.error("登录失败")
  1277. return False
  1278. keyword = self.product
  1279. if self.brand:
  1280. keyword = (self.brand + " " + self.product).strip()
  1281. if self.product_desc:
  1282. keyword = (keyword + " " + self.product_desc).strip()
  1283. for page in range(self.start_page, self.end_page + 1):
  1284. if self.scheduler.end:
  1285. logger.warning("收到停止信号,终止采集")
  1286. break
  1287. logger.info("正在爬取 %s %s,第%s页", self.brand, self.product, page)
  1288. page_items, api_total_pages = self.fetch_list_page(keyword=keyword, page=page)
  1289. if not page_items:
  1290. logger.info("第%s页无数据,停止", page)
  1291. break
  1292. for item in page_items:
  1293. if self.scheduler.end:
  1294. logger.warning("收到停止信号,终止当前页面采集")
  1295. break
  1296. item, detail_url, spu_code, seller_code = self._build_detail_url(item)
  1297. name_part = (item.get("productName") or "").strip()
  1298. short_part = (item.get("shortName") or "").strip()
  1299. product_name = f"{name_part} {short_part}".strip()
  1300. if self.product not in product_name:
  1301. self.is_not_product += 1
  1302. continue
  1303. if self.brand not in product_name:
  1304. self.is_not_product += 1
  1305. continue
  1306. self.is_not_product = 0
  1307. if not self._goto_detail_page(detail_url, spu_code, seller_code):
  1308. logger.warning(
  1309. "详情页跳转失败,跳过 spu=%s seller=%s url=%s",
  1310. spu_code, seller_code, detail_url,
  1311. )
  1312. continue
  1313. upload_key = hashlib.md5(detail_url.encode("utf-8")).hexdigest()
  1314. snap_url = self._take_snapshot(upload_key)
  1315. product = self.parse_product(item, detail_url, snap_url)
  1316. if not product.get("item_id"):
  1317. continue
  1318. try:
  1319. self.pipeline.storge_data(product)
  1320. logger.info("%s", json.dumps(product, ensure_ascii=False, default=str))
  1321. except Exception as e:
  1322. logger.exception("写入数据库失败: %s", e)
  1323. time.sleep(random.uniform(1, 2))
  1324. if self.is_not_product > NOT_PRODUCT_BREAK:
  1325. logger.info("连续不匹配商品过多,停止搜索")
  1326. break
  1327. # 每页结束上报进度,检查限额
  1328. self._page_no = page
  1329. prog_status = self._report_progress(page, total_pages=api_total_pages)
  1330. if prog_status == "limit_reached":
  1331. logger.warning("⛔ 进度上报检测到限额,通知停止")
  1332. self.scheduler.set_flag(True)
  1333. time.sleep(random.uniform(1, 3))
  1334. return True
  1335. def run(self):
  1336. self._page_no = 0
  1337. try:
  1338. self.scheduler.start()
  1339. self._report_start()
  1340. self.init_browser()
  1341. self.search()
  1342. except Exception as e:
  1343. logger.exception("运行异常: %s", e)
  1344. self.success = False
  1345. finally:
  1346. self.scheduler.stop()
  1347. try:
  1348. self._report_end(
  1349. success=self.success,
  1350. real_count=self.pipeline.crawl_count,
  1351. page_no=self._page_no,
  1352. )
  1353. except Exception:
  1354. pass
  1355. self._quit_browser()
  1356. logger.info("爬取总数%s", self.pipeline.crawl_count)
  1357. return self.pipeline.crawl_count, self.success