สั่งฟอร์มเราเตอร์ผ่าน CDP แล้ว Apply เงียบ: .value ไม่ติด ต้องพิมพ์
ฟอร์มที่ไม่ฟ้อง error อันตรายกว่าฟอร์มที่พัง เพราะมันทำให้เราคิดว่าสคริปต์ผิดตรงไหนก็ได้ ยกเว้นตรงที่ผิดจริง คือเราเอาค่าไปใส่ในช่อง แต่หน้าเว็บไม่เคยรู้ว่ามีใครพิมพ์อะไรลงไป
สรุปใน 1 นาที
- อาการ/ปัญหา
- สั่งฟอร์มจอง IP ของเราเตอร์ผ่าน CDP ด้วย element.value แล้วกด Apply ไม่เกิดอะไรและไม่มี error
- ใครควรอ่าน
- คนที่ขับหน้า admin ของเราเตอร์หรืออุปกรณ์ด้วย Chrome DevTools Protocol
- ทางแก้แบบสั้น
- ส่ง key event จริงเข้าช่องที่มองเห็นแทนการตั้ง .value และให้สคริปต์ดูก่อน ยืนยัน แล้วค่อยทำ
พอดีผมมีเราเตอร์ไฟเบอร์ของ ISP ตัวหนึ่งที่เป็นของผมเอง หน้า admin มีเมนูจอง IP ตาม MAC (DHCP Binding) ไม่มี API ให้เรียก ผมเลยเขียนสคริปต์ Python ขับ Chrome ผ่าน CDP ให้กรอกฟอร์มแทนมือ ปรากฏว่าสคริปต์รันจบสวย ไม่มี exception คลิก Apply เรียบร้อย แต่รายการจองไม่เกิดขึ้น หน้าเว็บนิ่งสนิท ไม่มีกล่องแดง ไม่มีข้อความ ผมนั่งแก้ selector อยู่หลายรอบก่อนจะรู้ว่าปัญหาไม่ได้อยู่ที่ selector เลย
ชื่อรายการ MAC และ IP ในบทความนี้ผมเปลี่ยนเป็นค่าตัวอย่างทั้งหมด เรื่องนี้เขียนจากหน้า admin ของเราเตอร์ตัวเดียวที่ผมลอง เทคนิคเลยเอาไปใช้กับหน้า admin ที่ทำงานคล้ายกัน แต่ผมไม่ได้ทดสอบกับรุ่นอื่น และไม่ได้เปิดโค้ดฝั่งหน้าเว็บอ่านว่ามันใช้อะไรทำฟอร์ม เหตุผลฝั่ง framework ในบทความเป็นสมมติฐานที่สอดคล้องกับอาการ ไม่ใช่ข้อเท็จจริงที่พิสูจน์แล้ว
อาการ: ใส่ค่าครบ กด Apply แล้วไม่มีอะไรเกิดขึ้น
ฟอร์มจองมีช่อง ชื่อรายการ, MAC และ IP โดย MAC กับ IP แยกเป็นหลายช่องย่อย ช่องละส่วน สคริปต์รอบแรกของผมทำแบบที่ใครก็ทำ คือหา element แล้วตั้ง .value ตรง ๆ
# รอบแรก: ตั้งค่าตรง ๆ ด้วย JS แล้วกด Apply
fill = """
(() => {
const set = (id, v) => { document.getElementById(id).value = v; };
set('name_new', 'living-tv');
'AA:BB:CC:DD:EE:FF'.split(':').forEach((p, i) => set('mac_new_' + i, p));
'192.168.10.50'.split('.').forEach((p, i) => set('ip_new_' + i, p));
document.getElementById('btn_apply').click();
})()
"""
cdp.js(fill)
# คืนค่าปกติ ไม่มี exception ไม่มี error ในคอนโซล
ผลคือเปิดดูหน้าจอแล้วเห็นค่าอยู่ครบในทุกช่อง ตาเห็นชัดว่าเต็ม แต่กด Apply แล้วตารางรายการจองเหมือนเดิม ไม่มีแถวใหม่ ไม่มีข้อความ ส่วนหน้า login ของรุ่นนี้มี captcha รูป ผมเลยเริ่มสคริปต์ตั้งแต่หลัง login เท่านั้น เรื่องนั้นไม่เกี่ยวกับบั๊กนี้

