Files
sbnews/server/public/dongqiudi-crawler/lottery_community.py
T
2026-06-11 12:15:29 +08:00

432 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import hashlib
import html
import json
import re
import time
from datetime import datetime
from urllib.parse import urljoin
import pymysql
import requests
BASE_URL = "https://cc33.49bd28.com/"
CDN_FALLBACK = "https://tk2cdn-hw.thc517.com"
IMAGE_CDN_HOST = "https://tk2cdn.ai4funs.com"
SOURCE = "49baodian"
USER_ACCOUNT = "lottery_publisher"
USER_NICKNAME = "彩票资料员"
USER_SN_START = 49000001
LOCK_WAIT_RETRY_CODES = {1205, 1213}
LOTTERY_VARIANTS = {
"a6": {
"name": "旧澳六合",
"aggregate_tag": "彩票",
"game_type": 2032,
"issue_info_url": "https://dokv.buyacard.cc/kv/gr/a6/issue/currentInfo",
"recommend_url": "https://ocs.ai4funs.com/pwtkprd/tk/pw02tk02/issue/a6/recommend",
"serial_list_url": "https://ocs.ai4funs.com/pwtkprd/tk/pw02tk02/a6/serialList",
},
"xa6": {
"name": "新澳六合",
"aggregate_tag": "彩票",
"game_type": 5,
"issue_info_url": "https://dokv.buyacard.cc/kv/gr/xa6/issue/currentInfo",
"recommend_url": "https://ocs.ai4funs.com/pwtkprd/tk/pw02tk02/issue/xa6/recommend",
"serial_list_url": "https://ocs.ai4funs.com/pwtkprd/tk/pw02tk02/xa6/serialList",
},
}
def table(prefix: str, name: str) -> str:
return f"`{prefix}{name}`"
def astro_decode(value):
if isinstance(value, list) and len(value) == 2 and isinstance(value[0], int):
value_type, payload = value
if value_type == 0:
if isinstance(payload, dict):
return {key: astro_decode(val) for key, val in payload.items()}
return payload
if value_type == 1:
return [astro_decode(item) for item in payload]
if value_type in (2, 3, 6, 7):
return payload
if value_type in (4, 5):
return [astro_decode(item) for item in payload]
if value_type in (8, 9, 10):
return payload
return payload
if isinstance(value, dict):
return {key: astro_decode(val) for key, val in value.items()}
if isinstance(value, list):
return [astro_decode(item) for item in value]
return value
def fetch_home_html(session: requests.Session) -> str:
resp = session.get(BASE_URL, timeout=30)
resp.raise_for_status()
return resp.content.decode("utf-8", errors="replace")
def extract_cdn_host(page_html: str) -> str:
match = re.search(r"window\.CDN_HOST\s*=\s*['\"]([^'\"]+)['\"]", page_html)
return match.group(1).rstrip("/") if match else CDN_FALLBACK
def extract_home_data(page_html: str) -> dict:
for match in re.finditer(r"<astro-island\b[^>]*\bprops=([\"'])(.*?)\1", page_html, re.S):
props_raw = html.unescape(match.group(2))
try:
props = astro_decode(json.loads(props_raw))
except json.JSONDecodeError:
continue
data = props.get("data") if isinstance(props, dict) else None
if isinstance(data, dict) and (data.get("lotteryResult") or data.get("galleryResult")):
return data
return {}
def request_json(session: requests.Session, url: str) -> dict:
resp = session.get(url, timeout=30)
resp.raise_for_status()
return resp.json()
WUXING_MAP = {"j": "金", "m": "木", "s": "水", "h": "火", "t": "土"}
ODD_EVEN_MAP = {"o": "单", "e": "双"}
SIZE_MAP = {"b": "大", "s": "小"}
def color_text(color: str) -> str:
return {"red": "红波", "blue": "蓝波", "green": "绿波", "R": "红波", "G": "绿波", "B": "蓝波"}.get(color, color or "")
def format_time(value) -> str:
if not value:
return ""
if isinstance(value, str) and re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$", value):
return value
try:
ts = int(value)
except (TypeError, ValueError):
return ""
if ts > 100000000000:
ts = ts // 1000
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
def image_url(path: str, cdn_host: str) -> str:
if not path:
return ""
if path.startswith("http://") or path.startswith("https://"):
return path
normalized = path.lstrip("/")
if normalized.startswith("prod/") or normalized.startswith("prodmedia/"):
return urljoin(IMAGE_CDN_HOST + "/", normalized)
return urljoin(cdn_host.rstrip("/") + "/", normalized)
def int_value(value) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def is_retryable_db_error(exc: Exception) -> bool:
return isinstance(exc, pymysql.err.OperationalError) and len(exc.args) > 0 and exc.args[0] in LOCK_WAIT_RETRY_CODES
def build_draw_post(detail: dict, lottery_key: str) -> list[dict]:
if not isinstance(detail, dict):
return []
issue = detail.get("currentCompleteIssue") or detail.get("currentIssue") or detail.get("issue")
if not issue:
return []
numbers = detail.get("processedOpenCode") or detail.get("openCode") or detail.get("currentResult") or []
year = detail.get("currentYear") or detail.get("year", "")
lines = ["【49宝典开奖】", f"{year}年第{issue}期"]
for index, number in enumerate(numbers):
label = "特码" if index == len(numbers) - 1 else f"第{index + 1}球"
five_el = number.get("ws") or number.get("fiveElements") or ""
five_el = WUXING_MAP.get(five_el, five_el)
sz = number.get("size", "")
sz = SIZE_MAP.get(sz, sz)
oe = number.get("parity") or number.get("oddEven") or ""
oe = ODD_EVEN_MAP.get(oe, oe)
attrs = [
str(number.get("value") or number.get("num") or ""),
str(number.get("pet") or number.get("shengxiao") or ""),
str(five_el),
color_text(str(number.get("color", ""))),
str(oe),
str(number.get("combinedParity", "")),
str(sz),
str(number.get("tailSize", "")),
]
lines.append(f"{label}" + " ".join([item for item in attrs if item]))
total_parity = detail.get("totalParity")
total_size = detail.get("totalSize")
if total_parity or total_size:
lines.append(f"总和:{total_parity or ''} {total_size or ''}".strip())
next_issue = detail.get("nextCompleteIssue") or detail.get("nextIssue")
if next_issue:
lines.append(f"下期:{next_issue}")
next_time = format_time(detail.get("nextOpenTime") or detail.get("nextTime"))
if next_time:
lines.append(f"下期开奖时间:{next_time}")
return [{
"origin_id": f"{SOURCE}:draw:{lottery_key}:{issue}",
"content": "\n".join(lines),
"images": [],
"like_count": 0,
"ext": {"type": "draw", "source": SOURCE, "lottery_key": lottery_key, "raw": detail},
"create_time": int(time.time()),
}]
def build_gallery_posts(items: list[dict], lottery_key: str, cdn_host: str) -> list[dict]:
if not isinstance(items, list):
return []
posts = []
for item in items:
if not isinstance(item, dict):
continue
target_id = str(item.get("targetId") or item.get("newspaperCode") or "")
if not target_id:
raw_key = json.dumps(item, ensure_ascii=False, sort_keys=True)
target_id = hashlib.md5(raw_key.encode("utf-8")).hexdigest()
images = []
for image in item.get("img") or []:
if isinstance(image, dict):
url = image_url(str(image.get("url") or ""), cdn_host)
if url:
images.append(url)
author = item.get("author") if isinstance(item.get("author"), dict) else {}
lines = [f"【{item.get('title') or '49宝典资料'}】"]
if item.get("year") or item.get("issue"):
lines.append(f"期号:{item.get('year', '')}年第{item.get('issue', '')}期")
if item.get("serialName"):
lines.append(f"系列:{item.get('serialName')}")
posts.append({
"origin_id": f"{SOURCE}:gallery:{lottery_key}:{target_id}",
"legacy_origin_ids": [f"{SOURCE}:gallery:{target_id}"] if lottery_key == "a6" else [],
"content": "\n".join(lines),
"images": images,
"like_count": int_value(item.get("totalLikeCount")),
"ext": {"type": "gallery", "source": SOURCE, "lottery_key": lottery_key, "raw": item},
"create_time": int(time.time()),
})
return posts
def build_posts(issue_detail: dict, gallery_items: list[dict], lottery_key: str, cdn_host: str) -> list[dict]:
posts = []
posts.extend(build_draw_post(issue_detail, lottery_key))
posts.extend(build_gallery_posts(gallery_items, lottery_key, cdn_host))
return posts
def ensure_user(conn, prefix: str) -> int:
with conn.cursor() as cur:
cur.execute(f"SELECT id FROM {table(prefix, 'user')} WHERE account=%s LIMIT 1", (USER_ACCOUNT,))
row = cur.fetchone()
if row:
return int(row["id"])
cur.execute(f"SELECT MAX(sn) AS max_sn FROM {table(prefix, 'user')}")
max_sn = int((cur.fetchone() or {}).get("max_sn") or 0)
sn = max(USER_SN_START, max_sn + 1)
now = int(time.time())
cur.execute(
f"""INSERT INTO {table(prefix, 'user')}
(sn, avatar, real_name, nickname, account, password, mobile, sex, channel, is_disable,
login_ip, login_time, is_new_user, is_vip, vip_level, vip_expire_time, ai_free_count,
invite_code, inviter_id, user_money, user_points, total_recharge_amount, create_time, update_time)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(sn, "", "", USER_NICKNAME, USER_ACCOUNT, "", "", 0, 0, 0,
"", 0, 0, 0, 0, 0, 3, "", 0, 0, 0, 0, now, now)
)
return int(cur.lastrowid)
def ensure_tag(conn, prefix: str, tag_name: str) -> int:
with conn.cursor() as cur:
cur.execute(f"SELECT id, status FROM {table(prefix, 'community_tag')} WHERE name=%s LIMIT 1", (tag_name,))
row = cur.fetchone()
now = int(time.time())
if row:
tag_id = int(row["id"])
if int(row.get("status") or 0) != 1:
cur.execute(f"UPDATE {table(prefix, 'community_tag')} SET status=1 WHERE id=%s", (tag_id,))
return tag_id
cur.execute(
f"""INSERT INTO {table(prefix, 'community_tag')}
(name, icon, sort, post_count, is_hot, status, create_time)
VALUES (%s,%s,%s,%s,%s,%s,%s)""",
(tag_name, "", 10, 0, 1, 1, now)
)
return int(cur.lastrowid)
def save_post_images(conn, prefix: str, post_id: int, images: list[str], create_time: int):
with conn.cursor() as cur:
cur.execute(f"DELETE FROM {table(prefix, 'community_post_image')} WHERE post_id=%s", (post_id,))
for index, url in enumerate(images):
cur.execute(
f"INSERT INTO {table(prefix, 'community_post_image')} (post_id, image_url, sort, create_time) VALUES (%s,%s,%s,%s)",
(post_id, url, index, create_time)
)
def ensure_post_tag(conn, prefix: str, post_id: int, tag_id: int):
with conn.cursor() as cur:
cur.execute(
f"INSERT IGNORE INTO {table(prefix, 'community_post_tag')} (post_id, tag_id) VALUES (%s,%s)",
(post_id, tag_id)
)
def update_tag_count(conn, prefix: str, tag_id: int):
with conn.cursor() as cur:
cur.execute(
f"""UPDATE {table(prefix, 'community_tag')} SET post_count=(
SELECT COUNT(*) FROM {table(prefix, 'community_post_tag')} pt
INNER JOIN {table(prefix, 'community_post')} p ON p.id=pt.post_id
WHERE pt.tag_id=%s AND p.status=1 AND p.delete_time IS NULL
) WHERE id=%s""",
(tag_id, tag_id)
)
def sync_to_db(conn, prefix: str, posts: list[dict], user_id: int, tag_ids: list[int]) -> tuple[int, int]:
inserted = 0
updated = 0
now = int(time.time())
for post in posts:
images = post.get("images") or []
images_json = json.dumps(images, ensure_ascii=False)
ext_json = json.dumps(post.get("ext") or {}, ensure_ascii=False)
create_time = int(post.get("create_time") or now)
legacy_origin_ids = [item for item in (post.get("legacy_origin_ids") or []) if item]
post_id = 0
for attempt in range(3):
try:
with conn.cursor() as cur:
cur.execute(
f"SELECT id FROM {table(prefix, 'community_post')} WHERE origin_id=%s LIMIT 1",
(post["origin_id"],)
)
existing = cur.fetchone()
if not existing and legacy_origin_ids:
placeholders = ",".join(["%s"] * len(legacy_origin_ids))
cur.execute(
f"SELECT id FROM {table(prefix, 'community_post')} WHERE origin_id IN ({placeholders}) ORDER BY id DESC LIMIT 1",
tuple(legacy_origin_ids)
)
existing = cur.fetchone()
if existing:
post_id = int(existing["id"])
cur.execute(
f"""UPDATE {table(prefix, 'community_post')}
SET origin_id=%s, user_id=%s, content=%s, images=%s, ext=%s, like_count=%s, status=1, update_time=%s
WHERE id=%s""",
(post["origin_id"], user_id, post["content"], images_json, ext_json, int_value(post.get("like_count")), now, post_id)
)
updated += 1
else:
cur.execute(
f"""INSERT INTO {table(prefix, 'community_post')}
(origin_id, user_id, content, images, post_type, is_paid, price_points, free_content_len,
like_count, ext, status, create_time, update_time)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(post["origin_id"], user_id, post["content"], images_json, 0, 0, 0, 100,
int_value(post.get("like_count")), ext_json, 1, create_time, now)
)
post_id = int(cur.lastrowid)
inserted += 1
break
except Exception as exc:
if not is_retryable_db_error(exc) or attempt == 2:
raise
conn.rollback()
time.sleep(0.5 * (attempt + 1))
for tag_id in tag_ids:
ensure_post_tag(conn, prefix, post_id, tag_id)
save_post_images(conn, prefix, post_id, images, create_time)
for tag_id in tag_ids:
update_tag_count(conn, prefix, tag_id)
return inserted, updated
def load_variant_payload(session: requests.Session, lottery_key: str) -> tuple[dict, list[dict]]:
variant = LOTTERY_VARIANTS[lottery_key]
issue_detail = request_json(session, variant["issue_info_url"])
gallery_payload = request_json(session, variant["recommend_url"])
gallery_items = gallery_payload.get("issueList") or []
return issue_detail, gallery_items
def run(db_config: dict):
config = dict(db_config)
prefix = config.pop("prefix", "la_")
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Referer": BASE_URL,
})
page_html = fetch_home_html(session)
cdn_host = extract_cdn_host(page_html)
data = extract_home_data(page_html)
if not data:
raise RuntimeError("未解析到49宝典首页props数据")
conn = pymysql.connect(**config, cursorclass=pymysql.cursors.DictCursor)
try:
user_id = ensure_user(conn, prefix)
aggregate_tag_id = ensure_tag(conn, prefix, "彩票")
total_inserted = 0
total_updated = 0
total_candidates = 0
for lottery_key, variant in LOTTERY_VARIANTS.items():
issue_detail, gallery_items = load_variant_payload(session, lottery_key)
posts = build_posts(issue_detail, gallery_items, lottery_key, cdn_host)
print(f"{variant['name']} 解析到 {len(posts)} 条49宝典数据")
total_candidates += len(posts)
if not posts:
continue
tag_id = ensure_tag(conn, prefix, variant["name"])
inserted, updated = sync_to_db(conn, prefix, posts, user_id, [aggregate_tag_id, tag_id])
conn.commit()
total_inserted += inserted
total_updated += updated
summary = f"发布用户ID {user_id}, 聚合分类ID {aggregate_tag_id}, 新增 {total_inserted} 条, 更新 {total_updated} 条"
print(summary)
return {"success": True, "candidate_count": total_candidates, "saved_count": total_inserted + total_updated, "summary_text": summary}
except Exception:
conn.rollback()
raise
finally:
conn.close()