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)