diff --git a/Cash_Based/JiaShan.py b/Cash_Based/JiaShan.py index 78fdcc6..fda3444 100644 --- a/Cash_Based/JiaShan.py +++ b/Cash_Based/JiaShan.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -in嘉善 JiaShan 明文 Python 版 # cron: 31 9,19 * * * # const $ = new Env('in嘉善阅读'); -cron: 31 9,19 * * * +""" +in嘉善 JiaShan 明文 Python 版 环境变量: JiaShan="账号1&密码1\n账号2&密码2" @@ -34,9 +33,11 @@ import random import re import time import uuid +import hashlib from dataclasses import dataclass, field +from datetime import datetime, timezone, timedelta from typing import Any, Dict, List, Optional -from urllib.parse import unquote +from urllib.parse import unquote, quote_plus try: import requests @@ -46,15 +47,20 @@ except ImportError: NAME = "in嘉善" BASE = "https://api.app.injs.jsxww.cn" +YAPI_BASE = "https://yapi.y-h5.iyunxh.com/api" +OAPI_BASE = "https://oapi.injs.jsxww.cn" DEBUG = os.getenv("JIASHAN_DEBUG", "false").lower() in {"1", "true", "yes"} TIMEOUT = int(os.getenv("JIASHAN_TIMEOUT", "20") or "20") TASK_DELAY_MIN = float(os.getenv("JIASHAN_TASK_DELAY_MIN", "5") or "5") TASK_DELAY_MAX = float(os.getenv("JIASHAN_TASK_DELAY_MAX", "10") or "10") ACCOUNT_DELAY_MIN = float(os.getenv("JIASHAN_ACCOUNT_DELAY_MIN", "8") or "8") ACCOUNT_DELAY_MAX = float(os.getenv("JIASHAN_ACCOUNT_DELAY_MAX", "15") or "15") +CN_TZ = timezone(timedelta(hours=8)) -# 原 JS 追踪到的首页动态组件文章列表入口 +# 原 JS 追踪到的阅读活动入口。这个接口返回的不是普通新闻列表,而是活动页 q/id。 ARTICLE_LIST_PATH = "/app/layout/dynamic/component/data?layoutId=7853114638635438077&layoutDatasourceId=7853114638635438096&pageNo=1&pageSize=20" +READ_ACTIVITY_ID = os.getenv("JIASHAN_READ_ACTIVITY_ID", "11106624") +TENANT_CODE = os.getenv("JIASHAN_TENANT_CODE", "in_jiashan") MOBILE_UA = ( "Mozilla/5.0 (Linux; Android 11; 21091116AC Build/RP1A.200720.011; wv) " @@ -63,6 +69,15 @@ MOBILE_UA = ( ) +def now_cn() -> datetime: + return datetime.now(CN_TZ) + + +def cn_month_day() -> str: + now = now_cn() + return f"{now.month}月{now.day}日" + + def mask(s: Any, keep: int = 4) -> str: s = "" if s is None else str(s) if len(s) <= keep * 2: @@ -107,8 +122,53 @@ def js_wait(label: str = "") -> None: def extract_first_q(obj: Any) -> str: - m = re.search(r'(?<=q=)[^&",]+', json.dumps(obj, ensure_ascii=False)) - return unquote(m.group(0)) if m else "" + text = json.dumps(obj, ensure_ascii=False) + # 原 JS 需要的是活动链接里的 q 参数;不能随便取 layoutDatasourceId/componentId, + # 否则 activity-api 会报“链接解密失败”。 + for pat in [r'(?<=q=)[^&",\\]+', r'(?<=[?&]q%3D)[^%&",\\]+']: + m = re.search(pat, text) + if m: + return unquote(m.group(0)) + # 只有明确的 q 字段才当活动 q 使用。 + def walk(obj2: Any): + if isinstance(obj2, dict): + if obj2.get("q"): + yield obj2.get("q") + for key in ("url", "jumpUrl", "linkUrl", "h5Url", "targetUrl", "externalUrl", "activityUrl"): + val = obj2.get(key) + if isinstance(val, str): + mm = re.search(r'(?<=q=)[^&",\\]+', val) + if mm: + yield unquote(mm.group(0)) + for v in obj2.values(): + yield from walk(v) + elif isinstance(obj2, list): + for v in obj2: + yield from walk(v) + for val in walk(obj): + val = str(val).strip() + if val: + return unquote(val) + return "" + + +def random_string(length: int = 32, prefix_u: bool = True, radix: Optional[int] = None) -> str: + chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + if radix is None: + radix = len(chars) + out = "".join(chars[int(random.random() * radix)] for _ in range(length)) + return ("u" + out[1:]) if prefix_u and out else out + + +def urlencode_form(obj: Dict[str, Any]) -> str: + return "&".join(f"{k}={quote_plus(str(v))}" for k, v in obj.items()) + + +def yapi_signature() -> str: + ts = int(time.time() * 1000) + nonce = random_string(32, prefix_u=False) + raw = f"jsxww{nonce}{ts}3a82b6ac78145c2a6c4ff1f7d3dced1b" + return f"jsxww;{nonce};{ts};{hashlib.md5(raw.encode()).hexdigest()}" def walk_values(obj: Any): @@ -135,6 +195,8 @@ class ClientState: user_id: str = "" read_q: str = "" read_token: str = "" + yapi_user_id: str = "0" + api_dt: str = "" lottery_q: str = "" lottery_token: str = "" third_id: str = "" @@ -196,7 +258,7 @@ class JiaShanRunner: url = path if path.startswith("http") else BASE + path return self.request_json("POST", url, state=state, json_body=payload, desc=path) - def login(self, account: Account, state: ClientState) -> bool: + def login(self, account: Account, state: ClientState) -> Optional[Dict[str, Any]]: print("登录") ret = self.request_json( "POST", @@ -214,81 +276,150 @@ class JiaShanRunner: user_id = str(data.get("userId") or data.get("uid") or data.get("id") or user.get("id") or user.get("userId") or user.get("uid") or "") if not token: print(f"登录失败:{ret.get('message') or ret.get('msg') or short_json(ret)}") - return False + return None state.token = str(token) state.user_id = user_id print(f"登录成功:token={mask(state.token)}" + (f" user_id={mask(user_id)}" if user_id else "")) + return data + + def yapi_headers(self, state: ClientState, authed: bool = True) -> Dict[str, str]: + h = { + "Connection": "Keep-Alive", + "Access-T-Id-In": "49", + "Access-Wxclient-Type": "wx_app", + "User-Agent": "injs;android:11;version:1.1.12;clientid:" + state.client_id + ";" + MOBILE_UA, + "Access-Api-Unique-Token": "1", + "Access-Api-Dt": state.api_dt or str(int(time.time() * 1000)), + "Access-T-Id": "49", + "Accept": "*/*", + "Origin": "https://jsxww.y-h5.iyunxh.com", + "X-Requested-With": "info.ltit.www.cloudjiashan", + "Referer": "https://jsxww.y-h5.iyunxh.com/", + "Accept-Encoding": "gzip, deflate", + "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", + } + if authed: + h["Access-User-Id"] = state.yapi_user_id or "0" + h["Access-Api-Signature"] = yapi_signature() + h["Access-Token"] = state.read_token + return h + + def yapi_get(self, state: ClientState, path: str, *, authed: bool = True, wait: bool = True) -> Dict[str, Any]: + return self.request_json("GET", YAPI_BASE + path, headers=self.yapi_headers(state, authed), desc=path, wait=wait) + + def yapi_post(self, state: ClientState, path: str, payload: Dict[str, Any], *, wait: bool = True) -> Dict[str, Any]: + headers = self.yapi_headers(state, True) + headers["Content-Type"] = "application/json" + return self.request_json("POST", YAPI_BASE + path, headers=headers, json_body=payload, desc=path, wait=wait) + + def oapi_get(self, state: ClientState, path: str, *, wait: bool = True) -> Dict[str, Any]: + headers = { + "Connection": "Keep-Alive", + "ClientId": state.client_id, + "Authorization": state.token, + "User-Agent": "injs;android:11;version:1.1.12;clientid:" + state.client_id, + "Accept-Encoding": "gzip", + } + return self.request_json("GET", OAPI_BASE + path, headers=headers, desc=path, wait=wait) + + def app_user_init(self, account: Account, state: ClientState, app_user_token: str, login_data: Dict[str, Any]) -> bool: + userinfo = login_data.get("userinfo") or login_data.get("userInfo") or login_data.get("user") or {} + user_id = userinfo.get("id") or state.user_id + nickname = userinfo.get("nickname") or userinfo.get("name") or userinfo.get("username") or f"用户{account.phone[-6:]}" + avatar = userinfo.get("avatarUrl") or userinfo.get("avatar_url") or "" + payload = { + "app_user_token": app_user_token, + "appid": "jsxww", + "noncestr": random_string(6, prefix_u=False), + "phone": account.phone, + "portrait_url": ("https://oss.injs.jsxww.cn" + avatar) if avatar and str(avatar).startswith("/") else str(avatar), + "timestamp": str(int(time.time())), + "user_id": user_id, + "user_name": nickname, + "wx_openid": "", + "wx_unionid": "", + } + payload["signature"] = hashlib.md5((urlencode_form(payload) + "&appkey=0c3eafb13e9f1ac110a432798b021862").encode()).hexdigest() + ret = self.yapi_post(state, "/aosbase/_auth_appuserinit", payload) + data = ret.get("data") or {} + state.read_token = data.get("access_token") or data.get("token") or "" + state.yapi_user_id = str((data.get("data") or {}).get("user_id") or data.get("user_id") or "0") + if not state.read_token: + print(f"阅读登录失败:{short_json(ret)}") + return False + print(f"阅读token:{state.read_token}") return True - def pick_article_items(self, obj: Dict[str, Any]) -> List[Dict[str, Any]]: - candidates = [] - data = obj.get("data") - for val in [data, *list(walk_values(data))]: - if isinstance(val, list): - dicts = [x for x in val if isinstance(x, dict)] - if dicts and any(("id" in x or "articleId" in x or "contentId" in x) for x in dicts): - candidates.append(dicts) - return candidates[0] if candidates else [] - - def article_id_of(self, article: Dict[str, Any]) -> str: - return str(article.get("id") or article.get("articleId") or article.get("contentId") or article.get("resourceId") or "") - - def do_read_tasks_and_lottery(self, account: Account, state: ClientState) -> None: + def do_read_tasks_and_lottery(self, account: Account, state: ClientState, login_data: Dict[str, Any]) -> None: print("————————————") print("阅读抽奖") print("获取id") article_list = self.api_get(state, ARTICLE_LIST_PATH) state.read_q = extract_first_q(article_list) - articles = self.pick_article_items(article_list) - if state.read_q: - print(f"获取id完成:{mask(state.read_q)}") - elif articles: - print(f"获取文章列表完成:{len(articles)}篇") - else: - print("获取id失败") + if not state.read_q: + # 历史原 JS 日志中此活动 q 为 11106624;仅在页面未暴露 q 时兜底使用。 + state.read_q = READ_ACTIVITY_ID + print(state.read_q) + + print("获取apiDt") + auth_dt = self.yapi_get(state, "/aosbase/_auth_dt", authed=False) + state.api_dt = str(auth_dt.get("data") or "")[32:68] + print(state.api_dt) + if not state.api_dt: + print(f"获取apiDt失败:{short_json(auth_dt)}") return - # in嘉善原脚本主要是阅读抽奖。若活动接口与天目云一致,则按同类接口尝试;失败不影响阅读。 - if state.read_q: - print("获取阅读token") - auth = self.request_json( - "POST", - "https://act.tmlyun.com/activity-api/task/h5/auth/userLogin", - state=state, - json_body={"q": state.read_q, "accountId": state.user_id, "sessionId": state.token, "tenantCode": "in_jiashan"}, - headers={"Authorization": state.read_token, "X-Requested-With": "com.jsxww.injiashan"}, - desc="获取阅读token", - ) - state.read_token = (auth.get("data") or {}).get("token") or "" - if state.read_token: - print(f"获取阅读token完成:{mask(state.read_token)}") - - # 阅读文章:不同版本接口字段可能不同,这里按常见阅读上报接口做兼容尝试。 - for i, article in enumerate(articles, 1): - aid = self.article_id_of(article) - if not aid: - continue - ret = self.api_get(state, f"/app/article/read_time?channel_article_id={aid}&is_end=1&read_time=1617") - print(f"阅读{i}/{len(articles)}:{ret.get('message') or ret.get('msg') or short_json(ret, 120)}") - - if not state.read_token: + print("阅读登录") + access_ret = self.yapi_get(state, "/admin/_service_custom_jsxww_getaccesstoken?access_t_id=1&access_t_id_in=1", authed=False) + access_token = (access_ret.get("data") or "") + open_ret = self.oapi_get(state, f"/auth/openid?access_token={access_token}&app_token={state.token}") + open_data = open_ret.get("data") or {} + app_user_token = f"{open_data.get('openid')}.{open_data.get('ticket')}" if open_data.get("openid") and open_data.get("ticket") else "" + if not app_user_token or not self.app_user_init(account, state, app_user_token, login_data): print("未获取到活动token,跳过抽奖") return - lottery_info = self.request_json( - "GET", - "https://act.tmlyun.com/activity-api/task/h5/activity/getLotteryInfo", - state=state, - headers={"Authorization": state.read_token, "X-Requested-With": "com.jsxww.injiashan"}, - desc="获取抽奖次数", - ) + today_md = cn_month_day() + pass_list_ret = self.yapi_get(state, f"/aoslearnfoot/_optionp_list?activity_id={state.read_q}") + pass_list = pass_list_ret.get("data") or [] + if isinstance(pass_list, dict): + pass_list = pass_list.get("list") or pass_list.get("records") or [] + for item in pass_list if isinstance(pass_list, list) else []: + pass_id = item.get("id") + title = item.get("title") or item.get("name") or f"{today_md}阅读" + print(title) + detail = self.yapi_get(state, f"/aoslearnfoot/optionp_detail?id={pass_id}") + data = detail.get("data") or {} + task_num = int(data.get("task_num") or item.get("task_num") or 0) + done_num = int(data.get("user_done_num") or item.get("user_done_num") or 0) + if task_num and task_num == done_num: + print("已完成") + continue + print(f"进度:{done_num}/{task_num}") + # 需要滑块时,原 JS 会请求 ddddocr。Python 版先不伪造滑块,避免错误提交。 + if task_num != done_num: + print("未完成任务需要滑块验证,已跳过阅读提交") + if not pass_list: + print(f"{today_md}阅读") + print("未获取到阅读任务") + + ac_detail = self.yapi_get(state, f"/aoslearnfoot/_ac_detail?id={state.read_q}") + lottery_id = "" try: - lottery_count = int((lottery_info.get("data") or {}).get("lotteryCount") or 0) + other_set = json.loads((ac_detail.get("data") or {}).get("other_set") or "{}") + lottery_id = str(((other_set.get("lottery") or {}).get("id")) or "") + except Exception: + lottery_id = "" + lottery_times = self.yapi_get(state, f"/aoslottery/ac_lottery_times?id={lottery_id}") if lottery_id else {} + try: + lottery_count = int((lottery_times.get("data") or {}).get("all_remain") or 0) except Exception: lottery_count = 0 print(f"拥有{lottery_count}次抽奖") if lottery_count <= 0: return + print("当前账号有抽奖次数,但抽奖接口需要滑块验证,Python版已跳过自动抽奖") + return print("获取抽奖token") activity_info = self.request_json( @@ -303,7 +434,7 @@ class JiaShanRunner: "POST", "https://act.tmlyun.com/activity-api/lottery/api/auth/userLogin", state=state, - json_body={"q": state.lottery_q, "accountId": state.user_id, "sessionId": state.token, "tenantCode": "in_jiashan"}, + json_body={"q": state.lottery_q, "accountId": state.user_id, "sessionId": state.token, "tenantCode": TENANT_CODE}, headers={"Authorization": state.lottery_token, "X-Requested-With": "com.jsxww.injiashan"}, desc="获取抽奖token", ) @@ -332,9 +463,10 @@ class JiaShanRunner: print("随机生成clientId") print(state.client_id) print(f"用户:{account.phone}开始任务") - if not self.login(account, state): + login_data = self.login(account, state) + if not login_data: return state.summary_lines - self.do_read_tasks_and_lottery(account, state) + self.do_read_tasks_and_lottery(account, state, login_data) return state.summary_lines @@ -388,4 +520,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file