Files
QUZ/fetch_quiz_finalm.py
T
root f5ddd0b877 py
2026-06-27 01:50:08 +00:00

443 lines
19 KiB
Python

import requests
from thrift.protocol import TBinaryProtocol
from thrift.transport import TTransport
import sqlite3
import time
import os
import json
import argparse
import sys
import traceback
import lz4.block
import hashlib
import datetime
#import keyboard
from urllib.parse import urlparse, unquote
# Standardized parsing logic for the specific Thrift structure of this app
class QpleClient:
def __init__(self, config_path="config.json", mode="Job"):
if not os.path.exists(config_path):
print(f"[Fatal] Error: {config_path} not found.")
sys.exit(1)
with open(config_path, 'r', encoding='utf-8') as f:
self.cfg = json.load(f)
self.session = requests.Session()
self.base_url = "https://qple-api-prod.qpleapp.com"
self.auth_token = self.cfg.get('auth_token', '')
self.mode = mode
# Proper encoding for Korean headers
self.enc_nick = self.cfg['nickname'].encode('utf-8').decode('latin-1')
self.enc_branch = self.cfg['branch_name'].encode('utf-8').decode('latin-1')
self.timeout = 60
self.max_retries = 3
self.common_headers = {
'Accept': '*/*',
'AppVersion': self.cfg['app_version'],
'Content-Type': 'application/x-thrift',
'User-Agent': f'UnityPlayer/{self.cfg["unity_version"]} (UnityWebRequest/1.0, libcurl/8.10.1-DEV)',
'X-Unity-Version': self.cfg["unity_version"]
}
# Force login if token is missing
if not self.auth_token:
if not self.login():
print("[Fatal] Initial login failed. Check your config.json.")
sys.exit(1)
def get_current_headers(self):
headers = self.common_headers.copy()
if self.auth_token:
headers['Authorization'] = self.auth_token
headers['Cookie'] = f'CHARCTER_INFO=nickname={self.enc_nick}&branchName={self.enc_branch}&feedType={self.mode}'
return headers
def login(self, attempt=1):
print(f"\n[Auth] >>> Attempting login (Try {attempt}/{self.max_retries})...")
login_data = self.cfg.get('login_credentials')
if not login_data:
print("[Error] No login credentials found in config.json")
return False
trans = TTransport.TMemoryBuffer()
proto = TBinaryProtocol.TBinaryProtocol(trans)
proto.writeFieldBegin(b'id', 11, 1); b = login_data['device_id'].encode('utf-8'); proto.writeI32(len(b)); proto.trans.write(b); proto.writeFieldEnd()
proto.writeFieldBegin(b'id', 11, 2); b = login_data['account_id'].encode('utf-8'); proto.writeI32(len(b)); proto.trans.write(b); proto.writeFieldEnd()
proto.writeFieldBegin(b'id', 11, 3); b = login_data['auth_secret'].encode('utf-8'); proto.writeI32(len(b)); proto.trans.write(b); proto.writeFieldEnd()
proto.writeFieldStop()
url = f"{self.base_url}/Login/UserLoginReq"
try:
r = self.session.post(url, headers=self.common_headers, data=trans.getvalue(), timeout=self.timeout)
if r.status_code == 200:
parsed = self.parse_thrift(r.content)
new_token = parsed.get(6, parsed.get(7))
if new_token and isinstance(new_token, str):
self.auth_token = f"Bearer {new_token}"
print(f"[Auth] Login Successful! New token acquired.")
return True
print(f"[Auth] Login failed with status code: {r.status_code}")
except Exception as e:
print(f"[Auth] Login error: {e}")
if attempt < self.max_retries:
time.sleep(5)
return self.login(attempt + 1)
return False
def parse_thrift(self, data):
if not data: return {}
trans = TTransport.TMemoryBuffer(data)
proto = TBinaryProtocol.TBinaryProtocol(trans)
def _read_val(ftype):
if ftype == 2: return proto.readBool()
elif ftype == 8: return proto.readI32()
elif ftype == 4: return proto.readDouble()
elif ftype == 11:
size = proto.readI32()
return trans.readAll(size).decode('utf-8', 'ignore')
elif ftype == 12: return _read_struct()
elif ftype == 15:
etype, size = proto.readListBegin()
return [_read_val(etype) for _ in range(size)]
return None
def _read_struct():
s = {}
while True:
_, ftype, fid = proto.readFieldBegin()
if ftype == 0: break
s[fid] = _read_val(ftype)
proto.readFieldEnd()
return s
try: return _read_struct()
except: return {}
def encode_str_struct(self, fid, val):
trans = TTransport.TMemoryBuffer()
proto = TBinaryProtocol.TBinaryProtocol(trans)
proto.writeFieldBegin(b'id', 11, fid); b = str(val).encode('utf-8'); proto.writeI32(len(b)); proto.trans.write(b); proto.writeFieldEnd(); proto.writeFieldStop()
return trans.getvalue()
def encode_submit(self, guid, q_seq, ans):
# Syntax fix for the duplicate parenthesis typo
trans = TTransport.TMemoryBuffer()
proto = TBinaryProtocol.TBinaryProtocol(trans)
# Field 1: Subject GUID (string)
proto.writeFieldBegin(b'id', 11, 1); b = guid.encode('utf-8'); proto.writeI32(len(b)); proto.trans.write(b); proto.writeFieldEnd()
# Field 2: Question Sequence/ID (int32) - CRITICAL: Use Field 5 from response
proto.writeFieldBegin(b'id', 8, 2); proto.writeI32(int(q_seq)); proto.writeFieldEnd()
# Field 3: Answer String (string)
proto.writeFieldBegin(b'id', 11, 3); b = str(ans).encode('utf-8'); proto.writeI32(len(b)); proto.trans.write(b); proto.writeFieldEnd()
proto.writeFieldStop()
return trans.getvalue()
def post(self, path, data, attempt=1):
if not self.auth_token: return None
headers = self.get_current_headers()
try:
r = self.session.post(f"{self.base_url}/{path}", headers=headers, data=data, timeout=self.timeout)
if r.status_code == 200:
return self.parse_thrift(r.content)
elif r.status_code == 409:
print(f"\n[!] 409 Conflict on {path}. Cooling down...")
for i in range(60, 0, -10):
print(f" Wait... {i}s left")
time.sleep(10)
return self.post(path, data, attempt)
elif r.status_code == 401:
if self.login(): return self.post(path, data, attempt)
else: sys.exit(1)
else:
print(f"\n[!] HTTP {r.status_code} error on {path}")
except requests.exceptions.Timeout:
if attempt < self.max_retries:
time.sleep(5)
return self.post(path, data, attempt + 1)
except Exception as e:
print(f"\n[!] Request Error: {e}")
return None
def get_answer_from_db(q_text, field9):
db_file = 'qple_quiz.db'
if not os.path.exists(db_file):
return None
passage = ""
if isinstance(field9, str):
if not field9.startswith("http"):
passage = field9
try:
conn = sqlite3.connect(db_file)
cursor = conn.cursor()
cursor.execute("SELECT correct_answer_str FROM QuizQuestions WHERE source = 'qple' AND question_text = ? AND passage = ?", (q_text, passage))
row = cursor.fetchone()
conn.close()
if row:
return row[0]
except Exception as e:
print(f"[DB Error] {e}")
return None
def get_lz4_image_filename(url):
parsed = urlparse(url)
pure_path = parsed.scheme + "://" + parsed.netloc + parsed.path
pure_path = unquote(pure_path)
base_path, ext = os.path.splitext(pure_path)
if not ext:
ext = ".png"
compressed = lz4.block.compress(base_path.encode('utf-8'))
hex_name = compressed.hex()
if len(hex_name) > 150:
h = hashlib.md5(hex_name.encode('utf-8')).hexdigest()
hex_name = hex_name[:150] + "_" + h
return hex_name + ext
def log_image_mapping(original_url, local_filename):
base_dir = os.path.dirname(os.path.abspath(__file__))
# 1. JSON lookup map update
map_file = os.path.join(base_dir, "image_map.json")
mapping = {}
if os.path.exists(map_file):
try:
with open(map_file, "r", encoding="utf-8") as f:
mapping = json.load(f)
except Exception:
mapping = {}
mapping[local_filename] = original_url
try:
with open(map_file, "w", encoding="utf-8") as f:
json.dump(mapping, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"[Log Error] Failed to write JSON map: {e}")
# 2. Sequential text download log
log_file = os.path.join(base_dir, "image_download.log")
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_line = f"[{timestamp}] {local_filename} -> {original_url}\n"
try:
with open(log_file, "a", encoding="utf-8") as f:
f.write(log_line)
except Exception as e:
print(f"[Log Error] Failed to write text log: {e}")
def download_and_save_image(image_url, target_dir="./frontend/public/assets"):
if not image_url or not image_url.startswith("http"):
return image_url
try:
os.makedirs(target_dir, exist_ok=True)
filename = get_lz4_image_filename(image_url)
dest_path = os.path.join(target_dir, filename)
# Log the mapping of compressed filename to original url
log_image_mapping(image_url, filename)
if os.path.exists(dest_path):
return f"/assets/{filename}"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
r = requests.get(image_url, headers=headers, timeout=15)
if r.status_code == 200:
with open(dest_path, "wb") as f:
f.write(r.content)
print(f" [Image] Downloaded {image_url} -> {dest_path}")
return f"/assets/{filename}"
else:
print(f" [Image Warning] Failed to download {image_url}, status code: {r.status_code}")
except Exception as e:
print(f" [Image Error] Error downloading {image_url}: {e}")
return image_url
def sync_db():
db_file = 'qple_quiz.db'
app_db_file = r'./qple_quiz.db'
if os.path.exists(db_file):
try:
import shutil
shutil.copy2(db_file, app_db_file)
print(f" [Sync] Copied {db_file} -> {app_db_file}")
except Exception as e:
print(f" [Sync Error] Database synchronization failed: {e}")
def save_to_db(q, cat, diff, correct_ans_str):
db_file = 'qple_quiz.db'
conn = sqlite3.connect(db_file)
cursor = conn.cursor()
q_seq = int(q.get(3, 0))
txt = str(q.get(7, ''))
choices = q.get(10, [])
if not isinstance(choices, list): choices = []
c1, c2, c3, c4 = (choices + ['', '', '', ''])[:4]
explanation = str(q.get(12, ''))
field9 = q.get(9, '')
passage, image_url = "", ""
if isinstance(field9, str):
if field9.startswith("http"):
image_url = field9
else:
passage = field9
if image_url:
image_url = download_and_save_image(image_url)
raw_json = json.dumps(q, ensure_ascii=False)
correct_idx = 0
for i, c in enumerate(choices):
if c == correct_ans_str: correct_idx = i; break
# Try to find existing question with same category, difficulty, question_text, and passage for qple
cursor.execute("SELECT id FROM QuizQuestions WHERE source = 'qple' AND category = ? AND difficulty = ? AND question_text = ? AND passage = ?", (cat, diff, txt, passage))
row = cursor.fetchone()
if row:
q_id = row[0]
else:
# Assign the first vacant numeric key from the front
cursor.execute("SELECT id FROM QuizQuestions WHERE source = 'qple' ORDER BY id ASC")
existing_ids = [r[0] for r in cursor.fetchall()]
if not existing_ids:
q_id = 1
elif existing_ids[0] > 1:
q_id = 1
else:
q_id = existing_ids[-1] + 1
for i in range(len(existing_ids) - 1):
if existing_ids[i+1] - existing_ids[i] > 1:
q_id = existing_ids[i] + 1
break
cursor.execute('''INSERT OR REPLACE INTO QuizQuestions (id, source, category, question_text, choice1, choice2, choice3, choice4, correct_answer, correct_answer_str, explanation, difficulty, passage, image_url, raw_json, oid)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', (q_id, 'qple', cat, txt, c1, c2, c3, c4, correct_idx, correct_ans_str, explanation, diff, passage, image_url, raw_json, q_seq))
conn.commit()
conn.close()
def main():
parser = argparse.ArgumentParser(description="Qple Scraper")
parser.add_argument("--mode", type=str, choices=["Job", "Certificate"], default="Job")
parser.add_argument("--category", type=str)
parser.add_argument("--delay", type=float, default=1.5)
args = parser.parse_args()
client = QpleClient(mode=args.mode)
select_list_path = f"Quiz/{args.mode}SelectListReq"
subject_list_path = f"Quiz/{args.mode}SubjectListReq"
print(f"\n>>> Starting {args.mode} Extraction Loop...")
job_list = client.post(select_list_path, b'\x00')
if not job_list or 1 not in job_list: sys.exit(1)
targets = [j for j in job_list[1] if not args.category or j.get(2) == args.category]
if not targets: return
for job in targets:
job_guid, job_name = job.get(1), job.get(2, 'Unknown')
print(f"\n>>> [Category] {job_name}")
subject_list = client.post(subject_list_path, client.encode_str_struct(1, job_guid))
if not subject_list: continue
for subj in subject_list[1]:
guid, diff, q_count = subj.get(2), subj.get(3, 'Normal'), subj.get(5, 0)
if q_count == 0: continue
print(f" -> [Subject] {diff} (Pool: {q_count})")
unique_questions = set()
consecutive_dupes = 0
# Load existing questions from DB to skip them and prevent duplicate limit trigger immediately
try:
conn = sqlite3.connect('qple_quiz.db')
c = conn.cursor()
c.execute("SELECT question_text, passage FROM QuizQuestions WHERE source = 'qple' AND category = ? AND difficulty = ?", (job_name, diff))
for row in c.fetchall():
txt_db, pas_db = row[0], row[1] or ""
unique_questions.add(txt_db + pas_db)
conn.close()
print(f" [Database] Pre-loaded {len(unique_questions)} existing questions to skip.")
except Exception as db_err:
print(f" [Database Warning] Failed to load existing questions: {db_err}")
while len(unique_questions) < q_count:
print(f" [Status] {len(unique_questions)+1}/{q_count} - Fetching...")
q = client.post("Quiz/QuizQuestionInfoReq", client.encode_str_struct(1, guid))
if not q or 7 not in q: break
txt = q.get(7)
q_seq = q.get(5, 0) # Use Field 5 as Sequence ID for submission
choices = q.get(10, [])
field9 = q.get(9, '')
ukey = txt + str(field9)
# Check if this question is already in DB and we know the answer
known_ans = get_answer_from_db(txt, field9)
res = None
if known_ans:
print(f" [Database] Found existing question. Submitting correct answer directly: '{known_ans}'")
res = client.post("Quiz/QuizSubmitSelectedAnswerReq", client.encode_submit(guid, q_seq, known_ans))
true_ans = known_ans
else:
# Advance & Learn (Submit dummy 'O' or first choice to learn answer)
dummy_ans = choices[0] if choices else "O"
print(f" [Status] {len(unique_questions)+1}/{q_count} - Submitting dummy '{dummy_ans}' to learn answer with Seq ID: {q_seq}...")
res = client.post("Quiz/QuizSubmitSelectedAnswerReq", client.encode_submit(guid, q_seq, dummy_ans))
true_ans = ""
if res:
if res.get(1) == True:
true_ans = dummy_ans
else:
true_ans = res.get(2, '')
if not true_ans:
true_ans = dummy_ans
# Save to DB if new
if ukey not in unique_questions:
save_to_db(q, job_name, diff, true_ans)
unique_questions.add(ukey)
consecutive_dupes = 0
print(f" [Saved] {len(unique_questions)}/{q_count} - {txt[:35]}...")
# Sync DB periodically for visual feedback
#if len(unique_questions) % 50 == 0:
#sync_db()
if len(unique_questions) >= q_count: break
else:
consecutive_dupes += 1
print(f" [...] Duplicate #{consecutive_dupes} skipped.")
if consecutive_dupes >= 35:
print(f" [Status] Hitted {consecutive_dupes} consecutive duplicates. Breaking loop.")
break
if res and res.get(3) == True:
print(" => Server marked session as finished. (GotoLearningResultPage)")
print(" => Visiting TodayLearningResultReq...")
client.post("Quiz/TodayLearningResultReq", b'\x00')
print(" => Waiting 5 seconds before moving to next subject...")
time.sleep(5.0)
# break
#if keyboard.is_pressed('esc'):
# break
time.sleep(args.delay)
print(" => Visiting TodayLearningResultReq...")
client.post("Quiz/TodayLearningResultReq", b'\x00')
# Sync DB at the end of each subject
# sync_db()
print(" => Waiting 5 seconds before moving to next target...")
time.sleep(5.0)
print("\nMission Complete.")
#sync_db()
if __name__ == "__main__":
main()