เดาผิดไปสามรอบ
อาการแบบกดแล้วเงียบ ทำให้ผมเดาไปตามนิสัยของคนเขียนสคริปต์ก่อนเลย เรียงตามที่ลองจริง
- เดาว่า selector ผิด เลยไล่เปลี่ยน id เป็น name เป็น CSS path ดูผล ค่าก็ยังไปอยู่ในช่องเหมือนเดิม แปลว่า element ถูกตัวแน่ ๆ
- เดาว่าฟอร์มยังโหลดไม่เสร็จ เลยเติมการรอ element และหน่วงเวลาก่อนกด ผลเหมือนเดิมทุกค่าที่ลอง
- เดาว่าฟอร์มอยู่ใน iframe คนละ context ไล่ดู frame tree ของหน้าแล้วก็ไม่ใช่ ฟอร์มอยู่ในหน้าหลักนี่เอง
สามข้อนี้เสียเวลาไปพอสมควร เพราะทั้งสามข้ออธิบายว่า "ทำไมหาไม่เจอ" แต่ปัญหาของผมคือหาเจอแล้ว ใส่ค่าแล้ว และเห็นค่านั้นบนจอ
พิสูจน์: พิมพ์ด้วยมือได้ ตั้งด้วย JS ไม่ได้
ตัวที่ตัดสินคือการทดลองง่าย ๆ ผมเปิดหน้าเดียวกันด้วยตัวเอง พิมพ์ค่าเดียวกันลงในช่องด้วยแป้นพิมพ์ แล้วกด Apply รายการจองเข้าทันที ฟอร์มเดียวกัน ค่าเดียวกัน ปุ่มเดียวกัน ต่างกันแค่ว่าค่าไปอยู่ในช่องด้วยวิธีไหน
พอรู้แบบนี้ก็เหลือคำอธิบายเดียวที่เข้ากับอาการทั้งหมด คือหน้าฟอร์มไม่ได้อ่านค่าจาก DOM ตอนกด Apply แต่เก็บค่าไว้ในตัวแปรภายในของมันเอง และอัปเดตตัวแปรนั้นเมื่อมี event จากการพิมพ์ การตั้ง .value ผ่าน JS เปลี่ยนแค่สิ่งที่ตาเห็นในช่อง ไม่ได้ยิง keydown หรือ input ให้ใครสักคนรู้ ตัวแปรภายในเลยยังว่าง พอ Apply ตรวจจากตัวแปรที่ว่างก็เลยไม่ทำอะไรโดยไม่ฟ้อง
ผมไม่ได้เปิดโค้ดของหน้าเว็บเพื่อยืนยันว่ามันเป็นแบบ "ค่าอยู่ในโมเดลภายใน" จริง และไม่รู้ว่าใช้ framework อะไร สิ่งที่พิสูจน์แล้วมีแค่สองข้อ คือตั้ง .value แล้วไม่ติด และส่ง key event จริงแล้วติด คำอธิบายที่เหลือเป็นการเดาที่เข้ากับอาการ
วิธีแก้: ส่ง key event จริงเข้าช่องที่มองเห็น
CDP มีคำสั่งกลุ่ม Input.* ที่ส่ง event ในระดับเบราว์เซอร์ เหมือนมีคนกดแป้นจริง ผมใช้สองอย่าง คือ Input.dispatchKeyEvent กดทีละตัวอักษร กับ Input.insertText ที่ใส่ข้อความทีเดียวหลังโฟกัสช่องแล้ว ทั้งสองแบบผมลองแล้วติดทั้งคู่ ต่างกันแค่ความสมจริงของ event ตัวสคริปต์ที่ผมเขียนใหม่ให้อ่านง่ายหน้าตาประมาณนี้ ใช้ websocket-client ล้วน ๆ ไม่พึ่งไลบรารีขับเบราว์เซอร์
# cdp_min.py - ตัวขับ CDP แบบบางที่สุด (pip install websocket-client)
import itertools, json, os, urllib.request
import websocket
class CDP:
def __init__(self, port):
tabs = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json"))
page = next(t for t in tabs if t["type"] == "page")
self.ws = websocket.create_connection(page["webSocketDebuggerUrl"], timeout=15)
self._id = itertools.count(1)
def call(self, method, **params):
i = next(self._id)
self.ws.send(json.dumps({"id": i, "method": method, "params": params}))
while True: # ข้าม event ที่ไม่ใช่คำตอบของเรา
msg = json.loads(self.ws.recv())
if msg.get("id") == i:
if "error" in msg:
raise RuntimeError(msg["error"])
return msg["result"]
def js(self, expr):
r = self.call("Runtime.evaluate", expression=expr, returnByValue=True)
return r["result"].get("value")
def type_text(self, text):
for ch in text: # กดทีละตัว = keyDown + keyUp
self.call("Input.dispatchKeyEvent", type="keyDown", text=ch, key=ch)
self.call("Input.dispatchKeyEvent", type="keyUp", key=ch)
cdp = CDP(int(os.environ["CDP_PORT"])) # พอร์ตของ Chrome ที่เปิด remote debugging
ส่วนที่ต้องระวังอยู่ที่การเลือกช่อง ฟอร์มนี้มี template ซ่อนไว้ชุดหนึ่งสำหรับสร้างแถวใหม่ id ของ template ไม่มีเลข slot ต่อท้าย ส่วนแถวที่ถูกสร้างขึ้นจริงจะได้ id ที่มี slot ต่อท้าย (รูปแบบ ...:N) ถ้าเล็งไปที่ id แรกที่เจอ เราจะพิมพ์ใส่ template ที่มองไม่เห็นและไม่มีใครอ่านค่า ต้องเล็งเฉพาะช่องที่ มองเห็น ของ slot ใหม่
# เลือกเฉพาะช่องที่มองเห็นจริงของ slot ใหม่ แล้วโฟกัสทีละช่อง
FIND = """
(slot) => [...document.querySelectorAll('input')]
.filter(e => e.id.endsWith(':' + slot) && e.offsetParent !== null)
.map(e => e.id)
"""
def fill_field(cdp, field_id, value):
ok = cdp.js(f"""(() => {{
const e = document.getElementById({json.dumps(field_id)});
if (!e || e.offsetParent === null) return false; // ซ่อนอยู่ = ไม่ใช่ตัวที่ต้องการ
e.scrollIntoView({{block: 'center'}});
e.focus(); e.select(); // เลือกค่าเดิมไว้ให้พิมพ์ทับ
return document.activeElement === e;
}})()""")
if not ok:
raise RuntimeError(f"โฟกัสช่อง {field_id} ไม่ได้")
cdp.type_text(value)
ตัวอย่างนี้ id ของช่องเป็นชื่อสมมติ ของจริงแต่ละรุ่นไม่เหมือนกัน ให้เปิด DevTools ดู id ของช่องที่ มองเห็น ในแถวใหม่ก่อน ที่ผมทำจริงคือไล่ทีละช่องตามลำดับ ชื่อ แล้ว MAC แล้ว IP ก่อนกด Apply ครั้งสุดท้าย

