-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmonitor.py
More file actions
388 lines (353 loc) · 17 KB
/
Copy pathmonitor.py
File metadata and controls
388 lines (353 loc) · 17 KB
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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
편집몬(editmon.com) '편집자 모집' 새 공고 → 텔레그램 알림. (v4)
- '본 적 있는 글번호(seen)' 목록으로 새 공고 감지, 중복 발송 방지
- 편집몬이 클라우드 IP를 차단하므로, 클라우드에서 돌릴 때는
실제 브라우저(Playwright)로 접속해 차단 페이지를 통과한다. (폰에서는 불필요)
v4 변경점(2026-08-06) — 사이트 몰아서 게시 대응:
* 2026-08-05~06 로그 분석 결과, 감시는 밤낮없이 1분마다 정상 작동했고
편집몬 사이트 자체가 공고를 모아서(특히 저녁에) 한꺼번에 게시하는 것으로 확인됨.
* 기존 watermark(최대 글번호) 방식은 "지금까지 본 최대 번호보다 큰 글"만 새 공고로 봐서,
번호는 낮게 받아놓고 나중에 승인되어 뒤늦게 목록에 나타나는 공고를 영영 놓칠 수 있음.
* v4는 '본 적 있는 글번호(seen) 목록'에 없는 글은 번호가 낮아도 전부 새 공고로 처리 → 놓침 방지.
v3 변경점(2026-08-06) — 감시 공백 대응:
1) 감시 공백 감지: 폰 절전 등으로 확인이 GAP_ALERT_MIN분 이상 끊겼다 재개되면
첫 수신자(본인)에게 "몇 분 끊겼었는지" 안내를 먼저 보낸다.
→ 알림이 몰려온 날, 언제부터 언제까지 멈췄었는지 텔레그램에서 바로 보임
2) 특정 수신자에게 3번 연속 발송 실패하면 그 공고는 그 수신자만 건너뛴다.
→ 한 명의 봇/토큰 문제로 나머지 사람 알림까지 전부 밀리는 일 방지
3) 매 실행마다 마지막 성공 시각(last_ok_ts)을 state.json에 기록
v2 변경점(신뢰성):
1) 발송에 성공한 공고까지만 watermark 전진 → 발송 실패 시 다음 실행에서 재시도(유실 방지)
2) 수신자별 발송기록(sent_log)으로 부분 실패 시에도 같은 사람에게 중복 발송 없음
3) 상세페이지 실패 시 다음 실행에서 재시도, 5회 초과면 제목+링크만이라도 발송
4) state.json 원자적 저장(강제 종료돼도 파일 안 깨짐)
환경변수:
TELEGRAM_BOT_TOKEN (필수) TELEGRAM_CHAT_ID (기본 8628024271)
TELEGRAM_BOT_TOKEN_2 TELEGRAM_CHAT_ID_2
ONLY_REMOTE (1=재택만, 0=모든 새 공고)
STATE_FILE (기본 state.json)
MAX_PER_RUN (1회 최대 처리 수, 기본 20)
USE_BROWSER (1=브라우저로 접속. GitHub Actions에서는 자동 on)
GAP_ALERT_MIN (확인 공백이 이 분수 이상이면 재개 시 안내 발송. 기본 10, 0=끄기)
"""
import os, sys, re, json, time, urllib.request, urllib.parse
from bs4 import BeautifulSoup
VERSION = "v4 (2026-08-06)"
SEEN_KEEP = 400 # 기억해 둘 '본 적 있는 글번호' 개수
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
BASE = "https://editmon.com/m/content/"
LIST_URL = BASE + "work_employ_list.html"
def detail_url(no): return BASE + "work_employ_detail.html?no=%s" % no
TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "8628024271").strip()
TOKEN2 = os.environ.get("TELEGRAM_BOT_TOKEN_2", "").strip()
CHAT_ID2 = os.environ.get("TELEGRAM_CHAT_ID_2", "").strip()
# 발송 대상: (봇토큰, chat_id) 목록 — 비어있는 쌍은 자동 제외
RECIPIENTS = [(t, c) for (t, c) in [(TOKEN, CHAT_ID), (TOKEN2, CHAT_ID2)] if t and c]
STATE_FILE= os.environ.get("STATE_FILE", "state.json")
ONLY_REMOTE = os.environ.get("ONLY_REMOTE", "1") == "1"
MAX_PER_RUN = int(os.environ.get("MAX_PER_RUN", "20"))
DETAIL_MAX_TRIES = 5
SEND_MAX_TRIES = 3 # 같은 수신자에게 이 횟수 연속 실패하면 그 공고는 건너뜀
GAP_ALERT_MIN = float(os.environ.get("GAP_ALERT_MIN", "10"))
# GitHub Actions 등 클라우드(차단 대상)에서는 브라우저 모드 자동 사용
USE_BROWSER = os.environ.get("USE_BROWSER", "").strip() == "1" or \
os.environ.get("GITHUB_ACTIONS", "").strip() == "true"
CHALLENGE_MARKERS = ("보안절차", "prove that you are human", "자동등록방지")
# ---------- 브라우저(Playwright) 경로 ----------
_PG = {"page": None, "browser": None, "pw": None}
def _browser_page():
if _PG["page"]:
return _PG["page"]
from playwright.sync_api import sync_playwright
pw = sync_playwright().start()
br = pw.chromium.launch(headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = br.new_context(user_agent=UA, locale="ko-KR",
viewport={"width": 1280, "height": 900})
pg = ctx.new_page()
_PG.update(pw=pw, browser=br, page=pg)
return pg
def _browser_close():
try:
if _PG["browser"]: _PG["browser"].close()
if _PG["pw"]: _PG["pw"].stop()
except Exception:
pass
def _looks_blocked(html):
return any(m in html for m in CHALLENGE_MARKERS)
def fetch_browser(url, tries=4):
pg = _browser_page()
last = ""
for i in range(tries):
try:
pg.goto(url, wait_until="domcontentloaded", timeout=40000)
pg.wait_for_timeout(3500) # 차단 페이지 JS가 통과하도록 대기
html = pg.content()
if not _looks_blocked(html):
return html
# 아직 차단 페이지면 한 번 더 새로고침(쿠키 세팅 후)
pg.wait_for_timeout(2500)
pg.reload(wait_until="domcontentloaded", timeout=40000)
pg.wait_for_timeout(2500)
html = pg.content()
if not _looks_blocked(html):
return html
last = html
except Exception as e:
print(" browser fetch err:", str(e)[:120])
time.sleep(2)
return last
# ---------- 단순 HTTP 경로(가정용 IP에서만 통과) ----------
def fetch_http(url, tries=3):
last = None
for i in range(tries):
try:
req = urllib.request.Request(url, headers={
"User-Agent": UA, "Accept-Language": "ko,en;q=0.8",
"Accept": "text/html,application/xhtml+xml",
})
raw = urllib.request.urlopen(req, timeout=25).read()
return raw.decode("euc-kr", "replace")
except Exception as e:
last = e; time.sleep(2 + i)
raise last
def fetch(url):
return fetch_browser(url) if USE_BROWSER else fetch_http(url)
# ---------- 파싱 ----------
def get_listing():
html = fetch(LIST_URL)
if _looks_blocked(html):
print(" listing still blocked. head:", re.sub(r"\s+"," ",html)[:160])
return []
soup = BeautifulSoup(html, "html.parser")
items, seen = [], set()
for a in soup.select('a[href*="work_employ_detail.html?no="]'):
m = re.search(r"no=(\d+)", a.get("href", ""))
if not m: continue
no = int(m.group(1))
if no in seen: continue
seen.add(no)
comp = a.select_one(".company")
subj = a.select_one(".subject")
meta = a.select_one(".meta-info")
items.append({
"no": no,
"company": comp.get_text(strip=True) if comp else "",
"title": subj.get_text(strip=True) if subj else "",
"meta": re.sub(r"\s+"," ",meta.get_text(" ",strip=True)) if meta else "",
})
return items
def get_detail(no):
soup = BeautifulSoup(fetch(detail_url(no)), "html.parser")
for t in soup(["script", "style"]): t.decompose()
kv = {}
for lab in soup.find_all(["th", "dt"]):
k = lab.get_text(strip=True)
sib = lab.find_next_sibling()
v = sib.get_text(" ", strip=True) if sib else ""
if k and v and len(k) < 12:
kv[k] = re.sub(r"\s+", " ", v)[:140]
body = ""
lbl = soup.find(string=re.compile("상세모집내용"))
if lbl:
cont = lbl.find_parent()
sib = cont.find_next_sibling()
if not sib and cont.find_parent():
sib = cont.find_parent().find_next_sibling()
if sib:
body = re.sub(r"\s+", " ", sib.get_text(" ", strip=True))
return kv, body
def is_remote(kv):
return "재택" in (kv.get("복리후생", "") + " " + kv.get("근무형태", ""))
# ---------- 텔레그램 ----------
def _send_one(token, chat_id, text):
data = urllib.parse.urlencode({
"chat_id": chat_id, "text": text, "disable_web_page_preview": "true",
}).encode()
url = "https://api.telegram.org/bot%s/sendMessage" % token
for _ in range(2):
try:
r = urllib.request.urlopen(urllib.request.Request(url, data=data), timeout=20).read().decode()
if '"ok":true' in r:
return True
except Exception as e:
print(" send error (%s):" % chat_id, e)
time.sleep(2)
print(" send FAILED -> chat_id %s:" % chat_id, text[:50])
return False
def tg_send(text):
"""공지성 메시지: 전 수신자에게 발송, 한 명이라도 성공하면 True."""
if not RECIPIENTS:
print("(dry-run, no token) ----\n" + text + "\n----"); return True
ok_any = False
for tok, cid in RECIPIENTS:
if _send_one(tok, cid, text):
ok_any = True
print(" sent -> chat_id", cid)
return ok_any
def tg_send_owner(text):
"""운영 안내(감시 공백 등): 첫 수신자(본인)에게만 발송."""
if not RECIPIENTS:
print("(dry-run, no token) ----\n" + text + "\n----"); return True
tok, cid = RECIPIENTS[0]
if _send_one(tok, cid, text):
print(" sent(owner) -> chat_id", cid)
return True
return False
def tg_send_item(no, text, st):
"""공고 발송: 수신자별 기록(sent_log)으로 중복 방지.
모든 수신자에게 도달(또는 포기 확정)했을 때만 True(→ watermark 전진 가능).
같은 수신자에게 SEND_MAX_TRIES회 연속 실패하면 그 수신자는 포기하고 흐름을 계속 진행."""
if not RECIPIENTS:
print("(dry-run, no token) ----\n" + text + "\n----"); return True
log = st.setdefault("sent_log", [])
fails = st.setdefault("send_fails", {})
done = set(log)
ok_all = True
for tok, cid in RECIPIENTS:
key = "%s:%s" % (no, cid)
if key in done:
continue # 이미 이 사람에겐 보냈음
if _send_one(tok, cid, text):
log.append(key)
fails.pop(key, None)
print(" sent -> chat_id", cid)
else:
n = fails.get(key, 0) + 1
fails[key] = n
if n >= SEND_MAX_TRIES: # 이 수신자만 포기, 다른 알림 흐름은 막지 않음
log.append(key)
fails.pop(key, None)
print(" give up -> chat_id %s (공고 %s, %d회 실패)" % (cid, no, n))
else:
ok_all = False
st["sent_log"] = log[-300:]
if len(fails) > 100: # 청소(비정상 상황에서 무한히 커지지 않게)
st["send_fails"] = {}
return ok_all
def load_state():
try:
with open(STATE_FILE, encoding="utf-8") as f: return json.load(f)
except Exception:
return {}
def save_state(st):
tmp = STATE_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(st, f, ensure_ascii=False, indent=2)
os.replace(tmp, STATE_FILE) # 원자적 교체(파일 깨짐 방지)
def _fmt_gap(minutes):
m = int(round(minutes))
if m < 60:
return "%d분" % m
return "%d시간 %d분" % (m // 60, m % 60)
def fmt(item, kv, body):
L = ["🆕 편집몬 새 재택 공고" if ONLY_REMOTE else "🆕 편집몬 새 공고", ""]
L.append("📌 " + (item["title"] or "(제목 없음)"))
L.append("🏢 " + (item["company"] or "-"))
if kv.get("급여"): L.append("💰 급여: " + kv["급여"])
if kv.get("경력"): L.append("📈 경력: " + kv["경력"])
if kv.get("근무형태"): L.append("🏠 근무형태: " + kv["근무형태"])
if kv.get("편집가능툴"):L.append("🎬 편집툴: " + kv["편집가능툴"])
if kv.get("복리후생"): L.append("🎁 복리후생: " + kv["복리후생"])
if kv.get("담당업무"): L.append("📋 담당업무: " + kv["담당업무"])
if kv.get("모집인원"): L.append("👥 모집인원: " + kv["모집인원"])
if kv.get("접수기간"): L.append("🗓 마감: " + kv["접수기간"])
if body:
snip = body[:180] + ("…" if len(body) > 180 else "")
L += ["", "📝 " + snip]
L += ["", "🔗 " + detail_url(item["no"])]
return "\n".join(L)
def main():
print("monitor.py", VERSION, "| USE_BROWSER =", USE_BROWSER)
st = load_state()
watermark = int(st.get("watermark", 0))
try:
items = get_listing()
except Exception as e:
print("listing fetch failed:", e); _browser_close(); return
if not items:
print("no items (blocked?); skip"); _browser_close(); return
all_max = max(i["no"] for i in items)
print("fetched %d items, max=%d" % (len(items), all_max))
# ---- 감시 공백 감지(v3): 마지막 '성공' 이후 얼마나 끊겼었나 ----
now = time.time()
last_ok = float(st.get("last_ok_ts", 0) or 0)
gap_min = (now - last_ok) / 60.0 if last_ok > 0 else 0.0
st["last_ok_ts"] = now
seen_list = st.get("seen")
if watermark == 0 and seen_list is None:
# 완전 첫 실행: 현재 목록 전체를 '본 것'으로 기준선 설정 (과거 공고 쏟아내지 않음)
st["watermark"] = all_max
st["seen"] = sorted(i["no"] for i in items)
save_state(st)
kind = "'재택 가능' " if ONLY_REMOTE else ""
tg_send("🤖 편집몬 공고 알림 시작!\n"
"지금부터 새로 올라오는 %s편집자 모집 공고를 1분마다 확인해 알려드릴게요.\n"
"(기준 글번호: %d)" % (kind, all_max))
print("baseline set:", all_max); _browser_close(); return
if seen_list is None:
# v2/v3 상태에서 첫 v4 실행: 옛 규칙(watermark 초과분)으로 후보를 뽑고,
# 그 이하 번호는 '이미 본 것'으로 이번 실행에서 자동 등록된다.
new = sorted([i for i in items if i["no"] > watermark], key=lambda x: x["no"])
seen = set(i["no"] for i in items if i["no"] <= watermark)
st["seen"] = sorted(seen)
print("migrate v4: watermark=%d, new=%d" % (watermark, len(new)))
else:
seen = set(seen_list)
new = sorted([i for i in items if i["no"] not in seen], key=lambda x: x["no"])
print("seen=%d, new=%d (watermark=%d)" % (len(seen), len(new), watermark))
if last_ok > 0 and GAP_ALERT_MIN > 0 and gap_min >= GAP_ALERT_MIN:
print("gap detected: %.0f min" % gap_min)
if new:
tail = "그 사이 올라온 새 공고 %d건을 지금 바로 이어서 보냅니다." % len(new)
else:
tail = "다행히 그 사이 새로 올라온 공고는 없었어요."
tg_send_owner("⚠️ 감시 공백 안내\n"
"공고 확인이 %s 동안 멈췄다가 방금 재개됐어요.\n%s\n\n"
"(원인은 대부분 폰 절전이에요. 이 안내가 자주 오면\n"
"Termux 배터리 '제한 없음' / 절전 앱 제외 설정을 다시 확인하고,\n"
"가능하면 폰을 충전기에 꽂아 두세요.)" % (_fmt_gap(gap_min), tail))
def mark_seen(no):
seen.add(no)
st["seen"] = sorted(seen)[-SEEN_KEEP:]
fails = st.setdefault("fail_count", {})
for it in new[:MAX_PER_RUN]:
no = it["no"]
try:
kv, body = get_detail(no)
except Exception as e:
n = fails.get(str(no), 0) + 1
fails[str(no)] = n
print("detail fail %d (%d회):" % (no, n), e)
if n < DETAIL_MAX_TRIES:
save_state(st)
break # 다음 실행에서 재시도 (seen 미등록 유지)
kv, body = {}, "" # 계속 실패 → 제목+링크만이라도 발송
if ONLY_REMOTE and not is_remote(kv):
print("skip non-remote", no)
mark_seen(no)
st["watermark"] = max(int(st.get("watermark", 0)), no)
fails.pop(str(no), None)
save_state(st)
time.sleep(0.4)
continue
if tg_send_item(no, fmt(it, kv, body), st):
mark_seen(no) # 이 공고 처리 완료로 기록
st["watermark"] = max(int(st.get("watermark", 0)), no)
fails.pop(str(no), None)
save_state(st)
print("sent + seen ->", no)
else:
save_state(st) # 부분 성공 기록은 남김
print("send incomplete at", no, "-> 다음 실행에서 재시도")
break
time.sleep(0.6)
save_state(st) # last_ok_ts/seen 등 최종 저장(새 공고 없어도)
print("done. watermark=%d, seen=%d" % (int(st.get("watermark", watermark)), len(st.get("seen", []))))
_browser_close()
if __name__ == "__main__":
main()