snapshot_taobao_crawl.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. import hashlib
  2. import json
  3. import random
  4. import re
  5. import time
  6. from decimal import Decimal, InvalidOperation
  7. from curl_cffi import requests
  8. from lxml import etree
  9. from commons.Logger import get_spider_logger
  10. from commons.conn_mysql import MySQLPoolOnline
  11. from pipelines.drug_pipelines import DrugPipeline
  12. from spiders.taobao.snapshot_taobao_login import (TaobaoAutoCrawl)
  13. from area_info.city_name_to_id import get_city
  14. from oss_upload.oss_upload import AliyunOSSUploader
  15. from urllib.parse import quote, unquote
  16. logger = get_spider_logger("taobao")
  17. from urllib.parse import quote
  18. MTOP_APP_KEY = "12574478"
  19. MTOP_APP_ID = "34385"
  20. SEARCH_MAX_PAGE = 20
  21. REQUEST_RETRY_COUNT = 3
  22. COOKIE_MAX_AGE_SEC = 3600
  23. def build_taobao_search_url(keyword: str, page: int = 1) -> str:
  24. """
  25. 构建淘宝搜索URL
  26. 参数:
  27. keyword: 搜索关键词 (例如: "999 玉屏风口服液 10支")
  28. page: 页码,从1开始
  29. 返回:
  30. 完整的淘宝搜索URL字符串
  31. """
  32. # 对关键词进行URL编码(空格转为%20)
  33. encoded_keyword = quote(keyword, safe='').replace(' ', '%20')
  34. # 固定参数
  35. fixed_params = {
  36. "_input_charset": "utf-8",
  37. "commend": "all",
  38. "ie": "utf8",
  39. "preLoadOrigin": "https://www.taobao.com",
  40. "search_type": "item",
  41. "source": "suggest",
  42. "sourceId": "tb.index",
  43. "spm": "a21bo.jianhua/a.search_history.d1",
  44. "ssid": "s5-e",
  45. "tab": "all",
  46. "suggest_query": "",
  47. }
  48. # 动态参数
  49. dynamic_params = {
  50. "q": encoded_keyword,
  51. "page": str(page),
  52. }
  53. # 合并参数
  54. all_params = {**fixed_params, **dynamic_params}
  55. # 构建查询字符串并返回完整URL
  56. query_string = "&".join([f"{k}={v}" for k, v in all_params.items()])
  57. return f"https://s.taobao.com/search?{query_string}"
  58. def extract_item_data(item_element):
  59. """
  60. 从商品元素中提取数据
  61. """
  62. result = {
  63. "item_id": "",
  64. "title": "",
  65. "price": "",
  66. "realSales": "",
  67. "shopInfo": {"title": ""},
  68. "procity": "",
  69. "auctionURL": ""
  70. }
  71. # 1. 提取 item_id - 从 a 标签的 id 属性
  72. a_elem = item_element.ele('xpath=.//a[contains(@id, "item_id_")]')
  73. if a_elem:
  74. item_id_full = a_elem.attr('id')
  75. if item_id_full:
  76. result["item_id"] = item_id_full.replace("item_id_", "")
  77. # 2. 提取 title - 从 div 的 title 属性
  78. title_elem = item_element.ele('xpath=.//div[contains(@class, "title--")]')
  79. if title_elem:
  80. title = title_elem.attr('title')
  81. if not title:
  82. # 如果没有 title 属性,取文本内容
  83. title = title_elem.text
  84. result["title"] = title
  85. # 3. 提取 price - 整数部分 + 小数部分
  86. price_int = item_element.ele('xpath=.//div[contains(@class, "priceInt--")]')
  87. price_float = item_element.ele('xpath=.//div[contains(@class, "priceFloat--")]')
  88. if price_int and price_float:
  89. result["price"] = f"{price_int.text}.{price_float.text.replace('.', '')}"
  90. # 4. 提取 realSales (销量)
  91. sales_elem = item_element.ele('xpath=.//span[contains(@class, "realSales--")]')
  92. if sales_elem:
  93. result["realSales"] = sales_elem.text
  94. # 5. 提取 shopInfo.title (店铺名称)
  95. shop_elem = item_element.ele('xpath=.//span[contains(@class, "shopNameText--")]')
  96. if shop_elem:
  97. result["shopInfo"]["title"] = shop_elem.text
  98. # 6. 提取 procity (发货地)
  99. procity_elem = item_element.ele('xpath=.//div[contains(@class, "procity--")]/span')
  100. if procity_elem:
  101. result["procity"] = procity_elem.text
  102. # 7. 提取 auctionURL
  103. if a_elem:
  104. href = a_elem.attr('href')
  105. if href:
  106. result["auctionURL"] = href
  107. elif result["item_id"]:
  108. result["auctionURL"] = f"https://item.taobao.com/item.htm?id={result['item_id']}"
  109. return result
  110. headers = {
  111. "accept": "*/*",
  112. "accept-language": "zh-CN,zh;q=0.9",
  113. "referer": "https://s.taobao.com/search?page=1&q=999%E6%84%9F%E5%86%92%E7%81%B5&spm=a21bo.jianhua%2Fa.201867-main.d4_first.42f72a89n1ITMs&tab=mall",
  114. "sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
  115. "sec-ch-ua-mobile": "?0",
  116. "sec-ch-ua-platform": '"Windows"',
  117. "sec-fetch-dest": "script",
  118. "sec-fetch-mode": "no-cors",
  119. "sec-fetch-site": "same-site",
  120. "user-agent": (
  121. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
  122. "(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"
  123. ),
  124. }
  125. MTOP_URL = (
  126. "https://h5api.m.taobao.com/h5/mtop.relationrecommend.wirelessrecommend.recommend/2.0/"
  127. )
  128. class TaobaoCrawl:
  129. def __init__(self, drug_dict=None):
  130. self.cookies = None
  131. self.db = MySQLPoolOnline()
  132. self.pipeline = DrugPipeline("taobao")
  133. self.session = None
  134. self.proxies = None
  135. self.account_name = None
  136. self.ip = None
  137. self.cookie_stamp = None
  138. self.platform = 1
  139. self.task_dict = drug_dict or {}
  140. self.collect_task_id = None
  141. self.success = True
  142. if self.task_dict:
  143. self.get_product_data()
  144. self.is_no_product = 0
  145. self.driver=''
  146. self.ossuploader = AliyunOSSUploader()
  147. def get_product_data(self):
  148. self.task_id = self.task_dict["id"]
  149. self.company_id = self.task_dict["company_id"]
  150. self.product = self.task_dict["product_name"]
  151. self.product_desc = self.task_dict.get("product_specs", "")
  152. self.brand = self.task_dict.get("product_brand", "")
  153. self.product_keyword = self.task_dict.get("product_keyword", "")
  154. self.collect_task_id = self.task_dict.get("collect_task_id", "")
  155. self.sampling_cycle = self.task_dict.get("sampling_cycle", "")
  156. self.sampling_start_time = self.task_dict.get("sampling_start_time", "")
  157. self.sampling_end_time = self.task_dict.get("sampling_end_time", "")
  158. self.collect_equipment_id = self.task_dict.get("collect_equipment_id", "")
  159. self.account_id = self.task_dict.get("collect_equipment_account_id", "")
  160. self.collect_region_id = self.task_dict.get("collect_region_id", "")
  161. self.collect_round = self.task_dict.get("collect_round", 1)
  162. @staticmethod
  163. def _normalize_url(url):
  164. if not url:
  165. return ""
  166. url = str(url)
  167. if url.startswith("//"):
  168. return "https:" + url
  169. return url
  170. @staticmethod
  171. def _extract_shop_id(shop_url):
  172. if not shop_url:
  173. return ""
  174. shop_re = re.search(r"appUid=(\w+)", shop_url)
  175. if shop_re:
  176. return shop_re.group(1)
  177. return hashlib.md5(shop_url.encode("utf-8")).hexdigest()
  178. @staticmethod
  179. def _sql_literal(value):
  180. """避免拼接账号名时单引号打断 SQL(非完整防注入,仅兜底)。"""
  181. if value is None:
  182. return ""
  183. return str(value).replace("'", "''").replace("\\", "\\\\")
  184. def init_session(self):
  185. self.session = requests.Session(impersonate="chrome124")
  186. self.session.cookies.update(self.cookies or {})
  187. self.session.headers.update(headers)
  188. if self.proxies:
  189. self.session.proxies.update(self.proxies)
  190. @staticmethod
  191. def _is_transport_error(err):
  192. msg = str(err or "")
  193. return ("curl: (16)" in msg) or ("Failed to perform" in msg)
  194. def get_token(self, t, app_key, data_str):
  195. _m_h5_tk = (self.cookies or {}).get("_m_h5_tk", "")
  196. token = _m_h5_tk.split("_")[0] if _m_h5_tk else ""
  197. text = f"{token}&{t}&{app_key}&{data_str}"
  198. return hashlib.md5(text.encode()).hexdigest()
  199. def get_html_content(self, res_html):
  200. if not res_html:
  201. return ""
  202. ele_html = etree.HTML(res_html)
  203. if ele_html is None:
  204. return str(res_html)
  205. text_list = ele_html.xpath(".//text()")
  206. return "".join(text_list)
  207. def _build_search_payload(self, keyword, page, page_size=50):
  208. return {
  209. "appId": MTOP_APP_ID,
  210. "params": {
  211. "device": "HMA-AL00",
  212. "isBeta": "false",
  213. "grayHair": "false",
  214. "from": "nt_history",
  215. "brand": "HUAWEI",
  216. "info": "wifi",
  217. "index": "4",
  218. "rainbow": "",
  219. "schemaType": "auction",
  220. "elderHome": "false",
  221. "isEnterSrpSearch": "true",
  222. "newSearch": "false",
  223. "network": "wifi",
  224. "subtype": "",
  225. "hasPreposeFilter": "false",
  226. "prepositionVersion": "v2",
  227. "client_os": "Android",
  228. "gpsEnabled": "false",
  229. "searchDoorFrom": "srp",
  230. "debug_rerankNewOpenCard": "false",
  231. "homePageVersion": "v7",
  232. "searchElderHomeOpen": "false",
  233. "search_action": "initiative",
  234. "sugg": "_4_1",
  235. "sversion": "13.6",
  236. "style": "list",
  237. "ttid": "600000@taobao_pc_10.7.0",
  238. "needTabs": "true",
  239. "areaCode": "CN",
  240. "vm": "nw",
  241. "countryNum": "156",
  242. "m": "pc",
  243. "page": page,
  244. "n": 48,
  245. "q": keyword,
  246. "qSource": "url",
  247. "pageSource": "",
  248. "channelSrp": "",
  249. "tab": "all",
  250. "pageSize": str(page_size),
  251. "sourceS": "2",
  252. "ntoffset": "0",
  253. "filterTag": "",
  254. "service": "",
  255. "prop": "",
  256. "loc": "",
  257. "categoryp": "",
  258. "screenResolution": "1920x1080",
  259. "viewResolution": "1092x4722",
  260. "userAgent": headers["user-agent"],
  261. "couponUnikey": "",
  262. "subTabId": "",
  263. "np": "",
  264. "clientType": "h5",
  265. "isNewDomainAb": "false",
  266. "forceOldDomain": "false",
  267. },
  268. }
  269. def _request_search_page(self, keyword, page):
  270. t = str(int(time.time() * 1000))
  271. data = self._build_search_payload(keyword, page)
  272. data_str = json.dumps(data, separators=(",", ":"))
  273. sign = self.get_token(t, MTOP_APP_KEY, data_str)
  274. params = {
  275. "jsv": "2.7.4",
  276. "appKey": MTOP_APP_KEY,
  277. "t": t,
  278. "sign": sign,
  279. "api": "mtop.relationrecommend.wirelessrecommend.recommend",
  280. "v": "2.0",
  281. "timeout": "10000",
  282. "type": "jsonp",
  283. "dataType": "jsonp",
  284. "callback": "",
  285. "data": data_str,
  286. }
  287. return self.session.get(MTOP_URL, params=params, timeout=30)
  288. def _parse_jsonp_body(self, res_text):
  289. res_text = (res_text or "").strip()
  290. json_str = res_text
  291. m = re.match(r"^[^(]*\((.*)\)\s*;?\s*$", res_text, re.DOTALL)
  292. if m:
  293. json_str = m.group(1)
  294. return json.loads(json_str)
  295. def get_search(self):
  296. keyword = self.product
  297. if self.brand:
  298. keyword = (self.brand + " " + self.product).strip()
  299. if self.product_desc:
  300. keyword = (keyword + " " + self.product_desc).strip()
  301. for page in range(1, SEARCH_MAX_PAGE + 1):
  302. logger.info(f"正在爬取关键词:{keyword},{page}页数据")
  303. # input_box = self.driver.ele('xpath=//*[@id="q"]')
  304. # input_box.input(keyword)
  305. # time.sleep(1)
  306. # button = self.driver.ele('xpath=//*[@id="J_TSearchForm"]/div[2]/button')
  307. # button.click()
  308. # time.sleep(2)
  309. base_url = build_taobao_search_url(keyword)
  310. tab = self.driver.latest_tab
  311. tab.listen.start('https://h5api.m.taobao.com/h5/mtop.relationrecommend.wirelessrecommend.recommend/2.0/') # 开始监听,指定获取包含该文本的数据包
  312. if page==1:
  313. tab.get(base_url)
  314. else:
  315. next_btn = tab.ele('xpath=//*[@id="search-content-leftWrap"]//button[contains(@aria-label, "下一页")]')
  316. if not next_btn.click():
  317. break
  318. for _ in range(5):
  319. response = tab.listen.wait() # 等待并获取一个数据包
  320. if len(response.url)>3000:
  321. res = response.response.raw_body
  322. break
  323. else:
  324. continue
  325. time.sleep(1.5)
  326. try:
  327. json_data = self._parse_jsonp_body(res)
  328. item_array = json_data.get("data", {}).get("itemsArray", [])
  329. except Exception as e:
  330. logger.warning(
  331. "解析数据异常,%s 账号可能退出登录,尝试重新登录: %s",
  332. self.account_name,
  333. e,
  334. )
  335. break
  336. s2 = 0
  337. if not item_array:
  338. logger.warning("关键词 %s 第 %s 页未获取到商品数据", keyword, page)
  339. return
  340. elems = tab.eles('xpath=//*[@id="content_items_wrapper"]/div//div/a/span/../../../..')
  341. title_eles = tab.eles('xpath=//*[@id="content_items_wrapper"]/div//div/a/span')
  342. for s,raw in enumerate(item_array):
  343. try:
  344. item_id = raw.get("item_id", "")
  345. if not item_id:
  346. continue
  347. while raw.get('shopInfo').get('title') != title_eles[s2].text:
  348. s2 += 1
  349. item_title = self.get_html_content(raw.get("title") or "")
  350. if self.brand not in item_title:
  351. self.is_no_product += 1
  352. continue
  353. if self.product not in item_title:
  354. self.is_no_product += 1
  355. continue
  356. if "+" in item_title:
  357. continue
  358. if self.product_desc:
  359. if self.product_desc in item_title:
  360. crawl_product_desc = self.product_desc
  361. else:
  362. crawl_product_desc = ""
  363. else:
  364. crawl_product_desc = ""
  365. self.is_no_product = 0
  366. status = 1
  367. if self.product_keyword:
  368. search_keyword_list = self.product_keyword.split(",")
  369. for search_keyword in search_keyword_list:
  370. if search_keyword.strip() not in item_title:
  371. status = 0
  372. if status == 0:
  373. continue
  374. item_price = raw.get("price")
  375. item_price_show = raw.get("priceShow", {}).get("price", 0)
  376. item_sales = raw.get("realSales") or ""
  377. sale_num = ""
  378. sales_m = re.search(r"(.*?)人付款", item_sales)
  379. if sales_m:
  380. sale_num = sales_m.group(1)
  381. item_url = self._normalize_url(raw.get("auctionURL"))
  382. match = re.search(r'skuId=([^&]*)', unquote(item_url))
  383. if match:
  384. if match.group(1) != 'null':
  385. item_id = item_id + '_' + match.group(1)
  386. shop_name = raw.get("shopInfo", {}).get("title", "")
  387. area_str = (raw.get("procity", "") or "").strip()
  388. city_id, province_id, city, province = get_city(area_str)
  389. shop_url = self._normalize_url(
  390. raw.get("shopInfo", {}).get("url", "")
  391. )
  392. structured_list = raw.get("structuredUSPInfo",{})
  393. for structured in structured_list:
  394. if structured.get("propertyName","") == "规格":
  395. crawl_product_desc = structured.get("propertyValueName","")
  396. pic_path = raw.get("pic_path", "")
  397. raw_price = item_price_show
  398. if raw_price in (None, ""):
  399. price = Decimal("0.00")
  400. else:
  401. try:
  402. price = Decimal(str(raw_price)).quantize(Decimal("0.00"))
  403. except (InvalidOperation, ValueError):
  404. price = Decimal("0.00")
  405. upload_key = hashlib.md5(item_url.encode("utf-8")).hexdigest()
  406. for i in range(3):
  407. try:
  408. jpg_bytes = elems[s2].get_screenshot(as_bytes="jpg")
  409. snapshot_url = self.ossuploader.upload_from_bytes(jpg_bytes, str(upload_key))
  410. break
  411. except Exception as e:
  412. snapshot_url=''
  413. print(e)
  414. s2+=1
  415. scrape_date = time.strftime("%Y-%m-%d")
  416. update_time = time.strftime("%Y-%m-%d %H:%M:%S")
  417. #snapshot_url = self._normalize_url(pic_path) if pic_path else ""
  418. # 字段与 yaofangwang_crawl 对齐;键顺序须与 commons.sql_data.RETRIEVE_SCRAPE_INSERT_COLUMNS 一致
  419. product = {
  420. "platform": self.platform,
  421. "item_id": item_id,
  422. "enterprise_id": self.company_id,
  423. "product_name": item_title,
  424. "spec": crawl_product_desc,
  425. "one_price": "",
  426. "detail_url": item_url,
  427. "shop_name": shop_name,
  428. "anonymous_store_name": "",
  429. "shop_url": shop_url,
  430. "city_name": "",
  431. "city_id": "",
  432. "province_name": "",
  433. "province_id": "",
  434. "shipment_city_name": city,
  435. "shipment_city_id": city_id,
  436. "shipment_province_name": province,
  437. "shipment_province_id": province_id,
  438. "area_info": area_str,
  439. "factory_name": "",
  440. "scrape_date": scrape_date,
  441. "price": price,
  442. "sales": sale_num,
  443. "stock_count": "",
  444. "snapshot_url": snapshot_url,
  445. "approval_num": "",
  446. "produced_time": "",
  447. "deadline": "",
  448. "update_time": update_time,
  449. "insert_time": update_time,
  450. "number": 1,
  451. "product_brand": self.brand or "",
  452. "collect_task_id": self.collect_task_id,
  453. "task_id": self.task_id,
  454. "search_name": self.product,
  455. "company_name": "",
  456. "collect_config_info": json.dumps(
  457. {
  458. "sampling_cycle": self.sampling_cycle,
  459. "sampling_start_time": self.sampling_start_time,
  460. "sampling_end_time": self.sampling_end_time,
  461. }
  462. ),
  463. "account_id": self.account_id,
  464. "collect_region_id": self.collect_region_id,
  465. "collect_round": self.collect_round,
  466. "is_sold_out": 0
  467. }
  468. try:
  469. self.pipeline.storge_data(product)
  470. logger.info("%s", json.dumps(product, ensure_ascii=False, default=str))
  471. except Exception as e:
  472. logger.exception("写入数据库失败: %s", e)
  473. except:
  474. continue
  475. logger.info(
  476. "关键词 %s 第 %s 页爬取完成",
  477. keyword,
  478. page,
  479. )
  480. total_page = (
  481. (json_data or {}).get("data", {}).get("mainInfo", {}).get("totalPage")
  482. )
  483. try:
  484. total_page_int = int(total_page) if total_page is not None else 50
  485. except (TypeError, ValueError):
  486. total_page_int = 50
  487. if page >= total_page_int:
  488. break
  489. if self.is_no_product > 100:
  490. break
  491. sleep_second = random.uniform(30, 60)
  492. logger.info("第 %s 页爬取完成,休息 %.1fs", page, sleep_second)
  493. time.sleep(sleep_second)
  494. def update_cookie(self):
  495. taobao_auto = TaobaoAutoCrawl(self.account_name, self.ip, self.product)
  496. self.driver=taobao_auto.run()
  497. if not self.driver :
  498. return False
  499. return True
  500. def get_account(self):
  501. sql_account = """
  502. SELECT * FROM `retrieve_collect_equipment_account` WHERE `id` = %s and `status` = 0
  503. """
  504. account_list = self.db.select_data(sql_account,self.account_id)
  505. if not account_list:
  506. return False
  507. account_dict = account_list[0]
  508. self.ip = account_dict.get("ip")
  509. cookie_str = account_dict.get("cookie_str")
  510. self.ip = account_dict.get("ip")
  511. self.account_name = account_dict.get("username")
  512. self.login_username = account_dict.get("phone", "")
  513. self.login_password = account_dict.get("password", "")
  514. self.cookie_stamp = account_dict.get("update_time")
  515. if self.ip:
  516. account_proxy = f"http://{self.ip}"
  517. self.proxies = {"http": account_proxy, "https": account_proxy}
  518. else:
  519. self.proxies = None
  520. need_refresh = (
  521. not cookie_str
  522. or int(time.time()) - int(self.cookie_stamp or 0) > COOKIE_MAX_AGE_SEC
  523. )
  524. if 1:
  525. if not self.update_cookie():
  526. return False
  527. logger.info("获取到账号: %s, ip: %s", self.account_name, self.ip)
  528. return True
  529. def run(self):
  530. if not self.get_account():
  531. logger.info("==================当前无账号可用==================")
  532. self.success = False
  533. return self.pipeline.crawl_count, self.success
  534. logger.info("获取到账号:%s,代理ip:%s", self.account_name, self.ip)
  535. try:
  536. self.get_search()
  537. self.driver.quit()
  538. except Exception as e:
  539. print('数据获取错误',e)
  540. logger.info(
  541. "任务id:%s, 任务状态已更新, 产品名称:%s, 爬取数据:%s条",
  542. self.task_id,
  543. self.product,
  544. self.pipeline.crawl_count,
  545. )
  546. return self.pipeline.crawl_count, self.success