หลังเปลี่ยนมาพิมพ์จริง ผมกด Apply แล้วรายการเข้าตารางจริง ส่วนวิธีที่ผมไม่ได้ลองคือสั่ง dispatchEvent(new Event('input')) เองหลังตั้ง .value ในหลักการมันน่าจะใช้ได้กับ framework ที่ฟัง event นั้น แต่ผมไม่ได้ทดสอบกับหน้านี้ เลยบอกไม่ได้ว่าได้ผลหรือไม่
element.value = ...
เปลี่ยนแค่สิ่งที่ตาเห็นในช่อง ไม่มี key หรือ input event ฟอร์มที่ฟังการพิมพ์จะไม่รู้ตัว Apply เลยเงียบ และไม่มี error ให้ไล่
Input.dispatchKeyEvent / insertText
ส่ง event ระดับเบราว์เซอร์เข้าช่องที่โฟกัสอยู่ ฟอร์มได้ event เหมือนคนพิมพ์ ต้องเล็งช่องที่มองเห็นของ slot ใหม่เท่านั้น
ข้อจำกัดที่ล้มแบบเงียบ
แก้เรื่อง event ได้แล้ว ผมเจออีกกลุ่มที่อาการคล้ายกันมากคือกด Apply แล้วเงียบ แต่สาเหตุเป็นกฎของหน้า admin เอง กลุ่มนี้ผมยืนยันจากการใช้งานจริงทุกข้อ
| ข้อจำกัด | เกิดอะไรขึ้นเมื่อฝ่าฝืน | ข้อสังเกต |
|---|---|---|
| ชื่อรายการต้องยาว 1-10 ตัวอักษร | กด Apply แล้วไม่ทำอะไร | บางครั้งมีข้อความ "Please enter a value with 1 ~ 10 characters" แต่ไม่เด่น มองข้ามง่าย |
| จองได้สูงสุด 10 รายการ | รายการที่ 11 ขึ้น "The number of entries has reached the maximum limit" | ต้องนับก่อนเพิ่ม ไม่ใช่รอให้หน้าฟ้อง |
| session หมดอายุเร็ว | กดเมนูแล้วไม่มีอะไรเกิดขึ้น | ให้ login ใหม่ แล้วเริ่มจากหน้าเมนูอีกรอบ |
ชื่อแบบ living-room-tv ยาว 14 ตัวอักษร พิมพ์ลงไปครบทุกตัวและช่องก็โชว์ครบ แต่ Apply จะไม่ทำอะไร ผมเสียเวลากับข้อนี้ไปรอบหนึ่งเพราะนึกว่าเป็นบั๊กเรื่อง event อีกรอบ ตอนนี้สคริปต์ตรวจความยาวก่อนพิมพ์ตัวแรก ชื่อสั้นอย่าง living-tv (9 ตัว) ผ่านสบาย
อีกเรื่องคือจองแล้วเครื่องปลายทางยังไม่ได้ IP ใหม่ทันที เครื่องจะได้ IP ที่จองไว้ตอนต่ออายุ lease ซึ่งเราเตอร์ตัวนี้ตั้ง lease ไว้ 600 วินาที จะรอก็ได้ หรือสั่งให้เครื่องขอใหม่เองเลยก็ได้
# ฝั่งเครื่อง Linux ที่ถูกจอง: ปล่อย lease เก่าแล้วขอใหม่
sudo dhclient -r eth0 && sudo dhclient eth0
# หรือถ้าใช้ NetworkManager
nmcli con up "Wired connection 1"
# เช็กว่าได้ IP ที่จองไว้แล้ว
ip -4 addr show eth0 | grep inet
วินัยของสคริปต์: ดูก่อน ยืนยันทีหลัง
ฟอร์มที่เขียนทับได้แบบเงียบ ๆ ต้องมีสคริปต์ที่ระวังกว่ามือคน เพราะมือคนเห็นหน้าจอตลอด ส่วนสคริปต์ไม่เห็นอะไรเลยถ้าไม่ได้สั่งให้ไปดู ผมแยกสคริปต์เป็นคำสั่งสองกลุ่ม กลุ่มแรกอ่านอย่างเดียว กลุ่มที่สองเขียน และกลุ่มเขียนมีสองจังหวะ
listอ่านตารางจองที่มีอยู่ แสดงชื่อ MAC IP ของแต่ละ slot พร้อมนับ n/10 ก่อนทำอย่างอื่นเสมอset NAME MAC IPตรวจก่อนว่าไม่ชน ถ้า IP หรือ MAC ซ้ำกับ slot อื่นให้ปฏิเสธเป็น CONFLICT ถ้าชื่อยาวเกิน 10 หรือรายการเต็ม 10 ก็ปฏิเสธเช่นกัน- ถ้าผ่านก็กรอกด้วยการพิมพ์จริง แล้วถ่ายภาพหน้าจอเก็บไว้ ขั้นนี้ยังไม่กด Apply (dry-run)
- คนเปิดภาพดูว่าค่าถูกช่อง ถูกแถวจริงไหม
- รันซ้ำพร้อม
--applyสคริปต์กด Apply แล้วอ่านตารางกลับมา ตรวจว่ามีแถวใหม่ตรงกับที่สั่ง ไม่ตรงให้ถือว่าล้มเหลว
ตัวตรวจด่านที่ 2 เขียนสั้นพอที่จะวางไว้ต้นทุกคำสั่งเขียนได้เลย ตัวอย่างข้างล่างเป็นตัวย่อที่ผมเขียนใหม่ให้ครบแก่นเดียวกับของจริง
MAX_ENTRIES, MAX_NAME = 10, 10
def check_before_set(rows, name, mac, ip):
"""rows = [{'slot': 1, 'name': 'nas', 'mac': 'AA:BB:..', 'ip': '192.168.10.20'}, ...]
คืนข้อความเหตุผลที่ต้องปฏิเสธ หรือ None ถ้าไปต่อได้"""
if not (1 <= len(name) <= MAX_NAME):
return f"REFUSE ชื่อต้องยาว 1-{MAX_NAME} ตัวอักษร (ตอนนี้ {len(name)})"
if len(rows) >= MAX_ENTRIES:
return f"REFUSE รายการเต็ม {len(rows)}/{MAX_ENTRIES}"
for r in rows:
if r["ip"] == ip:
return f"CONFLICT IP {ip} ถูกจองใน slot {r['slot']} แล้ว"
if r["mac"].upper() == mac.upper():
return f"CONFLICT MAC {mac} ถูกจองใน slot {r['slot']} แล้ว"
return None
ผมขับหน้า admin ของเกตเวย์ที่บ้านอีกตัวด้วยวิธีเดียวกัน เจอสามเรื่องที่เอามาใช้ต่อได้ ช่อง user/pass มีค่า autofill ค้างอยู่ ต้องโฟกัสแล้ว Ctrl+A ลบก่อนพิมพ์ จอที่ device pixel ratio 1.25 ทำให้พิกัดบน screenshot ใหญ่กว่าพิกัด CSS 1.25 เท่า ต้องหารก่อนคลิกตามพิกัด และปุ่มที่อยู่ใต้จอต้อง scrollIntoView โดย container ที่เลื่อนได้คือ element ข้างใน ไม่ใช่ window
ตัวเลขที่ต้องจำ
ส่วนตัวผมสรุปไว้ว่า ถ้าสคริปต์ขับหน้าเว็บแล้วค่าอยู่ในช่องแต่ปุ่มไม่ทำอะไร ให้ลองพิมพ์เองด้วยมือก่อนเป็นอย่างแรก ถ้ามือทำได้แต่ JS ทำไม่ได้ นั่นคือเรื่อง event ไม่ต้องไปไล่ selector ต่อ และเรื่องที่สองคือฟอร์มพวกนี้เงียบได้ทั้งจากสาเหตุเรื่อง event และจากกฎของมันเอง ตรวจกฎให้ครบในสคริปต์ก่อนส่ง แล้วอ่านตารางกลับมายืนยันทุกครั้ง ใครจะใช้ insertText แทน dispatchKeyEvent ก็ตามสะดวกครับ ผมลองแล้วติดทั้งสองแบบ เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย