import hashlib from datetime import datetime from typing import Any from urllib.parse import urljoin from zoneinfo import ZoneInfo _TZ = ZoneInfo("Asia/Shanghai") from bs4 import BeautifulSoup def parse_gxgp_api_response( response_data: dict[str, Any], source_code: str, source_name: str, crawled_at: datetime, category_id: int, ) -> list[dict[str, Any]]: if not response_data.get("success"): return [] data = response_data.get("result", {}).get("data", {}) records = data.get("data", []) if not records: return [] results = [] for record in records: title = str(record.get("title", "")).strip() if not title: continue timestamp = record.get("publishDate") if not timestamp: continue try: publish_date = datetime.fromtimestamp(int(timestamp) / 1000, tz=_TZ).replace(tzinfo=None) except (ValueError, TypeError): continue purchase_name = str(record.get("purchaseName", "")).strip() article_id = record.get("articleId") if not article_id: continue content_url = ( f"https://zfcg.gxzf.gov.cn/site/detail?" f"parentId={category_id}&articleId={article_id}" ) announce = { "title": title, "publish_date": publish_date, "purchase_name": purchase_name, "content_url": content_url, "source_code": source_code, "source_name": source_name, "announcement_type": "purchase", "crawl_mode": "auto", "is_new": True, "is_today": publish_date.date() == datetime.now().date(), } announce["content_hash"] = _generate_hash(announce) results.append(announce) return results def extract_pagination(response_data: dict[str, Any]) -> dict[str, Any]: data = response_data.get("result", {}).get("data", {}) return { "total": data.get("total", 0), "page_no": data.get("pageNo", 1), "page_size": data.get("pageSize", 100), "pages": data.get("pages", 0), "empty": data.get("empty", True), "has_next": data.get("hasNext", False), "has_previous": data.get("hasPrevious", False), } def parse_dahuagov_html(html: str, crawled_at: datetime) -> list[dict[str, Any]]: soup = BeautifulSoup(html, "html.parser") lists = soup.find_all("ul", class_="more-list") if not lists: return [] results = [] base_url = "http://www.gxdh.gov.cn" base_path = "/xxgk/zdlyxxgk/ggzypzly/zfcgly/cggg/" for ul in lists: for li in ul.find_all("li"): date_span = li.find("span") if not date_span: continue date_text = date_span.get_text(strip=True) try: publish_date = datetime.strptime(date_text, "%Y-%m-%d") except ValueError: continue link_tag = li.find("a") if not link_tag: continue title = link_tag.get("title", "") or link_tag.get_text(strip=True) href = link_tag.get("href", "") if not title or not href: continue if href.startswith("./") or href.startswith("../"): content_url = urljoin(base_url + base_path, href) elif href.startswith("/"): content_url = base_url + href elif href.startswith("http"): content_url = href else: content_url = urljoin(base_url + base_path, href) announce = { "title": title, "publish_date": publish_date, "purchase_name": "大化瑶族自治县", "content_url": content_url, "source_code": "dahuagov", "source_name": "大化县政府网采购公告", "announcement_type": "purchase", "crawl_mode": "auto", "is_new": True, "is_today": publish_date.date() == datetime.now().date(), } announce["content_hash"] = _generate_hash(announce) results.append(announce) return results def parse_dahuagov_detail_pubdate(html: str) -> datetime | None: """从详情页 解析精确发布时间""" soup = BeautifulSoup(html, "html.parser") meta = soup.find("meta", attrs={"name": "PubDate"}) if not meta: return None content = meta.get("content", "").strip() for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"): try: return datetime.strptime(content, fmt) except ValueError: continue return None async def extract_page_content(url: str, timeout: int = 30) -> str | None: """抓取详情页并用 BeautifulSoup 提取正文纯文本""" import urllib.parse import httpx # ---- 站点专用处理 ---- # ① 政采云 SPA (HTTP API, 无需渲染) if "zfcg.gxzf.gov.cn" in url and "articleId=" in url: return await _extract_zcy_content(url, timeout) # ② 大化县政府网 (静态HTML) if "www.gxdh.gov.cn" in url or "gxdh.gov.cn" in url: return await _extract_dahuagov_content(url, timeout) # ---- 通用 HTML 提取 ---- try: headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html,application/xhtml+xml", "Accept-Language": "zh-CN,zh;q=0.9", } async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: resp = await client.get(url, headers=headers) if resp.status_code != 200: return None soup = BeautifulSoup(resp.text, "html.parser") # 尝试多种常见正文容器选择器 selectors = [ "div.article-content", "div.content", "div.TRS_Editor", "div.Custom_UnionStyle", "div.pages_content", "div#content", "div.main-content", "article", ".article", ".detail-content", ".text-content", ".news-content", ".detail-article", "div.article-con", ".trs_editor_view", ".TRS_UEDITOR", ".trs_paper_default", ".article-content", ] for selector in selectors: container = soup.select_one(selector) if container: for tag in container.find_all(["script", "style"]): tag.decompose() text = container.get_text(separator="\n", strip=True) if len(text) > 50: return text # 兜底:取 body 内所有文本 body = soup.find("body") if body: for tag in body.find_all(["script", "style", "nav", "footer", "header"]): tag.decompose() text = body.get_text(separator="\n", strip=True) lines = [l.strip() for l in text.split("\n") if l.strip()] text = "\n".join(lines[:200]) if len(text) > 50: return text return None except Exception: return None async def _extract_zcy_content(url: str, timeout: int = 30) -> str | None: """从政采云 SPA 隐藏 API 提取公告正文""" import urllib.parse import httpx from bs4 import BeautifulSoup params = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) article_id = params.get("articleId", [None])[0] parent_id = params.get("parentId", [None])[0] if not article_id: return None api_url = "https://zfcg.gxzf.gov.cn/portal/detail" if parent_id: api_url += f"?articleId={article_id}&parentId={parent_id}" else: api_url += f"?articleId={article_id}" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json, text/plain, */*", "Referer": url, } try: async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.get(api_url, headers=headers) if resp.status_code != 200: return None data = resp.json() if not data.get("success"): return None content_html = data.get("result", {}).get("data", {}).get("content", "") if not content_html: return None soup = BeautifulSoup(content_html, "html.parser") for tag in soup.find_all(["script", "style"]): tag.decompose() text = soup.get_text(separator="\n", strip=True) # 清理过短行和多余空白 lines = [l.strip() for l in text.split("\n") if len(l.strip()) > 5] text = "\n".join(lines[:300]) return text if len(text) > 50 else None except Exception: return None async def _extract_dahuagov_content(url: str, timeout: int = 30) -> str | None: """从大化县政府网详情页提取正文""" import httpx from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html,application/xhtml+xml", "Accept-Language": "zh-CN,zh;q=0.9", } try: async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: resp = await client.get(url, headers=headers) if resp.status_code != 200: return None soup = BeautifulSoup(resp.text, "html.parser") # 大化县政府网正文容器 selectors = [ "div.article-con", ".trs_editor_view", ".TRS_UEDITOR", ".trs_paper_default", "div.content", "div.TRS_Editor", "div.article-content", "div.Custom_UnionStyle", "div#content", "div.main-content", "article", ".detail-content", ] for selector in selectors: container = soup.select_one(selector) if container: for tag in container.find_all(["script", "style"]): tag.decompose() text = container.get_text(separator="\n", strip=True) if len(text) > 50: lines = [l.strip() for l in text.split("\n") if l.strip()] return "\n".join(lines[:300]) # 兜底 body = soup.find("body") if body: for tag in body.find_all(["script", "style", "nav", "footer", "header"]): tag.decompose() text = body.get_text(separator="\n", strip=True) lines = [l.strip() for l in text.split("\n") if l.strip()] text = "\n".join(lines[:200]) if len(text) > 50: return text return None except Exception: return None def _generate_hash(ann: dict[str, Any]) -> str: content = ( f"{ann['title']}|{ann['publish_date'].strftime('%Y-%m-%d')}" f"|{ann['purchase_name']}|{ann['content_url']}|{ann['source_code']}" ) return hashlib.sha256(content.encode("utf-8")).hexdigest()