|
|
@@ -363,3 +363,93 @@ def update_details_for_pending(log, sql_pool):
|
|
|
data={"state": 2},
|
|
|
condition={"id": sql_id}
|
|
|
)
|
|
|
+
|
|
|
+
|
|
|
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
|
|
|
+def refresh_lot_result(log, url, sql_pool, sql_id):
|
|
|
+ """
|
|
|
+ 重访详情页,判断拍卖是否结束;结束则回写最终 bids/status/price。
|
|
|
+
|
|
|
+ 首采时 status='Open' 表示拍卖进行中、价格未定,抓到的只是当时的当前价。
|
|
|
+ 拍卖结束后详情页会固定出现「This lot is closed. Bidding is not allowed.」,
|
|
|
+ 此时才把最终的出价次数、成交状态、成交价写回库。
|
|
|
+
|
|
|
+ :param log: (loguru.Logger) 日志对象
|
|
|
+ :param url: (str) 详情页 URL(形如 .../bids/bidplace.aspx?itemid=xxx),来自 detail_url 字段
|
|
|
+ :param sql_pool: (MySQLConnectionPool) 数据库连接池
|
|
|
+ :param sql_id: (int) 该记录在 auctions90s_record 的主键 id
|
|
|
+ :return: (str) 'closed' 表示已结束并完成更新;'open' 表示仍在进行、跳过未改动
|
|
|
+ :raises RuntimeError: 页面残缺(既无结束标志也无价格框)时抛出以触发重试,避免残页被误判为进行中
|
|
|
+ """
|
|
|
+ log.info(f">>>>>>>>>>>>>> 补采拍卖结果 id={sql_id} URL={url} <<<<<<<<<<<<<<")
|
|
|
+ response = requests.get(url, headers=headers,
|
|
|
+ impersonate=random.choice(client_identifier_list),
|
|
|
+ timeout=10, proxies=get_proxys(log))
|
|
|
+ response.raise_for_status()
|
|
|
+ html = response.text
|
|
|
+ selector = Selector(html)
|
|
|
+
|
|
|
+ # 结束信号:拍卖结束后详情页固定出现这句话,进行中的 lot 不会有(最稳的判定依据)
|
|
|
+ closed = "This lot is closed. Bidding is not allowed." in html
|
|
|
+ # 价格框:成交显示「SOLD FOR $x」,流拍显示「UNSOLD」,进行中显示「CURRENT BID $x」
|
|
|
+ price_box = ' '.join(selector.xpath('//h3[@id="MainContent_currentBidBox"]//text()').getall())
|
|
|
+ price_box = re.sub(r'\s+', ' ', price_box).strip()
|
|
|
+
|
|
|
+ if not closed:
|
|
|
+ # 无结束标志且连价格框都没有 → 判定为残页/被限流,抛错重试;否则确系进行中,跳过
|
|
|
+ if not price_box:
|
|
|
+ raise RuntimeError("详情页残缺:既无结束标志也无价格框")
|
|
|
+ log.info(f"id={sql_id} 拍卖仍在进行,跳过(price_box={price_box!r})")
|
|
|
+ return "open"
|
|
|
+
|
|
|
+ # 已结束:解析最终出价次数(Bid History 链接前的 <strong> 即出价数)
|
|
|
+ bids = selector.xpath('//a[@id="MainContent_btnBidHistory"]/preceding-sibling::strong/text()').get()
|
|
|
+ bids = bids.strip() if bids else None
|
|
|
+
|
|
|
+ data = {"bids": bids}
|
|
|
+ price_upper = price_box.upper()
|
|
|
+ if "UNSOLD" in price_upper:
|
|
|
+ # 流拍:详情页不给最终价,只更新 status 与 bids,price 保持原值不动
|
|
|
+ data["status"] = "Unsold"
|
|
|
+ elif "SOLD FOR" in price_upper:
|
|
|
+ # 成交:复用 _clean_price 去掉「SOLD FOR $」前缀与千分位逗号,与列表页 price 存法一致
|
|
|
+ data["status"] = "Sold"
|
|
|
+ data["price"] = _clean_price(price_box)
|
|
|
+ else:
|
|
|
+ # 未知价格框形态:只记日志、不动 status,避免把不确定的结果误写库
|
|
|
+ log.warning(f"id={sql_id} 价格框形态未知,仅更新 bids: {price_box!r}")
|
|
|
+
|
|
|
+ sql_pool.update_one_or_dict(table=TABLE_NAME, data=data, condition={"id": sql_id})
|
|
|
+ log.info(f"id={sql_id} 拍卖已结束,更新 -> {data}")
|
|
|
+ return "closed"
|
|
|
+
|
|
|
+
|
|
|
+def update_open_lots(log, sql_pool):
|
|
|
+ """
|
|
|
+ 扫描库里 status='Open' 的记录,补采其最终拍卖结果。
|
|
|
+
|
|
|
+ 首采发生在拍卖进行中,只抓到当时价格;本函数逐条重访详情页,
|
|
|
+ 对已结束的 lot 回写最终 bids/status/price,未结束的保持不动。
|
|
|
+ 更新后 status 变为 Sold/Unsold,下一轮不再命中,随任务多轮运行自然收敛。
|
|
|
+
|
|
|
+ :param log: (loguru.Logger) 日志对象
|
|
|
+ :param sql_pool: (MySQLConnectionPool) 数据库连接池
|
|
|
+ :return: None
|
|
|
+ """
|
|
|
+ log.debug('Refreshing open lots result ...........................')
|
|
|
+ rows = sql_pool.select_all(
|
|
|
+ f"select id, detail_url from {TABLE_NAME} where status='Open' order by id")
|
|
|
+ total = len(rows) if rows else 0
|
|
|
+ log.info(f"待补采 status='Open' 记录数: {total}")
|
|
|
+
|
|
|
+ closed_cnt = 0
|
|
|
+ for row in rows or []:
|
|
|
+ sql_id, detail_url = row[0], row[1]
|
|
|
+ try:
|
|
|
+ if refresh_lot_result(log, detail_url, sql_pool, sql_id) == "closed":
|
|
|
+ closed_cnt += 1
|
|
|
+ except Exception as e:
|
|
|
+ # 重试耗尽仍失败:记录并跳过,保持 status='Open' 留待下一轮,不阻塞整体
|
|
|
+ log.error(f"补采拍卖结果失败 id={sql_id} url={detail_url}: {e}")
|
|
|
+ continue
|
|
|
+ log.info(f"本轮补采完成:共 {total} 条,已结束并更新 {closed_cnt} 条")
|