Spaces:
Sleeping
Sleeping
File size: 7,249 Bytes
a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 5a50374 a2075f6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | """
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)
|