SUT-Tender-API / scraper.py
Therdpoom's picture
Upload scraper.py
5a50374 verified
Raw
History Blame Contribute Delete
7.25 kB
"""
scraper.py
ดึงประกาศจัดซื้อจัดจ้างจากระบบ e-GP ของ มทส. (mis.sut.ac.th)
โครงสร้างจริง (ยืนยันจาก raw HTML ของผู้ใช้):
- Table ข้อมูล: id="myTable", แถวข้อมูล class="LViewer_Row0"/"LViewer_Row1"
คอลัมน์: ลำดับ, เลขที่โครงการ, โค้ดวิธีจัดซื้อ, รายละเอียด, วันที่ประกาศ
มี fileId ฝังใน onclick="goLink('...fileId=XXXX',true)" -> ลิงก์เอกสารประกาศจริง
- Pagination: เป็น ASP.NET WebForm postback (ไม่ใช่ query param ธรรมดา)
form method="get" action="getEGP.aspx" id="aspnetForm"
ต้องส่ง __VIEWSTATE, __VIEWSTATEGENERATOR (อ่านจาก response ก่อนหน้า) กลับไปด้วย
พร้อม FrmCarHdr_PagingMove = '>' (หน้าถัดไป) / '<' (ก่อนหน้า) / '<<' (หน้าแรก) / '>>' (หน้าสุดท้าย)
ทั้งหมดมี 21 หน้า (ตามที่ระบุในตัวเว็บ "หน้าที่ 1 จาก 21 หน้า")
"""
import re
import requests
from bs4 import BeautifulSoup
from dataclasses import dataclass, asdict
from typing import List, Optional
import time
BASE_URL = "https://mis.sut.ac.th/MisPublic/Modules/WebService/getEGP.aspx"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
}
TOTAL_PAGES = 21 # ยืนยันจาก raw HTML: "หน้าที่ ... จาก 21 หน้า"
REQUEST_DELAY_SEC = 0.5 # หน่วงเวลาระหว่าง request เพื่อไม่โหลดเซิร์ฟเวอร์เขามากเกินไป
@dataclass
class TenderItem:
seq: str # ลำดับ
project_code: str # เลขที่โครงการ (เช่น 69069537783)
method_code: str # โค้ดวิธีจัดซื้อ (เช่น 15, B0, D0 ... ดูคำอธิบายโค้ดด้านบนตาราง)
title: str # รายละเอียดโครงการ (มีคำว่าวิธีจัดซื้อ เช่น e-bidding ปนอยู่ในข้อความนี้)
announce_date: str # วันที่ประกาศ (พ.ศ., เช่น "29 มิ.ย. 69")
file_url: str # ลิงก์ดาวน์โหลดเอกสารประกาศ (ดึงจาก onclick="goLink(...)")
def fetch_html(extra_params: Optional[dict] = None) -> str:
"""GET request ไปที่หน้า e-GP พร้อม encoding cp874 ที่ถูกต้อง"""
params = {"TotalListView": "yes"}
if extra_params:
params.update(extra_params)
resp = requests.get(BASE_URL, params=params, headers=HEADERS, timeout=30)
resp.encoding = "cp874" # สำคัญมาก: ไม่ใส่จะได้ภาษาไทยเพี้ยนเป็น "?"
return resp.text
def extract_viewstate(html: str) -> dict:
"""ดึง __VIEWSTATE และ __VIEWSTATEGENERATOR จาก HTML เพื่อใช้ในการเปลี่ยนหน้าถัดไป"""
soup = BeautifulSoup(html, "html.parser")
viewstate_tag = soup.find("input", {"id": "__VIEWSTATE"})
viewstategen_tag = soup.find("input", {"id": "__VIEWSTATEGENERATOR"})
return {
"__VIEWSTATE": viewstate_tag.get("value", "") if viewstate_tag else "",
"__VIEWSTATEGENERATOR": viewstategen_tag.get("value", "") if viewstategen_tag else "",
}
def parse_row(row) -> Optional[TenderItem]:
"""
Parse แถวข้อมูลจริงจากตาราง id="myTable"
ข้ามแถว header (class="Lister_RowHeader") และแถวที่ไม่ใช่ข้อมูล
"""
row_class = row.get("class", [])
if "Lister_RowHeader" in row_class:
return None # ข้ามแถว header
if not any(c.startswith("LViewer_Row") for c in row_class):
return None # ไม่ใช่แถวข้อมูล (เช่น แถว pagination ด้านล่างตาราง)
cells = row.find_all("td")
if len(cells) < 5:
return None
# ดึง fileId/URL จาก onclick="goLink('...',true)"
onclick = row.get("onclick", "")
file_url = ""
if "goLink(" in onclick:
match = re.search(r"goLink\('([^']+)'", onclick)
if match:
file_url = match.group(1)
return TenderItem(
seq=cells[0].get_text(strip=True),
project_code=cells[1].get_text(strip=True),
method_code=cells[2].get_text(strip=True),
title=cells[3].get_text(strip=True),
announce_date=cells[4].get_text(strip=True),
file_url=file_url,
)
def parse_page(html: str) -> List[TenderItem]:
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table", {"id": "myTable"})
if table is None:
return []
rows = table.find_all("tr")
items = [parse_row(r) for r in rows]
return [i for i in items if i is not None]
def scrape_all(max_pages: int = TOTAL_PAGES, keyword_filter: Optional[str] = None) -> List[dict]:
"""
ดึงทุกหน้าโดยจำลอง ASP.NET postback pagination:
หน้าแรก -> GET ปกติ
หน้าถัดไป -> GET พร้อม __VIEWSTATE/__VIEWSTATEGENERATOR ของหน้าก่อน + FrmCarHdr_PagingMove='>'
"""
results: List[TenderItem] = []
html = fetch_html()
results.extend(parse_page(html))
for page_num in range(2, max_pages + 1):
state = extract_viewstate(html)
if not state["__VIEWSTATE"]:
# ดึง viewstate ไม่ได้ -> หยุด ไม่งั้น server จะปฏิเสธ/วนซ้ำหน้าเดิม
break
params = {
"__VIEWSTATE": state["__VIEWSTATE"],
"__VIEWSTATEGENERATOR": state["__VIEWSTATEGENERATOR"],
"FrmCarHdr_PagingMove": ">",
}
time.sleep(REQUEST_DELAY_SEC)
html = fetch_html(params)
page_items = parse_page(html)
if not page_items:
break # ไม่มีข้อมูลเพิ่มแล้ว
results.extend(page_items)
if keyword_filter:
kw = keyword_filter.lower()
results = [r for r in results if kw in r.title.lower()]
return [asdict(r) for r in results]
if __name__ == "__main__":
# ทดสอบดึงแค่ 3 หน้าแรกก่อน (กัน spam เซิร์ฟเวอร์เขาตอน dev)
data = scrape_all(max_pages=3)
print(f"ดึงได้ {len(data)} รายการ")
for d in data[:5]:
print(d)