407 lines
15 KiB
Python
407 lines
15 KiB
Python
"""
|
|
FIFA 世界杯资讯采集模块 - 从 FIFA CXM API 获取世界杯相关新闻并写入 la_article 表
|
|
在 main.py 中通过 fifa_worldcup_news 命令调用
|
|
"""
|
|
import html
|
|
import json
|
|
import re
|
|
import time
|
|
import zlib
|
|
from datetime import datetime, timezone
|
|
from urllib.parse import urljoin
|
|
|
|
import pymysql
|
|
import requests
|
|
from article_publish_helper import has_publishable_article_content, resolve_crawled_article_is_show
|
|
|
|
BASE_URL = "https://cxm-api.fifa.com/fifaplusweb/api"
|
|
LOCALE = "en"
|
|
TOURNAMENT_PATH = "/en/tournaments/mens/worldcup/canadamexicousa2026"
|
|
TOURNAMENT_PAGE_URL = f"{BASE_URL}/pages{TOURNAMENT_PATH}"
|
|
NEWS_SECTION_URL = f"{BASE_URL}/sections/news/1aQDyhkYnKhkAW347zYi4Y?locale={LOCALE}"
|
|
ARTICLE_SECTION_URL = f"{BASE_URL}/sections/article/{{entry_id}}?locale={LOCALE}"
|
|
DEFAULT_CATEGORY_NAME = "世界杯"
|
|
DEFAULT_CATEGORY_SORT = 999
|
|
TITLE_MAX_LEN = 255
|
|
DESC_MAX_LEN = 255
|
|
ABSTRACT_MAX_LEN = 500
|
|
IMAGE_MAX_LEN = 128
|
|
AUTHOR_MAX_LEN = 255
|
|
PUBLISHED_AT_MAX_LEN = 30
|
|
CATEGORY_MAX_LEN = 50
|
|
SOURCE_URL_MAX_LEN = 500
|
|
|
|
|
|
def normalize_prefix(prefix: str) -> str:
|
|
prefix = prefix or "la_"
|
|
return prefix if re.match(r"^[A-Za-z0-9_]+$", prefix) else "la_"
|
|
|
|
|
|
def truncate(value: str, limit: int) -> str:
|
|
return (value or "")[:limit]
|
|
|
|
|
|
def console_print(message: str) -> None:
|
|
try:
|
|
print(message)
|
|
except UnicodeEncodeError:
|
|
print(message.encode("utf-8", "backslashreplace").decode("utf-8"))
|
|
|
|
|
|
def strip_html(text: str) -> str:
|
|
text = re.sub(r"<[^>]+>", "", text or "")
|
|
text = html.unescape(text)
|
|
text = text.replace("\xa0", " ")
|
|
return text.strip()
|
|
|
|
|
|
def parse_iso8601(date_str: str) -> tuple[int, str]:
|
|
if not date_str:
|
|
now = int(time.time())
|
|
return now, datetime.fromtimestamp(now).strftime("%Y-%m-%d %H:%M:%S")
|
|
try:
|
|
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
|
return int(dt.timestamp()), dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
except Exception:
|
|
now = int(time.time())
|
|
return now, datetime.fromtimestamp(now).strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
def stable_article_id(entry_id: str) -> int:
|
|
"""将 FIFA entryId 映射成稳定的负整数,避开现有正整数 article_id。"""
|
|
if not entry_id:
|
|
return 0
|
|
return -int(zlib.crc32(f"fifa_worldcup:{entry_id}".encode("utf-8")) & 0x7FFFFFFF)
|
|
|
|
|
|
def get_headers() -> dict:
|
|
return {
|
|
"User-Agent": "Mozilla/5.0 (compatible; SportEra/1.0)",
|
|
"Accept": "application/json, text/plain, */*",
|
|
"Origin": "https://www.fifa.com",
|
|
"Referer": "https://www.fifa.com" + TOURNAMENT_PATH,
|
|
}
|
|
|
|
|
|
def node_text(node) -> str:
|
|
if node is None:
|
|
return ""
|
|
if isinstance(node, str):
|
|
return node
|
|
node_type = node.get("nodeType", "")
|
|
content = node.get("content", []) or []
|
|
if node_type == "text":
|
|
return html.escape(str(node.get("value", "")))
|
|
if node_type == "hyperlink":
|
|
href = node.get("data", {}).get("uri", "")
|
|
inner = "".join(node_text(child) for child in content)
|
|
if href:
|
|
return f'<a href="{html.escape(href, quote=True)}">{inner}</a>'
|
|
return inner
|
|
if node_type in ("paragraph", "heading-1", "heading-2", "heading-3", "heading-4", "heading-5", "heading-6"):
|
|
inner = "".join(node_text(child) for child in content).strip()
|
|
if not inner:
|
|
return ""
|
|
if node_type.startswith("heading-"):
|
|
level = node_type.split("-")[-1]
|
|
return f"<h{level}>{inner}</h{level}>"
|
|
return f"<p>{inner}</p>"
|
|
if node_type == "unordered-list":
|
|
items = [node_text(child) for child in content if node_text(child)]
|
|
if not items:
|
|
return ""
|
|
return "<ul>" + "".join(items) + "</ul>"
|
|
if node_type == "list-item":
|
|
inner = "".join(node_text(child) for child in content).strip()
|
|
return f"<li>{inner}</li>" if inner else ""
|
|
if node_type == "embedded-entry-block":
|
|
target = node.get("data", {}).get("target", {}) or {}
|
|
images = target.get("bynderImage") or []
|
|
if isinstance(images, list) and images:
|
|
image = images[0] or {}
|
|
src = image.get("src") or image.get("original") or image.get("transformBaseUrl") or ""
|
|
alt = image.get("title") or image.get("description") or ""
|
|
if src:
|
|
return f'<p><img src="{html.escape(src, quote=True)}" alt="{html.escape(str(alt), quote=True)}" /></p>'
|
|
url = target.get("url") or ""
|
|
if url:
|
|
text = target.get("title") or url
|
|
return f'<p><a href="{html.escape(url, quote=True)}">{html.escape(str(text))}</a></p>'
|
|
return ""
|
|
if node_type == "document":
|
|
parts = [node_text(child) for child in content]
|
|
return "\n".join(part for part in parts if part)
|
|
parts = [node_text(child) for child in content]
|
|
return "".join(parts)
|
|
|
|
|
|
def html_to_text(value: str) -> str:
|
|
return strip_html(value)
|
|
|
|
|
|
def fetch_tournament_page(session: requests.Session) -> dict:
|
|
resp = session.get(TOURNAMENT_PAGE_URL, timeout=30)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
def fetch_news_section(session: requests.Session) -> dict:
|
|
resp = session.get(NEWS_SECTION_URL, timeout=30)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
def fetch_article_detail(session: requests.Session, entry_id: str) -> dict:
|
|
url = ARTICLE_SECTION_URL.format(entry_id=entry_id)
|
|
resp = session.get(url, timeout=30)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
def extract_article_detail(entry_json: dict) -> dict:
|
|
if not isinstance(entry_json, dict):
|
|
return {}
|
|
props = entry_json.get("properties") or {}
|
|
richtext = props.get("richtext") or entry_json.get("richtext") or {}
|
|
hero_image = (
|
|
props.get("heroImage")
|
|
or props.get("previewImage")
|
|
or entry_json.get("heroImage")
|
|
or entry_json.get("previewImage")
|
|
or {}
|
|
)
|
|
article_title = props.get("articleTitle") or entry_json.get("articleTitle") or entry_json.get("title") or ""
|
|
article_published = props.get("articlePublishedDate") or entry_json.get("articlePublishedDate") or ""
|
|
preview_text = props.get("articlePreviewText") or entry_json.get("articlePreviewText") or ""
|
|
content_html = node_text(richtext) if richtext else ""
|
|
image = ""
|
|
if isinstance(hero_image, dict):
|
|
image = hero_image.get("src") or hero_image.get("url") or hero_image.get("transformBaseUrl") or ""
|
|
return {
|
|
"title": article_title,
|
|
"desc": html_to_text(preview_text)[:255],
|
|
"content": content_html or (html.escape(html_to_text(preview_text)) if preview_text else ""),
|
|
"image": image,
|
|
"published_at": article_published,
|
|
}
|
|
|
|
|
|
def fetch_articles(session: requests.Session, section: dict) -> list[dict]:
|
|
items = []
|
|
for raw in section.get("items", []) or []:
|
|
entry_id = (raw.get("entryId") or "").strip()
|
|
slug = (raw.get("slug") or "").strip()
|
|
title = (raw.get("title") or "").strip()
|
|
if not entry_id or not slug:
|
|
continue
|
|
|
|
page_url = urljoin("https://www.fifa.com", raw.get("articlePageUrl") or f"{TOURNAMENT_PATH}/articles/{slug}")
|
|
preview_text = html_to_text(raw.get("previewText") or "")[:255]
|
|
published_at = raw.get("publishedDate") or ""
|
|
image = ""
|
|
img = raw.get("image") or {}
|
|
if isinstance(img, dict):
|
|
image = img.get("src") or img.get("url") or img.get("transformBaseUrl") or ""
|
|
|
|
try:
|
|
detail_json = fetch_article_detail(session, entry_id)
|
|
detail = extract_article_detail(detail_json)
|
|
except Exception:
|
|
detail = {}
|
|
|
|
if detail.get("title"):
|
|
title = detail["title"]
|
|
article_image = detail.get("image") or image
|
|
publish_ts, publish_fmt = parse_iso8601(detail.get("published_at") or published_at)
|
|
content = detail.get("content") or html.escape(preview_text)
|
|
|
|
items.append({
|
|
"entry_id": entry_id,
|
|
"title": truncate(title, TITLE_MAX_LEN),
|
|
"desc": truncate(detail.get("desc") or preview_text, DESC_MAX_LEN),
|
|
"content": content,
|
|
"source_url": page_url,
|
|
"image": article_image,
|
|
"author": "FIFA",
|
|
"published_ts": publish_ts,
|
|
"published_at": publish_fmt,
|
|
"category": "World Cup",
|
|
"ext": {
|
|
"source": "fifa_cxm",
|
|
"entry_id": entry_id,
|
|
"slug": slug,
|
|
"article_page_url": page_url,
|
|
"tournament_path": TOURNAMENT_PATH,
|
|
},
|
|
})
|
|
return items
|
|
|
|
|
|
def ensure_worldcup_category(conn, prefix: str) -> int:
|
|
cate_table = f"{prefix}article_cate"
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"SELECT id FROM `{cate_table}` WHERE delete_time IS NULL AND name = %s LIMIT 1",
|
|
(DEFAULT_CATEGORY_NAME,)
|
|
)
|
|
row = cur.fetchone()
|
|
now = int(time.time())
|
|
if row:
|
|
cid = int(row["id"])
|
|
cur.execute(
|
|
f"UPDATE `{cate_table}` SET is_show = 1, sort = %s, update_time = %s WHERE id = %s",
|
|
(DEFAULT_CATEGORY_SORT, now, cid)
|
|
)
|
|
return cid
|
|
|
|
cur.execute(
|
|
f"""INSERT INTO `{cate_table}` (pid, name, sort, is_show, create_time, update_time)
|
|
VALUES (0, %s, %s, 1, %s, %s)""",
|
|
(DEFAULT_CATEGORY_NAME, DEFAULT_CATEGORY_SORT, now, now)
|
|
)
|
|
return cur.lastrowid
|
|
|
|
|
|
def sync_to_db(conn, articles: list[dict], cid: int, prefix: str) -> tuple[int, int, int]:
|
|
if not articles:
|
|
return 0, 0, 0
|
|
|
|
urls = [a["source_url"] for a in articles if a.get("source_url")]
|
|
if not urls:
|
|
return 0, 0, 0
|
|
|
|
article_table = f"{prefix}article"
|
|
|
|
with conn.cursor() as cur:
|
|
placeholders = ",".join(["%s"] * len(urls))
|
|
cur.execute(
|
|
f"SELECT id, source_url, content, is_show FROM `{article_table}` WHERE source_url IN ({placeholders}) AND cid = %s",
|
|
urls + [cid]
|
|
)
|
|
existing = {row["source_url"]: row for row in cur.fetchall()}
|
|
|
|
titles = [a["title"][:255] for a in articles if a.get("title")]
|
|
existing_titles = set()
|
|
if titles:
|
|
with conn.cursor() as cur:
|
|
t_placeholders = ",".join(["%s"] * len(titles))
|
|
cur.execute(
|
|
f"SELECT title FROM `{article_table}` WHERE title IN ({t_placeholders}) AND cid = %s",
|
|
titles + [cid]
|
|
)
|
|
existing_titles = {row["title"] for row in cur.fetchall()}
|
|
|
|
new_count = 0
|
|
update_count = 0
|
|
now = int(time.time())
|
|
|
|
for article in articles:
|
|
source_url = article.get("source_url", "")
|
|
title = (article.get("title") or "").strip()
|
|
if not source_url or not title:
|
|
continue
|
|
|
|
create_ts = article.get("published_ts") or now
|
|
article_id = stable_article_id(article.get("entry_id", ""))
|
|
ext_json = json.dumps(article.get("ext", {}), ensure_ascii=False) if article.get("ext") else None
|
|
image = truncate(article.get("image", ""), IMAGE_MAX_LEN)
|
|
author = truncate(article.get("author") or "", AUTHOR_MAX_LEN)
|
|
category = truncate(article.get("category") or "", CATEGORY_MAX_LEN)
|
|
published_at = truncate(article.get("published_at") or "", PUBLISHED_AT_MAX_LEN)
|
|
desc = truncate(article.get("desc") or "", DESC_MAX_LEN)
|
|
abstract = truncate(article.get("desc") or "", ABSTRACT_MAX_LEN)
|
|
source_url = truncate(source_url, SOURCE_URL_MAX_LEN)
|
|
|
|
if source_url in existing:
|
|
existing_row = existing[source_url]
|
|
next_is_show = existing_row["is_show"] if has_publishable_article_content(existing_row.get("content", "")) else 0
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"""UPDATE `{article_table}` SET
|
|
title = %s, `desc` = %s, image = %s, author = %s,
|
|
category = %s, ext = %s, is_show = %s, update_time = %s
|
|
WHERE id = %s""",
|
|
(
|
|
truncate(title, TITLE_MAX_LEN),
|
|
desc,
|
|
image,
|
|
author,
|
|
category,
|
|
ext_json,
|
|
next_is_show,
|
|
now,
|
|
existing_row["id"],
|
|
)
|
|
)
|
|
update_count += 1
|
|
else:
|
|
if title[:255] in existing_titles:
|
|
continue
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"""INSERT INTO `{article_table}`
|
|
(article_id, cid, title, `desc`, abstract, image, author, published_at,
|
|
category, source_url, content, ext, is_show, sort,
|
|
click_virtual, click_actual, create_time, update_time)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
|
(
|
|
article_id,
|
|
cid,
|
|
truncate(title, TITLE_MAX_LEN),
|
|
desc,
|
|
abstract,
|
|
image,
|
|
author,
|
|
published_at,
|
|
category,
|
|
source_url,
|
|
article.get("content") or "",
|
|
ext_json,
|
|
resolve_crawled_article_is_show(article.get("content") or ""),
|
|
0,
|
|
0,
|
|
0,
|
|
create_ts,
|
|
now,
|
|
)
|
|
)
|
|
existing[source_url] = {"id": cur.lastrowid, "content": article.get("content") or "", "is_show": resolve_crawled_article_is_show(article.get("content") or "")}
|
|
existing_titles.add(title[:255])
|
|
new_count += 1
|
|
|
|
return len(articles), new_count, update_count
|
|
|
|
|
|
def run(db_config: dict):
|
|
db_config = dict(db_config)
|
|
prefix = normalize_prefix(db_config.pop("prefix", "la_"))
|
|
|
|
session = requests.Session()
|
|
session.headers.update(get_headers())
|
|
|
|
try:
|
|
page = fetch_tournament_page(session)
|
|
news_section = fetch_news_section(session)
|
|
articles = fetch_articles(session, news_section)
|
|
console_print(f"世界杯页面: {page.get('meta', {}).get('title', '')}")
|
|
console_print(f"新闻区块: {news_section.get('entryId', '')}, 原始条数: {len(articles)}")
|
|
except Exception as e:
|
|
console_print(f"[WARN] FIFA 页面/新闻区块拉取失败: {e}")
|
|
return {"success": False, "candidate_count": 0, "saved_count": 0, "error_message": str(e), "summary_text": f"FIFA 页面/新闻区块拉取失败: {e}"}
|
|
|
|
if not articles:
|
|
console_print("世界杯资讯无数据,跳过")
|
|
return {"success": True, "candidate_count": 0, "saved_count": 0, "summary_text": "世界杯资讯无数据,跳过"}
|
|
|
|
conn = pymysql.connect(**db_config, cursorclass=pymysql.cursors.DictCursor)
|
|
try:
|
|
cid = ensure_worldcup_category(conn, prefix)
|
|
total, new_count, update_count = sync_to_db(conn, articles, cid, prefix)
|
|
conn.commit()
|
|
console_print(f"分类ID: {cid}")
|
|
summary = f"共 {total} 条, 新增 {new_count}, 更新 {update_count}"
|
|
console_print(summary)
|
|
return {"success": True, "candidate_count": total, "saved_count": new_count + update_count, "summary_text": summary}
|
|
finally:
|
|
conn.close()
|