├── .dockerignore ├── .env.example ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── data └── .gitkeep ├── docker-compose.yml ├── docker-entrypoint.sh ├── mail ├── default.html └── images │ ├── mmsgletter_2_bg.png │ ├── mmsgletter_2_bg_topline.png │ ├── mmsgletter_2_btn.png │ ├── mmsgletter_chat_left.gif │ ├── mmsgletter_chat_right.gif │ └── ting.jpg ├── netflix.py ├── requirements.txt ├── resources ├── Netflix_Logo_RGB.png └── stealth.min.js ├── run.bat ├── utils ├── __init__.py └── version.py └── wait-for-it.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .idea 4 | logs 5 | venv 6 | README.md 7 | LICENSE 8 | chromedriver.exe 9 | .env 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Chromedriver 路径 2 | # 下载地址:https://chromedriver.chromium.org/downloads 3 | DRIVER_EXECUTABLE_FILE='/usr/bin/chromedriver' 4 | 5 | # 多个 Netflix 账户 6 | # 注意:用户名不要含“[]”字符,否则无法正确解析,此处的用户名会被保护,防止恶意用户篡改。同一账户下的 5 个子账户都会使用这里定义的用户名,并自动编号 7 | MULTIPLE_NETFLIX_ACCOUNTS='[账户1|密码1|用户名1][账户2|密码2|用户名2]' 8 | 9 | # 是否启用账户名保护,启用后,程序将每两分钟左右主动检查账户名是否被篡改并尝试恢复 1:启用 0:不启用 10 | ENABLE_ACCOUNT_PROTECTION=1 11 | 12 | # 机器人邮箱 13 | PUSH_MAIL_USERNAME='' 14 | PUSH_MAIL_PASSWORD='' 15 | 16 | # 拉取邮件 17 | PULL_MAIL_USERNAME='' 18 | PULL_MAIL_PASSWORD='' 19 | IMAP_HOST='imap.gmail.com' 20 | IMAP_PORT=993 21 | IMAP_SSL=1 22 | 23 | # 用于接收通知的邮箱 24 | INBOX='' 25 | 26 | # Redis 地址(本地可设为 127.0.0.1,以 docker-compose 形式运行请设为 redis_for_netflix) 27 | REDIS_HOST='redis_for_netflix' 28 | 29 | # Redis 端口 30 | REDIS_PORT=6379 31 | 32 | # 是否启用 Debug 模式 1:启用 0:不启用 33 | DEBUG=0 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | venv/ 3 | logs/ 4 | .idea/ 5 | chromedriver.exe 6 | utils/__pycache__/ 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.8-slim-bullseye 2 | 3 | LABEL author="mybsdc " \ 4 | maintainer="luolongfei " 5 | 6 | ENV TZ Asia/Shanghai 7 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 8 | 9 | # 非交互式安装,避免告警 10 | ARG DEBIAN_FRONTEND=noninteractive 11 | 12 | ARG CHROME_VERSION=96.0.4664.45-1 13 | ARG CHROME_DRIVER_VERSION=96.0.4664.45 14 | 15 | ARG CHROME_DOWNLOAD_URL=http://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb 16 | ARG CHROME_DRIVER_DOWNLOAD_URL=https://chromedriver.storage.googleapis.com/${CHROME_DRIVER_VERSION}/chromedriver_linux64.zip 17 | 18 | # set -eux e: 脚本只要发生错误,就终止执行 u: 遇到不存在的变量就会报错,并停止执行 x: 在运行结果之前,先输出执行的那一行命令 19 | RUN set -eux; \ 20 | # 安装基础依赖工具 21 | apt-get update; \ 22 | apt-get install -y --no-install-recommends \ 23 | fonts-liberation \ 24 | libasound2 \ 25 | libatk-bridge2.0-0 \ 26 | libatk1.0-0 \ 27 | libatspi2.0-0 \ 28 | libcups2 \ 29 | libdbus-1-3 \ 30 | libgtk-3-0 \ 31 | libnspr4 \ 32 | libnss3 \ 33 | libx11-xcb1 \ 34 | libxcomposite1 \ 35 | libxcursor1 \ 36 | libxdamage1 \ 37 | libxfixes3 \ 38 | libxi6 \ 39 | libxrandr2 \ 40 | libxss1 \ 41 | libxtst6 \ 42 | lsb-release \ 43 | libwayland-server0 \ 44 | libgbm1 \ 45 | curl \ 46 | unzip \ 47 | wget \ 48 | xdg-utils \ 49 | xvfb; \ 50 | # 清除非明确安装的推荐的或额外的扩展 configure apt-get to automatically consider those non-explicitly installed suggestions/recommendations as orphans 51 | apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \ 52 | # It removes everything but the lock file from /var/cache/apt/archives/ and /var/cache/apt/archives/partial/ 53 | apt-get clean; \ 54 | # 删除包信息缓存 55 | rm -rf /var/lib/apt/lists/* 56 | 57 | # 下载并安装 Chrome 58 | RUN wget --no-verbose -O /tmp/chrome.deb "${CHROME_DOWNLOAD_URL}"; \ 59 | apt-get install -yf /tmp/chrome.deb; \ 60 | /usr/bin/google-chrome --version; \ 61 | rm -f /tmp/chrome.deb 62 | 63 | # 下载并启用 ChromeDriver 64 | RUN wget --no-verbose -O chromedriver.zip "${CHROME_DRIVER_DOWNLOAD_URL}"; \ 65 | unzip chromedriver.zip; \ 66 | rm chromedriver.zip; \ 67 | mv chromedriver /usr/bin/chromedriver; \ 68 | chmod +x /usr/bin/chromedriver; \ 69 | /usr/bin/chromedriver --version 70 | 71 | WORKDIR /app 72 | 73 | COPY . ./ 74 | 75 | RUN pip install -i https://pypi.tuna.tsinghua.edu.cn/simple --no-cache-dir -r requirements.txt 76 | 77 | VOLUME ["/conf", "/app/logs"] 78 | 79 | RUN cp docker-entrypoint.sh /usr/local/bin/; \ 80 | chmod +x /usr/local/bin/docker-entrypoint.sh 81 | 82 | ENTRYPOINT ["sh", "-c", "docker-entrypoint.sh"] 83 | 84 | CMD ["crond", "-f"] 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 luolongfei 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Netflix logo 3 |
4 | 5 | # 监听奈飞(Netflix)密码变更邮件,自动重置密码 6 | 7 | ## 本人接与 Netflix 或其它平台相关的自动化脚本的单子,有需求的可以联系 luolongf@gmail.com 或加下面的微信 8 | ## 本人接与 Netflix 或其它平台相关的自动化脚本的单子,有需求的可以联系 luolongf@gmail.com 或加下面的微信 9 | ## 本人接与 Netflix 或其它平台相关的自动化脚本的单子,有需求的可以联系 luolongf@gmail.com 或加下面的微信 10 | 11 | > 通过上方邮箱地址联系,或者直接加下方微信联系,添加时备注“奈飞”以便通过验证,成功添加好友后,直接留言说明你的需求,我会尽快回复。 12 | 13 | WeChat 14 | 15 | # 项目演示 16 | 17 | **DEMO 环境地址:[https://demo.netflixadmin.com/admin](https://demo.netflixadmin.com/admin)** 18 | 19 | ## 奈飞全自动系统 Netflix-Admin 20 | 21 | Netflix-Admin 22 | 23 | Netflix-Admin 24 | 25 | Netflix-Admin 26 | 27 | Netflix-Admin 28 | 29 | Netflix-Admin 30 | 31 | ## 奈飞自动取码,支持独立部署或接口调用的方式 32 | 33 | Self-Service 34 | 35 | *所有功能支持定制开发,支持与现有系统交互。* 36 | 37 | # 写在前面 38 | 39 | 共享 Netflix 账户的用户,密码可能频繁被人修改,使大家无法登录。 40 | 41 | 本项目完美解决了这个问题,基本逻辑是监听 Netflix 密码变更邮件,自动重置密码。仅供 Netflix 账户主使用。 42 | 43 | # 使用方法 44 | 45 | *这里只说明如何在 Docker 中使用,按照步骤走即可。* 46 | 47 | ## 1、安装 Docker 48 | 49 | 升级源并安装软件(下面两行命令二选一,根据你自己的系统) 50 | 51 | ```shell 52 | apt-get update && apt-get install -y wget vim git # Debian / Ubuntu 53 | yum update && yum install -y wget vim git # CentOS 54 | ``` 55 | 56 | 一句话命令安装 Docker 57 | 58 | ```shell 59 | wget -qO- get.docker.com | bash 60 | ``` 61 | 62 | 说明:请使用 KVM 架构的 VPS,OpenVZ 架构的 VPS 不支持安装 Docker,另外 CentOS 8 不支持用此脚本来安装 Docker。 更多关于 63 | Docker 64 | 安装的内容参考 [Docker 官方安装指南](https://docs.docker.com/engine/install/) 。 65 | 66 | 启动 Docker 67 | 68 | ```shell 69 | systemctl start docker 70 | ``` 71 | 72 | 设置开机自动启动 73 | 74 | ```shell 75 | sudo systemctl enable docker.service 76 | sudo systemctl enable containerd.service 77 | ``` 78 | 79 | ## 2、安装 Docker-compose 80 | 81 | 一句话命令安装 Docker-compose,如果想自定义版本,可以修改下面的版本号(`DOCKER_COMPOSE_VER`对应的值),否则保持默认就好。 82 | 83 | ```shell 84 | DOCKER_COMPOSE_VER=1.29.2 && sudo curl -L "https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VER}/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose && sudo chmod +x /usr/local/bin/docker-compose && sudo ln -snf /usr/local/bin/docker-compose /usr/bin/docker-compose && docker-compose --version 85 | ``` 86 | 87 | ## 3、拉取源码 88 | 89 | ```shell 90 | git clone https://github.com/luolongfei/netflix.git && cd netflix 91 | ``` 92 | 93 | ## 4、修改 .env 配置 94 | 95 | 完成步骤 3 后,现在你应该正位于源码根目录,即 `.env.example` 文件所在目录,执行 96 | 97 | ```shell 98 | cp .env.example .env 99 | ``` 100 | 101 | 然后使用`vim`修改`.env`文件中的配置项。注意在 Docker 中运行的话,`DRIVER_EXECUTABLE_FILE`、`REDIS_HOST`以及`REDIS_PORT` 102 | 的值保持默认即可。 103 | 104 | ## 5、运行 105 | 106 | 直接执行 107 | 108 | ```shell 109 | docker-compose up -d --build 110 | ``` 111 | 112 | 执行完成后,项目便在后台跑起来了。 113 | 114 | ## Docker-compose 常用命令 115 | 116 | 查看程式的运行状态 117 | 118 | ```shell 119 | docker-compose ps 120 | ``` 121 | 122 | 输出程序日志 123 | 124 | ```shell 125 | docker-compose logs 126 | ``` 127 | 128 | 停用 129 | 130 | ```shell 131 | docker-compose down 132 | ``` 133 | 134 | 在后台启用 135 | 136 | ```shell 137 | # 如果跟上 --build 参数,则会自动多一步重新构建所有容器的动作 138 | docker-compose up -d 139 | ``` 140 | 141 | 更多 Docker-compose 命令请参考: [Docker-compose 官方指南](https://docs.docker.com/compose/reference/) 。在官网能找到所有命令。 142 | 143 | ## 问答 144 | 145 | > 如何升级到新版本呢? 146 | > 147 | 请在`docker-compose.yml`文件所在目录,执行`git pull`拉取最新的代码,然后同样执行`docker-compose up -d --build`,Docker 148 | 会自动使用最新的代码进行构建, 149 | 构建完跑起来后,即是最新版本。 150 | 151 | > 非 Netflix 账户主可以使用本项目吗? 152 | > 153 | 不能。本项目仅供 Netflix 账户主使用,因为涉及到监听 Netflix 账户的邮件,而只有 Netflix 账户主才有 Netflix 邮箱以及其密码的权限,所以只有 154 | Netflix 账户主有权使用。 155 | -------------------------------------------------------------------------------- /data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luolongfei/netflix/e5fb7408cdeb4d6bbb83bf0c85032fd72d1509b3/data/.gitkeep -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | netflix: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | depends_on: 9 | - redis 10 | container_name: netflix 11 | volumes: 12 | - .:/conf 13 | - ./logs:/app/logs 14 | networks: 15 | - netflix-network 16 | restart: always 17 | redis: 18 | image: redis:6.2.6-bullseye 19 | container_name: redis_for_netflix 20 | volumes: 21 | - redis-for-netflix-data:/data 22 | networks: 23 | - netflix-network 24 | restart: always 25 | 26 | volumes: 27 | redis-for-netflix-data: 28 | 29 | networks: 30 | netflix-network: 31 | driver: bridge 32 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | #===================================================================# 4 | # Author: mybsdc # 5 | # Intro: https://github.com/luolongfei/netflix # 6 | #===================================================================# 7 | 8 | set -e 9 | 10 | # 生成配置文件 11 | if [ ! -f /conf/.env ]; then 12 | cp /app/.env.example /conf/.env 13 | echo "[Info] 已生成 .env 文件,请将 .env 文件中的配置项改为你自己的,然后重启容器" 14 | fi 15 | 16 | # 为配置文件建立软链接 17 | if [ ! -f /app/.env ]; then 18 | ln -s /conf/.env /app/.env 19 | fi 20 | 21 | # 等待 redis 就绪才执行 netflix 脚本 22 | # https://docs.docker.com/compose/startup-order/ 23 | # https://github.com/vishnubob/wait-for-it 24 | chmod +x ./wait-for-it.sh 25 | ./wait-for-it.sh redis_for_netflix:6379 --strict --timeout=24 -- python netflix.py -hl -f 26 | 27 | exec "$@" -------------------------------------------------------------------------------- /mail/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 邮件通知 6 | 293 | 294 | 295 |
296 |
297 |
298 |
300 |
301 |
303 |
304 |

305 | {} 306 |

307 |
308 |
309 | 311 |
312 |

Im Robot

313 |

314 | Netflix 自动恢复密码团队 敬上
315 | llf.push@gmail.com 316 |

317 |
318 |
319 |
320 |
322 |
323 |
324 |
325 |
326 | 327 | -------------------------------------------------------------------------------- /mail/images/mmsgletter_2_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luolongfei/netflix/e5fb7408cdeb4d6bbb83bf0c85032fd72d1509b3/mail/images/mmsgletter_2_bg.png -------------------------------------------------------------------------------- /mail/images/mmsgletter_2_bg_topline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luolongfei/netflix/e5fb7408cdeb4d6bbb83bf0c85032fd72d1509b3/mail/images/mmsgletter_2_bg_topline.png -------------------------------------------------------------------------------- /mail/images/mmsgletter_2_btn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luolongfei/netflix/e5fb7408cdeb4d6bbb83bf0c85032fd72d1509b3/mail/images/mmsgletter_2_btn.png -------------------------------------------------------------------------------- /mail/images/mmsgletter_chat_left.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luolongfei/netflix/e5fb7408cdeb4d6bbb83bf0c85032fd72d1509b3/mail/images/mmsgletter_chat_left.gif -------------------------------------------------------------------------------- /mail/images/mmsgletter_chat_right.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luolongfei/netflix/e5fb7408cdeb4d6bbb83bf0c85032fd72d1509b3/mail/images/mmsgletter_chat_right.gif -------------------------------------------------------------------------------- /mail/images/ting.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luolongfei/netflix/e5fb7408cdeb4d6bbb83bf0c85032fd72d1509b3/mail/images/ting.jpg -------------------------------------------------------------------------------- /netflix.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Netflix 6 | 7 | 监听奈飞(netflix)密码变更邮件,自动重置密码。 8 | 9 | 流程:实时监听邮件,发现有人修改了密码 -> 访问奈飞,点击忘记密码 -> 等待接收奈飞的重置密码邮件 -> 收到重置密码邮件,访问邮件内的链接, 10 | 进行密码重置操作,恢复初始密码 11 | 12 | @author mybsdc 13 | @date 2021/6/29 14 | @time 11:20 15 | """ 16 | 17 | import os 18 | import sys 19 | import time 20 | import argparse 21 | import random 22 | import string 23 | import json 24 | import re 25 | import datetime 26 | import traceback 27 | from functools import reduce, wraps 28 | from pathlib import Path 29 | from concurrent.futures import ThreadPoolExecutor, as_completed 30 | from selenium import webdriver 31 | from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException 32 | from selenium.webdriver.common.action_chains import ActionChains 33 | from selenium.webdriver.support import expected_conditions as EC 34 | from selenium.webdriver.common.by import By 35 | from selenium.webdriver.support.ui import WebDriverWait 36 | from selenium.webdriver.remote.webelement import WebElement 37 | from dotenv import load_dotenv 38 | from loguru import logger 39 | import imaplib 40 | import email 41 | from email.header import decode_header 42 | import redis 43 | import ssl 44 | import smtplib 45 | from email.mime.base import MIMEBase 46 | from email.mime.text import MIMEText 47 | from email.mime.image import MIMEImage 48 | from email.mime.multipart import MIMEMultipart 49 | from email.utils import formataddr 50 | from email import encoders 51 | from utils.version import __version__ 52 | from selenium.webdriver.support.ui import WebDriverWait, Select 53 | 54 | 55 | def retry(max_retries, exception_cls=Exception, uncaught_exception_cls=None): 56 | """ 57 | 重试装饰器 58 | :param max_retries: 最大尝试次数 59 | :param exception_cls: 重试次数超过最大次数后抛出的异常 60 | :param uncaught_exception_cls: 不捕获的异常,直接向外抛出,不重试 61 | :return: 62 | """ 63 | 64 | def wrapper(func): 65 | def inner_wrapper(*args, **kwargs): 66 | retries = 0 67 | while True: 68 | try: 69 | return func(*args, **kwargs) 70 | except Exception as e: 71 | # 无需处理的异常,直接抛出 72 | if uncaught_exception_cls and isinstance(e, uncaught_exception_cls): 73 | raise e 74 | 75 | retries += 1 76 | if retries > max_retries: 77 | raise exception_cls(e) 78 | 79 | sleep_time = retries * 2 80 | logger.warning( 81 | f'调用出错:{str(e)}。将于 {sleep_time} 秒后重试 {func.__name__}() [{retries}/{max_retries}]') 82 | 83 | time.sleep(sleep_time) 84 | 85 | return inner_wrapper 86 | 87 | return wrapper 88 | 89 | 90 | def catch_exception(origin_func): 91 | """ 92 | 用于异常捕获的装饰器 93 | :param origin_func: 94 | :return: 95 | """ 96 | 97 | def wrapper(*args, **kwargs): 98 | try: 99 | return origin_func(*args, **kwargs) 100 | except AssertionError as e: 101 | logger.error(f'参数错误:{str(e)}') 102 | except NoSuchElementException as e: 103 | logger.error('匹配元素超时,超过 {} 秒依然没有发现元素:{}', Netflix.TIMEOUT, str(e)) 104 | except TimeoutException as e: 105 | logger.error(f'查找元素超时或请求超时:{str(e)} [{Netflix.driver.current_url}]') 106 | except WebDriverException as e: 107 | logger.error(f'未知错误:{str(e)}') 108 | except Exception as e: 109 | logger.error('出错:{} 位置:{}', str(e), traceback.format_exc()) 110 | finally: 111 | Netflix.driver.quit() 112 | logger.info('已关闭浏览器,释放资源占用') 113 | 114 | return wrapper 115 | 116 | 117 | class Netflix(object): 118 | # 超时秒数 119 | # 如果同时设置了显式等待和隐式等待,则 webdriver 会取二者中更大的时间 120 | TIMEOUT = 24 121 | 122 | USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36' 123 | 124 | LOGIN_URL = 'https://www.netflix.com/login' 125 | LOGOUT_URL = 'https://www.netflix.com/SignOut?lnkctr=mL' 126 | RESET_PASSWORD_URL = 'https://www.netflix.com/password' 127 | FORGOT_PASSWORD_URL = 'https://www.netflix.com/LoginHelp' 128 | MANAGE_PROFILES_URL = 'https://www.netflix.com/ManageProfiles' 129 | BROWSE_URL = 'https://www.netflix.com/browse' 130 | ACCOUNT_URL = 'https://www.netflix.com/YourAccount' # 账户管理地址 131 | 132 | # 请求重置密码的邮件正则 133 | RESET_MAIL_REGEX = re.compile(r'accountaccess.*?URL_ACCOUNT_ACCESS', re.I) 134 | 135 | # 提取完成密码重置的链接正则 136 | RESET_URL_REGEX = re.compile(r'https://www\.netflix\.com/password[^]]+', re.I) 137 | 138 | # 匹配多账户 139 | MULTIPLE_ACCOUNTS_REGEX = re.compile(r'(?P[^\-\n]+?)-(?P

[^\-\n]+?)-(?P.+)', re.I) 140 | 141 | # 密码被重置邮件正则 142 | PWD_HAS_BEEN_CHANGED_REGEX = re.compile( 143 | r'https?://.*?netflix\.com/YourAccount\?(?:lnktrk=EMP&g=[^&]+&lkid=URL_YOUR_ACCOUNT_2|g=[^&]+&lkid=URL_YOUR_ACCOUNT&lnktrk=EVO)', 144 | re.I) 145 | 146 | # 奈飞强迫用户修改密码 147 | FORCE_CHANGE_PASSWORD_REGEX = re.compile(r'https?://www\.netflix\.com/LoginHelp.*?lkid=URL_LOGIN_HELP', re.I) 148 | 149 | MAIL_SYMBOL_REGEX = re.compile('{(?!})|(? undefined 198 | # }) 199 | # """ 200 | # }) 201 | 202 | # 隐藏无头浏览器特征,增加检测难度 203 | with open('resources/stealth.min.js') as f: 204 | stealth_js = f.read() 205 | 206 | self.driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', { 207 | 'source': stealth_js 208 | }) 209 | 210 | # 通用显式等待实例 211 | self.wait = WebDriverWait(self.driver, timeout=Netflix.TIMEOUT, poll_frequency=0.5) 212 | 213 | # 测试无头浏览器特征是否正确隐藏 214 | if self.args.test: 215 | logger.info('测试过程将只以无头模式进行') 216 | logger.info('开始测试无头浏览器特征是否正确隐藏') 217 | 218 | self.driver.get('https://bot.sannysoft.com/') 219 | 220 | time.sleep(3.5) 221 | 222 | filename = 'bot_test.png' 223 | self.__screenshot(filename, True) 224 | 225 | self.driver.quit() 226 | 227 | logger.info(f'已测试完成,测试结果保存在 {filename}') 228 | 229 | exit(0) 230 | 231 | self.PULL_MAIL_USERNAME = os.getenv('PULL_MAIL_USERNAME') 232 | assert self.PULL_MAIL_USERNAME, '请在 .env 文件配置 PULL_MAIL_USERNAME 的值,程式将监听此邮箱中的邮件内容' 233 | self.PULL_MAIL_PASSWORD = os.getenv('PULL_MAIL_PASSWORD') 234 | assert self.PULL_MAIL_PASSWORD, '请在 .env 文件配置 PULL_MAIL_PASSWORD 的值,程式用于登录被监听的邮箱' 235 | 236 | self.IMAP_HOST = os.getenv('IMAP_HOST', 'imap.gmail.com') 237 | self.IMAP_PORT = os.getenv('IMAP_PORT', 993) 238 | self.IMAP_SSL = int(os.getenv('IMAP_SSL', 1)) 239 | 240 | self.MULTIPLE_NETFLIX_ACCOUNTS = Netflix._parse_multiple_accounts() 241 | 242 | # 获取最近几天的邮件 243 | self.day = 3 244 | 245 | # 最多等待几分钟重置邮件的到来 246 | self.max_wait_reset_mail_time = 10 247 | 248 | # 恢复密码失败后最多重试几次 249 | self.max_num_of_attempts = 12 250 | 251 | self.first_time = [] 252 | self.today = Netflix.today_() 253 | 254 | # 线程池 255 | self.max_workers = self.args.max_workers 256 | 257 | # Redis 配置 258 | self.REDIS_HOST = os.getenv('REDIS_HOST', '127.0.0.1') 259 | self.REDIS_PORT = os.getenv('REDIS_PORT', 6379) 260 | self.redis = None 261 | 262 | @staticmethod 263 | def _parse_multiple_accounts(): 264 | accounts_file = Path('./accounts.txt') 265 | if accounts_file.exists(): 266 | with open(accounts_file, 'r', encoding='utf-8') as f: 267 | accounts = f.read() 268 | 269 | match = Netflix.MULTIPLE_ACCOUNTS_REGEX.findall(accounts) 270 | if match: 271 | return [{'u': item[0], 'p': item[1], 'n': item[2]} for item in match] 272 | 273 | accounts = os.getenv('MULTIPLE_NETFLIX_ACCOUNTS') 274 | match = re.findall(r'\[(?P[^|\]]+?)\|(?P

[^|\]]+?)\|(?P[^]]+?)\]', accounts, re.I) 275 | if match: 276 | return [{'u': item[0], 'p': item[1], 'n': item[2]} for item in match] 277 | 278 | raise Exception('未配置 Netflix 账户') 279 | 280 | @staticmethod 281 | def format_time(time: str or int, format: str = '%m/%d %H:%M:%S') -> str: 282 | return datetime.datetime.fromtimestamp(time).strftime(format) 283 | 284 | @staticmethod 285 | def today_(): 286 | return str(datetime.date.today()) 287 | 288 | def __logger_setting(self) -> None: 289 | logger.remove() 290 | 291 | level = 'DEBUG' if self.args.debug or int(os.getenv('DEBUG', 0)) else 'INFO' 292 | format = '[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level: <8} | {process.id}:{name}:{function}:{line} - {message}' 293 | 294 | logger.add('logs/{time:YYYY-MM-DD}.log', level=level, format=format, encoding='utf-8') 295 | logger.add(sys.stderr, colorize=True, level=level, format=format) 296 | 297 | @staticmethod 298 | def check_py_version(major=3, minor=7): 299 | if sys.version_info < (major, minor): 300 | raise UserWarning(f'请使用 python {major}.{minor} 及以上版本,推荐使用 python 3.9.8') 301 | 302 | @staticmethod 303 | def get_all_args(): 304 | """ 305 | 获取所有命令行参数 306 | :return: 307 | """ 308 | parser = argparse.ArgumentParser(description='Netflix 的各种参数及其含义', epilog='') 309 | parser.add_argument('-mw', '--max_workers', help='最大线程数', default=1, type=int) 310 | parser.add_argument('-d', '--debug', help='是否开启 Debug 模式', action='store_true') 311 | parser.add_argument('-f', '--force', help='是否强制执行,当然也要满足有“新的密码被重置的邮件”的条件', 312 | action='store_true') 313 | parser.add_argument('-t', '--test', help='测试无头浏览器特征是否正确隐藏', action='store_true') 314 | parser.add_argument('-hl', '--headless', help='是否启用无头模式', action='store_true') 315 | 316 | return parser.parse_args() 317 | 318 | def find_element_by_id(self, id: str, timeout: int or float = 24.0, ignored_exceptions=None, 319 | poll_frequency: int or float or None = None, message: str or None = None, 320 | scroll_into_view: bool = False, block: str = 'start') -> WebElement: 321 | """ 322 | 根据 id 查找元素 323 | 324 | 元素必须是已加载且可见才能寻到,如若未指定超时时间和异常处理等相关参数,则优先使用前期准备好的 WebDriverWait 实例,不再重复实例化 325 | :param id: 326 | :param timeout: 327 | :param ignored_exceptions: 328 | :param poll_frequency: 329 | :param message: 330 | :param scroll_into_view: 是否将元素滚动到可视范围内 331 | :param block: 定义垂直对齐方式,仅当 scroll_into_view 为 True 时才有意义,支持 start center end nearest 332 | :return: 333 | """ 334 | message = f'查找 id 为 {id} 的元素未果' if not message else message 335 | 336 | if not ignored_exceptions and timeout == Netflix.TIMEOUT and not poll_frequency: 337 | el = self.wait.until(EC.visibility_of_element_located((By.ID, id)), message) 338 | else: 339 | el = WebDriverWait(self.driver, timeout=timeout, poll_frequency=poll_frequency if poll_frequency else 0.5, 340 | ignored_exceptions=ignored_exceptions).until( 341 | EC.visibility_of_element_located((By.ID, id)), message) 342 | 343 | if scroll_into_view: 344 | self.scroll_page_until_el_is_visible(el, block) 345 | 346 | return el 347 | 348 | def find_element_by_class_name(self, class_name: str, timeout: int or float = 24.0, ignored_exceptions=None, 349 | poll_frequency: int or float or None = None, message: str or None = None, 350 | scroll_into_view: bool = False, block: str = 'start') -> WebElement: 351 | """ 352 | 根据 class name 查找元素 353 | 354 | 元素必须是已加载且可见才能寻到,如若未指定超时时间和异常处理等相关参数,则优先使用前期准备好的 WebDriverWait 实例,不再重复实例化 355 | :param class_name: 356 | :param timeout: 357 | :param ignored_exceptions: 358 | :param poll_frequency: 359 | :param message: 360 | :param scroll_into_view: 是否将元素滚动到可视范围内 361 | :param block: 定义垂直对齐方式,仅当 scroll_into_view 为 True 时才有意义,支持 start center end nearest 362 | :return: 363 | """ 364 | message = f'查找 class name 为 {class_name} 的元素未果' if not message else message 365 | 366 | if not ignored_exceptions and timeout == Netflix.TIMEOUT and not poll_frequency: 367 | el = self.wait.until(EC.visibility_of_element_located((By.CLASS_NAME, class_name)), message) 368 | else: 369 | el = WebDriverWait(self.driver, timeout=timeout, poll_frequency=poll_frequency if poll_frequency else 0.5, 370 | ignored_exceptions=ignored_exceptions).until( 371 | EC.visibility_of_element_located((By.CLASS_NAME, class_name)), message) 372 | 373 | if scroll_into_view: 374 | self.scroll_page_until_el_is_visible(el, block) 375 | 376 | return el 377 | 378 | def find_element_by_xpath(self, xpath: str, timeout: int or float = 24.0, ignored_exceptions=None, 379 | poll_frequency: int or float or None = None, message: str or None = None, 380 | scroll_into_view: bool = False, block: str = 'start') -> WebElement: 381 | """ 382 | 根据 xpath 查找元素 383 | 384 | 元素必须是已加载且可见才能寻到,如若未指定超时时间和异常处理等相关参数,则优先使用前期准备好的 WebDriverWait 实例,不再重复实例化 385 | :param xpath: 386 | :param timeout: 387 | :param ignored_exceptions: 388 | :param poll_frequency: 389 | :param message: 390 | :param scroll_into_view: 是否将元素滚动到可视范围内 391 | :param block: 定义垂直对齐方式,仅当 scroll_into_view 为 True 时才有意义,支持 start center end nearest 392 | :return: 393 | """ 394 | message = f'查找 xpath 为 {xpath} 的元素未果' if not message else message 395 | 396 | if not ignored_exceptions and timeout == Netflix.TIMEOUT and not poll_frequency: 397 | el = self.wait.until(EC.visibility_of_element_located((By.XPATH, xpath)), message) 398 | else: 399 | el = WebDriverWait(self.driver, timeout=timeout, poll_frequency=poll_frequency if poll_frequency else 0.5, 400 | ignored_exceptions=ignored_exceptions).until( 401 | EC.visibility_of_element_located((By.XPATH, xpath)), message) 402 | 403 | if scroll_into_view: 404 | self.scroll_page_until_el_is_visible(el, block) 405 | 406 | return el 407 | 408 | def find_element_by_tag_name(self, tag_name: str, timeout: int or float = 24.0, ignored_exceptions=None, 409 | poll_frequency: int or float or None = None, message: str or None = None, 410 | scroll_into_view: bool = False, block: str = 'start') -> WebElement: 411 | """ 412 | 根据 tag name 查找元素 413 | 414 | 元素必须是已加载且可见才能寻到,如若未指定超时时间和异常处理等相关参数,则优先使用前期准备好的 WebDriverWait 实例,不再重复实例化 415 | :param tag_name: 416 | :param timeout: 417 | :param ignored_exceptions: 418 | :param poll_frequency: 419 | :param message: 420 | :param scroll_into_view: 是否将元素滚动到可视范围内 421 | :param block: 定义垂直对齐方式,仅当 scroll_into_view 为 True 时才有意义,支持 start center end nearest 422 | :return: 423 | """ 424 | message = f'查找 tag name 为 {tag_name} 的元素未果' if not message else message 425 | 426 | if not ignored_exceptions and timeout == Netflix.TIMEOUT and not poll_frequency: 427 | el = self.wait.until(EC.visibility_of_element_located((By.TAG_NAME, tag_name)), message) 428 | else: 429 | el = WebDriverWait(self.driver, timeout=timeout, poll_frequency=poll_frequency if poll_frequency else 0.5, 430 | ignored_exceptions=ignored_exceptions).until( 431 | EC.visibility_of_element_located((By.TAG_NAME, tag_name)), message) 432 | 433 | if scroll_into_view: 434 | self.scroll_page_until_el_is_visible(el, block) 435 | 436 | return el 437 | 438 | def scroll_page_until_el_is_visible(self, el: WebElement, block: str = 'start') -> None: 439 | """ 440 | 滚动直到元素可见 441 | 442 | 按钮需要滚动直到可见,否则无法点击 443 | 参考:https://developer.mozilla.org/zh-CN/docs/Web/API/Element/scrollIntoView 444 | :param el: 445 | :param block: 定义垂直方向的对齐,“start”、“center”、“end”, 或“nearest”之一。默认为 start 446 | :return: 447 | """ 448 | self.driver.execute_script('arguments[0].scrollIntoView({{block: "{}"}});'.format(block), el) 449 | 450 | def _login(self, u: str, p: str, n: str = '') -> tuple: 451 | """ 452 | 登录 453 | :param u: 454 | :param p: 455 | :param n: 456 | :return: 457 | """ 458 | logger.debug('尝试登录账户:{}', u) 459 | 460 | # 多账户,每次登录前需要清除 cookies 461 | self.driver.delete_all_cookies() 462 | 463 | self.driver.get(Netflix.LOGIN_URL) 464 | 465 | username_input_el = self.find_element_by_id('id_userLoginId') 466 | username_input_el.clear() 467 | username_input_el.send_keys(u) 468 | 469 | time.sleep(1.1) 470 | 471 | pwd_input_el = self.find_element_by_id('id_password') 472 | pwd_input_el.clear() 473 | pwd_input_el.send_keys(p) 474 | 475 | self.find_element_by_class_name('login-button').click() 476 | 477 | if self.has_unknown_error_alert(): 478 | raise UserWarning(f'账户 {u} 可能正处于风控期间,无法登录,本次操作将被忽略') 479 | 480 | try: 481 | WebDriverWait(self.driver, timeout=3, poll_frequency=0.94).until(lambda d: 'browse' in d.current_url) 482 | except Exception: 483 | self.find_element_by_xpath('//a[@data-uia="header-signout-link"]', message='查找登出元素未果') 484 | 485 | logger.warning(f'当前账户可能非 Netflix 会员,本次登录没有意义') 486 | 487 | logger.debug(f'已成功登录。当前地址为:{self.driver.current_url}') 488 | 489 | return u, p, n 490 | 491 | def __forgot_password(self, netflix_username: str): 492 | """ 493 | 忘记密码 494 | :param netflix_username: 495 | :return: 496 | """ 497 | logger.info('尝试忘记密码') 498 | 499 | self.driver.delete_all_cookies() 500 | 501 | self.driver.get(Netflix.FORGOT_PASSWORD_URL) 502 | 503 | forgot_pwd = self.find_element_by_id('forgot_password_input') 504 | forgot_pwd.clear() 505 | forgot_pwd.send_keys(netflix_username) 506 | 507 | time.sleep(1) 508 | 509 | self.handle_event(self.click_forgot_pwd_btn, max_num_of_attempts=12) 510 | 511 | # 直到页面显示已发送邮件 512 | logger.debug('检测是否已到送信完成画面') 513 | self.find_element_by_xpath('//*[@class="login-content"]//h2[@data-uia="email_sent_label"]', 514 | message='查找送信完成元素未果') 515 | logger.info('已发送重置密码邮件到 {},注意查收', netflix_username) 516 | 517 | return True 518 | 519 | def click_forgot_pwd_btn(self): 520 | """ 521 | 点击忘记密码按钮 522 | :return: 523 | """ 524 | self.find_element_by_class_name('forgot-password-action-button').click() 525 | 526 | def __reset_password(self, curr_netflix_password: str, new_netflix_password: str): 527 | """ 528 | 账户内修改密码 529 | :param curr_netflix_password: 530 | :param new_netflix_password: 531 | :return: 532 | """ 533 | try: 534 | self.driver.get(Netflix.RESET_PASSWORD_URL) 535 | 536 | curr_pwd = self.find_element_by_id('id_currentPassword') 537 | curr_pwd.clear() 538 | curr_pwd.send_keys(curr_netflix_password) 539 | 540 | time.sleep(1) 541 | 542 | new_pwd = self.find_element_by_id('id_newPassword') 543 | new_pwd.clear() 544 | new_pwd.send_keys(new_netflix_password) 545 | 546 | time.sleep(1) 547 | 548 | confirm_new_pwd = self.find_element_by_id('id_confirmNewPassword') 549 | confirm_new_pwd.clear() 550 | confirm_new_pwd.send_keys(new_netflix_password) 551 | 552 | time.sleep(1.1) 553 | 554 | # 其它设备无需重新登录 555 | self.find_element_by_xpath('//li[@data-uia="field-requireAllDevicesSignIn+wrapper"]').click() 556 | 557 | time.sleep(1) 558 | 559 | self.handle_event(self.click_submit_btn) 560 | 561 | return self.__pwd_change_result() 562 | except Exception as e: 563 | raise Exception(f'直接在账户内修改密码出错:' + str(e)) 564 | 565 | def input_pwd(self, new_netflix_password: str) -> None: 566 | """ 567 | 输入密码 568 | :param new_netflix_password: 569 | :return: 570 | """ 571 | new_pwd = self.find_element_by_id('id_newPassword') 572 | new_pwd.clear() 573 | new_pwd.send_keys(new_netflix_password) 574 | 575 | time.sleep(2) 576 | 577 | confirm_new_pwd = self.find_element_by_id('id_confirmNewPassword') 578 | confirm_new_pwd.clear() 579 | confirm_new_pwd.send_keys(new_netflix_password) 580 | 581 | time.sleep(1) 582 | 583 | def click_submit_btn(self): 584 | """ 585 | 点击提交输入的密码 586 | :return: 587 | """ 588 | self.find_element_by_id('btn-save').click() 589 | 590 | def element_visibility_of(self, xpath: str, verify_val: bool = False, 591 | max_num_of_attempts: int = 3, el_wait_time: int = 2) -> WebElement or None: 592 | """ 593 | 元素是否存在且可见 594 | 适用于在已经加载完的网页做检测,可见且存在则返回元素,否则返回 None 595 | :param xpath: 596 | :param verify_val: 如果传入 True,则验证元素是否有值,或者 inner HTML 不为空,并作为关联条件 597 | :param max_num_of_attempts: 最大尝试次数,由于有的元素的值可能是异步加载的,需要多次尝试是否能获取到值,每次获取间隔休眠次数秒 598 | :param el_wait_time: 等待时间,查找元素最多等待多少秒,默认 2 秒 599 | :return: 600 | """ 601 | try: 602 | # 此处只为找到元素,如果下面不需要验证元素是否有值的话,则使用此处找到的元素 603 | # 否则下面验值逻辑会重新找到该元素以使用,此处的 el 会被覆盖 604 | el = self.find_element_by_xpath(xpath, timeout=el_wait_time) 605 | 606 | num = 0 607 | while True: 608 | if not verify_val: 609 | break 610 | 611 | # 需要每次循环找到此元素,以确定元素的值是否发生变化 612 | el = self.find_element_by_xpath(xpath, timeout=1) 613 | 614 | if el.tag_name == 'input': 615 | val = el.get_attribute('value') 616 | if val and len(val) > 0: 617 | break 618 | elif el.text != '': 619 | break 620 | 621 | # 多次尝试无果则放弃 622 | if num > max_num_of_attempts: 623 | break 624 | num += 1 625 | 626 | time.sleep(num) 627 | 628 | return el 629 | except Exception: 630 | return None 631 | 632 | def has_unknown_error_alert(self, error_el_xpath: str = '//div[@class="ui-message-contents"]') -> bool: 633 | """ 634 | 页面提示未知错误 635 | :return: 636 | """ 637 | error_tips_el = self.element_visibility_of(error_el_xpath, True) 638 | if error_tips_el: 639 | # 密码修改成功画面的提示语与错误提示语共用的同一个元素,防止误报 640 | if 'YourAccount?confirm=password' in self.driver.current_url or 'Your password has been changed' in error_tips_el.text: 641 | return False 642 | 643 | logger.warning(f'页面出现未知错误:{error_tips_el.text}') 644 | 645 | return True 646 | 647 | return False 648 | 649 | def handle_event(self, func, error_el_xpath='//div[@class="ui-message-contents"]', max_num_of_attempts: int = 10): 650 | """ 651 | 处理事件,一般是单个点击事件 652 | 653 | 在某些画面点击提交的时候,有可能报未知错误,需要稍等片刻再点击或者重新触发一系列事件后才正常 654 | :param func: 655 | :param max_num_of_attempts: 656 | :return: 657 | """ 658 | func() 659 | 660 | num = 0 661 | while True: 662 | if self.has_unknown_error_alert(error_el_xpath): 663 | func() 664 | 665 | if num >= max_num_of_attempts: 666 | raise Exception('处理未知错误失败') 667 | num += 1 668 | 669 | logger.debug( 670 | f'程式将休眠 {num} 秒后重试,最多不超过 {max_num_of_attempts} 次 [{num}/{max_num_of_attempts}]') 671 | time.sleep(num) 672 | else: 673 | break 674 | 675 | def __reset_password_via_mail(self, reset_url: str, new_netflix_password: str) -> bool: 676 | """ 677 | 通过邮件重置密码 678 | :param reset_url: 679 | :param new_netflix_password: 680 | :return: 681 | """ 682 | logger.info('尝试通过邮件内的重置密码链接进行密码重置操作') 683 | 684 | self.driver.delete_all_cookies() 685 | 686 | self.driver.get(reset_url) 687 | 688 | self.input_pwd(new_netflix_password) 689 | 690 | self.handle_event(self.click_submit_btn) 691 | 692 | # 如果奈飞提示密码曾经用过,则应该先改为随机密码,然后再改回来 693 | pwd_error_tips = self.element_visibility_of('//div[@data-uia="field-newPassword+error"]') 694 | if pwd_error_tips: 695 | logger.warning( 696 | '疑似 Netflix 提示你不能使用以前的密码(由于各种错误提示所在的 页面元素 相同,故无法准确判断,但是程式会妥善处理,不用担心)') 697 | logger.warning(f'原始的提示语为 {pwd_error_tips.text},故程式将尝试先改为随机密码,然后再改回正常密码。') 698 | 699 | random_pwd = self.gen_random_pwd() 700 | self.input_pwd(random_pwd) 701 | 702 | self.handle_event(self.click_submit_btn) 703 | 704 | self.__pwd_change_result() 705 | 706 | # 账户内直接将密码改回原始值 707 | logger.info('尝试在账户内直接将密码改回原始密码') 708 | 709 | return self.__reset_password(random_pwd, new_netflix_password) 710 | 711 | return self.__pwd_change_result() 712 | 713 | def __pwd_change_result(self): 714 | """ 715 | 断言密码修改结果 716 | :return: 717 | """ 718 | try: 719 | self.wait.until(lambda d: d.current_url == 'https://www.netflix.com/YourAccount?confirm=password') 720 | 721 | logger.info('已成功修改密码') 722 | 723 | return True 724 | except Exception as e: 725 | raise Exception(f'未能正确跳到密码修改成功画面,疑似未成功,抛出异常:' + str(e)) 726 | 727 | @staticmethod 728 | def parse_mail(data: bytes, onlySubject: bool = False) -> dict or str: 729 | """ 730 | 解析邮件内容 731 | :param data: 732 | :param onlySubject: 733 | :return: 734 | """ 735 | resp = { 736 | 'subject': '', 737 | 'from': '', 738 | 'date': '', 739 | 'text': '', 740 | 'html': '' 741 | } 742 | 743 | # 将字节邮件转换为一个 message 对象 744 | msg = email.message_from_bytes(data) 745 | 746 | # 解码邮件主题 747 | subject, encoding = decode_header(msg['Subject'])[0] 748 | if isinstance(subject, bytes): 749 | # 如果是字节类型,则解码为字符串 750 | subject = subject.decode(encoding) 751 | 752 | if onlySubject: 753 | return subject 754 | 755 | # 解码邮件发送者 756 | from_, encoding = decode_header(msg.get('From'))[0] 757 | if isinstance(from_, bytes): 758 | from_ = from_.decode(encoding) 759 | 760 | # 解码送信日期 761 | date, encoding = decode_header(msg.get('Date'))[0] 762 | if isinstance(date, bytes): 763 | date = date.decode(encoding) 764 | 765 | logger.debug(f'\nSubject: {subject}\nFrom: {from_}\nDate: {date}') 766 | 767 | resp['subject'] = subject 768 | resp['from'] = from_ 769 | resp['date'] = date 770 | 771 | # 邮件可能有多个部分,比如可能有 html、纯文本、附件 三个部分 772 | if msg.is_multipart(): 773 | # 遍历邮件的各部分 774 | for part in msg.walk(): 775 | # 获取邮件内容类型 776 | content_type = part.get_content_type() 777 | content_disposition = str(part.get('Content-Disposition')) 778 | 779 | if 'attachment' in content_disposition: 780 | # 附件,暂不处理 781 | # filename = part.get_filename() 782 | # if filename: 783 | # open(filename, 'wb').write(part.get_payload(decode=True)) 784 | continue 785 | 786 | try: 787 | # 获取邮件正文 788 | body = part.get_payload(decode=True).decode() 789 | except Exception as e: 790 | continue 791 | 792 | if content_type == 'text/plain': 793 | resp['text'] = body 794 | elif content_type == 'text/html': 795 | resp['html'] = body 796 | else: 797 | content_type = msg.get_content_type() 798 | body = msg.get_payload(decode=True).decode() 799 | 800 | if content_type == 'text/plain': 801 | resp['text'] = body 802 | elif content_type == 'text/html': 803 | # 可以选择将 html 写入文件以便预览,此处暂且不处理,直接给内容 804 | resp['html'] = body 805 | 806 | return resp 807 | 808 | @staticmethod 809 | def is_password_reset_result(text: str) -> bool: 810 | """ 811 | 是否密码重置结果邮件 812 | :param text: 813 | :return: 814 | """ 815 | return Netflix.PWD_HAS_BEEN_CHANGED_REGEX.search(text) is not None 816 | 817 | @staticmethod 818 | def is_password_reset_request(text: str): 819 | """ 820 | 是否请求重置密码的邮件 821 | :param text: 822 | :return: 823 | """ 824 | return Netflix.RESET_MAIL_REGEX.search(text) is not None 825 | 826 | @staticmethod 827 | def is_force_change_password_request(text: str): 828 | """ 829 | 是否奈飞强迫修改密码的邮件 830 | :param text: 831 | :return: 832 | """ 833 | return Netflix.FORCE_CHANGE_PASSWORD_REGEX.search(text) is not None 834 | 835 | def get_mail_last_id(self, netflix_account_email: str): 836 | """ 837 | 获取最新的邮件 ID 838 | :param netflix_account_email: 839 | :return: 840 | """ 841 | key_last_id = f'{netflix_account_email}.last_id' 842 | last_id = self.redis.get(key_last_id) if self.redis.exists(key_last_id) else 0 843 | 844 | return last_id 845 | 846 | def set_mail_last_id(self, netflix_account_email: str, id: int) -> bool: 847 | """ 848 | 设置最新的邮件 ID 849 | :param netflix_account_email: 850 | :param id: 851 | :return: 852 | """ 853 | key_last_id = f'{netflix_account_email}.last_id' 854 | self.redis.set(key_last_id, id) 855 | 856 | return True 857 | 858 | def is_need_to_do(self, netflix_account_email: str) -> int: 859 | """ 860 | 是否需要做处理 861 | :param netflix_account_email: 862 | :return: 863 | """ 864 | key_need_to_do = f'{netflix_account_email}.need_to_do' 865 | need_to_do = self.redis.get(key_need_to_do) if self.redis.exists(key_need_to_do) else 1 866 | 867 | return need_to_do 868 | 869 | def set_need_to_do(self, netflix_account_email: str, status: int = 1) -> bool: 870 | """ 871 | 设置是否需要做处理 872 | :param netflix_account_email: 873 | :param status: 1:需要 0:不需要 874 | :return: 875 | """ 876 | key_need_to_do = f'{netflix_account_email}.need_to_do' 877 | self.redis.set(key_need_to_do, status) 878 | 879 | return True 880 | 881 | def __fetch_mail(self, netflix_account_email: str, onlySubject: bool = False) -> str or None: 882 | """ 883 | 拉取邮件 884 | :param netflix_account_email: 885 | :param onlySubject: 886 | :return: 887 | """ 888 | logger.debug('尝试拉取最新邮件,以监听是否有重置密码相关的邮件') 889 | 890 | with (imaplib.IMAP4_SSL(self.IMAP_HOST, self.IMAP_PORT) if self.IMAP_SSL else imaplib.IMAP4(self.IMAP_HOST, 891 | self.IMAP_PORT)) as M: 892 | M.login(self.PULL_MAIL_USERNAME, self.PULL_MAIL_PASSWORD) 893 | status, total = M.select('INBOX', readonly=True) # readonly=True 则邮件将不会被标记为已读 894 | 895 | # https://gist.github.com/martinrusev/6121028 896 | # https://stackoverflow.com/questions/5621341/search-before-after-with-pythons-imaplib 897 | after_date = (datetime.date.today() - datetime.timedelta(self.day)).strftime( 898 | '%d-%b-%Y') # 仅需要最近 N 天的邮件,%b 表示字符月份 899 | criteria = f'(TO "{netflix_account_email}" SENTSINCE "{after_date}")' 900 | # criteria = f'(SENTSINCE "{after_date}")' 901 | status, data = M.search(None, criteria) 902 | if status != 'OK': 903 | raise Exception('通过发信人以及送信时间过滤邮件时出错') 904 | 905 | last_id = self.get_mail_last_id(netflix_account_email) 906 | data = data[0].split()[::-1] 907 | for num in data: 908 | id = int(num) 909 | if id <= last_id: # 只要最新未读的 910 | continue 911 | 912 | status, mail_data = M.fetch(num, '(RFC822)') 913 | if status != 'OK': 914 | logger.error( 915 | f'邮箱 {self.PULL_MAIL_USERNAME} 在为 {netflix_account_email} 拉取 ID 为 {id} 的邮件时出错') 916 | 917 | continue 918 | 919 | # 解析邮件 920 | resp = Netflix.parse_mail(mail_data[0][1], onlySubject) 921 | 922 | # 记录邮件 ID,之后此邮箱的此类型邮件必须大于此 ID 才有效,且此 ID 跟随 Netflix 账户 923 | self.set_mail_last_id(netflix_account_email, id) 924 | 925 | return resp 926 | 927 | return None 928 | 929 | def pwd_result_mail_listener(self, netflix_account_email: str): 930 | """ 931 | 监听密码重置结果邮件 932 | 既可能是恶意用户,也可能是 Netflix 强迫用户重置密码而发来的邮件,借此触发我们后续流程 933 | :param netflix_account_email: 934 | :return: 935 | """ 936 | # 拉取最新邮件 937 | resp = self.__fetch_mail(netflix_account_email) 938 | if not resp: 939 | return None 940 | 941 | # 定义事件类型 0:未知 1:用户恶意修改密码 2:Netflix 强迫用户修改密码 942 | event_type = 0 943 | 944 | if Netflix.is_password_reset_result(resp['text']): # 检测到有用户恶意修改密码 945 | logger.info('检测到有人修改了 Netflix 账户 {} 的密码', netflix_account_email) 946 | 947 | event_type = 1 948 | need_to_do = self.is_need_to_do(netflix_account_email) 949 | if not need_to_do: 950 | logger.info('今次检测到的密码重置结果邮件应是脚本的动作回执,故不做处理') 951 | 952 | self.set_need_to_do(netflix_account_email, 1) 953 | 954 | return None 955 | 956 | # 处理首次运行程式的情形 957 | if netflix_account_email not in self.first_time: 958 | self.first_time.append(netflix_account_email) 959 | 960 | if self.args.force: 961 | logger.info(f'强制运行,检测到账户 {netflix_account_email} 存在密码被重置的邮件,已触发密码重置流程') 962 | 963 | return True, event_type 964 | 965 | logger.info( 966 | f'首次运行,故今次检测账户 {netflix_account_email},发现的都是一些旧的密码被重置的邮件,不做处理') 967 | 968 | return None 969 | 970 | return True, event_type 971 | elif Netflix.is_force_change_password_request(resp['text']): # 检测到奈飞强迫用户修改密码 972 | logger.info('检测到 Netflix 以安全起见,强迫用户修改账户 {} 的密码', netflix_account_email) 973 | 974 | event_type = 2 975 | 976 | return True, event_type 977 | 978 | def pwd_reset_request_mail_listener(self, netflix_account_email) -> str or None: 979 | """ 980 | 监听请求重置密码的邮件 981 | 在发起重置密码动作后,我们会收到 Netflix 的邮件 982 | :param netflix_account_email: 983 | :return: 984 | """ 985 | # 拉取最新邮件 986 | resp = self.__fetch_mail(netflix_account_email) 987 | 988 | if resp and self.is_password_reset_request(resp.get('text', '')): 989 | logger.info('Netflix 账户 {} 已收到请求重置密码的邮件,开始提取重置链接', netflix_account_email) 990 | 991 | match = Netflix.RESET_URL_REGEX.search(resp['text']) 992 | if not match: 993 | raise Exception('已命中重置密码邮件,但是未能正确提取重置密码链接,请调查一下') 994 | 995 | logger.info('已成功提取重置密码链接') 996 | logger.info(f'本次重置链接为:{match.group(0)}') 997 | 998 | return match.group(0) 999 | 1000 | return None 1001 | 1002 | @staticmethod 1003 | def time_diff(start_time, end_time): 1004 | """ 1005 | 计算时间间隔 1006 | :param start_time: 开始时间戳 1007 | :param end_time: 结束时间戳 1008 | :return: 1009 | """ 1010 | diff_time = end_time - start_time 1011 | 1012 | if diff_time < 0: 1013 | raise ValueError('结束时间必须大于等于开始时间') 1014 | 1015 | if diff_time < 1: 1016 | return '{:.2f}秒'.format(diff_time) 1017 | else: 1018 | diff_time = int(diff_time) 1019 | 1020 | if diff_time < 60: 1021 | return '{:02d}秒'.format(diff_time) 1022 | elif 60 <= diff_time < 3600: 1023 | m, s = divmod(diff_time, 60) 1024 | 1025 | return '{:02d}分钟{:02d}秒'.format(m, s) 1026 | elif 3600 <= diff_time < 24 * 3600: 1027 | m, s = divmod(diff_time, 60) 1028 | h, m = divmod(m, 60) 1029 | 1030 | return '{:02d}小时{:02d}分钟{:02d}秒'.format(h, m, s) 1031 | elif 24 * 3600 <= diff_time: 1032 | m, s = divmod(diff_time, 60) 1033 | h, m = divmod(m, 60) 1034 | d, h = divmod(h, 24) 1035 | 1036 | return '{:02d}天{:02d}小时{:02d}分钟{:02d}秒'.format(d, h, m, s) 1037 | 1038 | def __do_reset(self, netflix_account_email: str, p: str) -> bool: 1039 | """ 1040 | 执行重置密码流程 1041 | :param netflix_account_email: 1042 | :param p: 1043 | :return: 1044 | """ 1045 | self.__forgot_password(netflix_account_email) 1046 | 1047 | logger.info('等待接收重置密码链接') 1048 | 1049 | # 坐等奈飞发送的重置密码链接 1050 | wait_start_time = time.time() 1051 | 1052 | while True: 1053 | reset_link = self.pwd_reset_request_mail_listener(netflix_account_email) 1054 | 1055 | if reset_link: 1056 | self.set_need_to_do(netflix_account_email, 0) # 忽略下一封密码重置邮件 1057 | 1058 | break 1059 | 1060 | if (time.time() - wait_start_time) > 60 * self.max_wait_reset_mail_time: 1061 | raise Exception( 1062 | f'等待超过 {self.max_wait_reset_mail_time} 分钟,依然没有收到奈飞的重置密码来信,故将重走恢复密码流程') 1063 | 1064 | time.sleep(2) 1065 | 1066 | return self.__reset_password_via_mail(reset_link, p) 1067 | 1068 | @staticmethod 1069 | def now(format='%Y-%m-%d %H:%M:%S.%f'): 1070 | """ 1071 | 当前时间 1072 | 精确到毫秒 1073 | :return: 1074 | """ 1075 | now = datetime.datetime.now().strftime(format) 1076 | 1077 | return now[:-3] if '%f' in format else now 1078 | 1079 | def __screenshot(self, filename: str, full_page=False): 1080 | """ 1081 | 截图 1082 | :param filename: 1083 | :param full_page: 仅无头模式支持截取全屏 1084 | :return: 1085 | """ 1086 | dir = os.path.dirname(filename) 1087 | if dir and not os.path.exists(dir): 1088 | os.makedirs(dir) 1089 | 1090 | if full_page: 1091 | if not self.args.headless and not self.args.test: # 若跟上 -t 参数则默认使用无头模式,可不传 -hl 1092 | raise Exception('仅无头模式支持全屏截图,请跟上 -hl 参数后重试') 1093 | 1094 | original_size = self.driver.get_window_size() 1095 | required_width = self.driver.execute_script('return document.body.parentNode.scrollWidth') 1096 | required_height = self.driver.execute_script('return document.body.parentNode.scrollHeight') 1097 | 1098 | self.driver.set_window_size(required_width, required_height) 1099 | 1100 | self.driver.find_element_by_tag_name('body').screenshot(filename) # 通过 body 元素截图可隐藏滚动条 1101 | 1102 | self.driver.set_window_size(original_size['width'], original_size['height']) 1103 | 1104 | return True 1105 | 1106 | self.driver.save_screenshot(filename) 1107 | 1108 | return True 1109 | 1110 | @staticmethod 1111 | def symbol_replace(val): 1112 | """ 1113 | 转义花括符 1114 | :param val: 1115 | :return: 1116 | """ 1117 | real_val = val.group() 1118 | if real_val == '{': 1119 | return '{{' 1120 | elif real_val == '}': 1121 | return '}}' 1122 | else: 1123 | return '' 1124 | 1125 | @staticmethod 1126 | def send_mail(subject: str, content: str or list, to: str = None, files: list = [], text_plain: str = '', 1127 | template='default') -> bool: 1128 | """ 1129 | 发送邮件 1130 | :param subject: 1131 | :param content: 1132 | :param to: 1133 | :param files: 1134 | :param text_plain: 纯文本,可选 1135 | :param template: 1136 | :return: 1137 | """ 1138 | try: 1139 | if not to: 1140 | to = os.getenv('INBOX') 1141 | assert to, '尚未在 .env 文件中检测到 INBOX 的值,请配置之' 1142 | 1143 | # 发信邮箱账户 1144 | username = os.getenv('PUSH_MAIL_USERNAME') 1145 | password = os.getenv('PUSH_MAIL_PASSWORD') 1146 | 1147 | # 根据发信邮箱类型自动使用合适的配置 1148 | if '@gmail.com' in username: 1149 | host = 'smtp.gmail.com' 1150 | secure = 'tls' 1151 | port = 587 1152 | elif '@qq.com' in username: 1153 | host = 'smtp.qq.com' 1154 | secure = 'tls' 1155 | port = 587 1156 | elif '@163.com' in username: 1157 | host = 'smtp.163.com' 1158 | secure = 'ssl' 1159 | port = 465 1160 | elif '@hhhzzz.cc' in username or '@98hg.top': 1161 | # host = 'mail.98hg.top' 1162 | host = 'mail.lhezu.com' 1163 | # host = '36.129.24.59' 1164 | secure = 'tls' 1165 | port = 465 1166 | else: 1167 | raise ValueError( 1168 | f'「{username}」 是不受支持的邮箱。目前仅支持谷歌邮箱、QQ邮箱以及163邮箱,推荐使用谷歌邮箱。') 1169 | 1170 | # 格式化邮件内容 1171 | if isinstance(content, list): 1172 | with open('./mail/{}.html'.format(template), 'r', encoding='utf-8') as f: 1173 | template_content = f.read() 1174 | text = Netflix.MAIL_SYMBOL_REGEX.sub(Netflix.symbol_replace, template_content).format(*content) 1175 | real_content = text.replace('{{', '{').replace('}}', '}') 1176 | elif isinstance(content, str): 1177 | real_content = content 1178 | else: 1179 | raise TypeError(f'邮件内容类型仅支持 list 或 str,当前传入的类型为 {type(content)}') 1180 | 1181 | # 邮件内容设置多个部分 1182 | msg = MIMEMultipart('alternative') 1183 | 1184 | msg['From'] = formataddr(('Im Robot', username)) 1185 | msg['To'] = formataddr(('', to)) 1186 | msg['Subject'] = subject 1187 | 1188 | # 添加纯文本内容(针对不支持 html 的邮件客户端) 1189 | # 注意:当同时包含纯文本和 html 时,一定要先添加纯文本再添加 html,因为一般邮件客户端默认优先展示最后添加的部分 1190 | # https://realpython.com/python-send-email/ 1191 | # https://docs.python.org/3/library/email.mime.html 1192 | # As not all email clients display HTML content by default, and some people choose only to receive plain-text emails for security reasons, 1193 | # it is important to include a plain-text alternative for HTML messages. As the email client will render the last multipart attachment first, 1194 | # make sure to add the HTML message after the plain-text version. 1195 | if text_plain: 1196 | msg.attach(MIMEText(text_plain, 'plain', 'utf-8')) 1197 | elif isinstance(content, str): # 仅当传入内容是纯文本才添加纯文本内容,因为一般传入 list 的情况下,我只想发送 html 内容 1198 | text_plain = MIMEText(content, 'plain', 'utf-8') 1199 | msg.attach(text_plain) 1200 | 1201 | # 添加网页 1202 | page = MIMEText(real_content, 'html', 'utf-8') 1203 | msg.attach(page) 1204 | 1205 | # 添加 html 内联图片,仅适配模板中头像 1206 | if isinstance(content, list): 1207 | with open('mail/images/ting.jpg', 'rb') as img: 1208 | avatar = MIMEImage(img.read()) 1209 | avatar.add_header('Content-ID', '') 1210 | msg.attach(avatar) 1211 | 1212 | # 添加附件 1213 | for path in files: # 注意,如果文件尺寸为 0 会被忽略 1214 | if not os.path.exists(path): 1215 | logger.error(f'发送邮件时,发现要添加的附件({path})不存在,本次已忽略此附件') 1216 | 1217 | continue 1218 | 1219 | part = MIMEBase('application', 'octet-stream') 1220 | with open(path, 'rb') as file: 1221 | part.set_payload(file.read()) 1222 | 1223 | encoders.encode_base64(part) 1224 | part.add_header('Content-Disposition', 'attachment; filename="{}"'.format(Path(path).name)) 1225 | msg.attach(part) 1226 | 1227 | with smtplib.SMTP_SSL(host=host, port=port) if secure == 'ssl' else smtplib.SMTP(host=host, 1228 | port=port) as server: 1229 | # 启用 tls 加密,优于 ssl 1230 | if secure == 'tls': 1231 | server.starttls(context=ssl.create_default_context()) 1232 | 1233 | server.login(username, password) 1234 | server.sendmail(from_addr=username, to_addrs=to, msg=msg.as_string()) 1235 | 1236 | return True 1237 | except Exception as e: 1238 | logger.error('邮件送信失败:' + str(e)) 1239 | 1240 | return False 1241 | 1242 | @staticmethod 1243 | def gen_random_pwd(length: int = 13): 1244 | """ 1245 | 生成随机密码 1246 | :param length: 1247 | :return: 1248 | """ 1249 | characters = string.ascii_letters + string.digits # + string.punctuation 1250 | password = ''.join(random.choice(characters) for i in range(length)) 1251 | 1252 | return password 1253 | 1254 | @staticmethod 1255 | def send_keys_delay_random(element, keys, min_delay=0.11, max_delay=0.24): 1256 | """ 1257 | 随机延迟输入 1258 | :param element: 1259 | :param keys: 1260 | :param min_delay: 1261 | :param max_delay: 1262 | :return: 1263 | """ 1264 | for key in keys: 1265 | element.send_keys(key) 1266 | time.sleep(random.uniform(min_delay, max_delay)) 1267 | 1268 | def error_page_screenshot(self) -> str: 1269 | """ 1270 | 错误画面实时截图 1271 | :return: 1272 | """ 1273 | screenshot_file = f'logs/screenshots/error_page/{self.now("%Y-%m-%d_%H_%M_%S_%f")}.png' 1274 | 1275 | self.__screenshot(screenshot_file) 1276 | 1277 | logger.info(f'出错画面已被截图,图片文件保存在:{screenshot_file}') 1278 | 1279 | return screenshot_file 1280 | 1281 | @staticmethod 1282 | def get_event_reason(event_type: int) -> str: 1283 | if event_type == 1: 1284 | return '用户恶意修改密码' 1285 | elif event_type == 2: 1286 | return 'Netflix 强迫用户修改密码' 1287 | 1288 | return '未知原因' 1289 | 1290 | def __recover_name(self, link_el: WebElement, real_name: str) -> bool: 1291 | """ 1292 | 执行用户名恢复操作 1293 | :param link_el: 1294 | :param real_name: 1295 | :return: 1296 | """ 1297 | try: 1298 | link_el.click() 1299 | 1300 | save_btn = self.find_element_by_xpath('//button[@data-uia="profile-save-button"]', timeout=4.2, 1301 | poll_frequency=0.94, message='保存按钮尚未准备好') 1302 | 1303 | name_input_el = self.find_element_by_id('profile-name-entry') 1304 | name_input_el.clear() 1305 | name_input_el.send_keys(real_name) 1306 | 1307 | save_btn.click() 1308 | 1309 | self.find_element_by_class_name('profile-link', timeout=5, poll_frequency=0.94, 1310 | message='编辑按钮元素不可见') 1311 | 1312 | return True 1313 | except Exception as e: 1314 | logger.error(f'尝试恢复用户名出错:{str(e)}') 1315 | 1316 | return False 1317 | 1318 | def _logout(self, u: str): 1319 | """ 1320 | 登出 1321 | :param u: 1322 | :return: 1323 | """ 1324 | try: 1325 | logger.debug(f'尝试登出 [账户:{u}]') 1326 | 1327 | self.driver.get(Netflix.LOGOUT_URL) 1328 | 1329 | self.find_element_by_xpath('//a[@data-uia="header-login-link"]', timeout=4.9, poll_frequency=0.5, 1330 | message='查找登入元素未果') 1331 | 1332 | logger.debug(f'登出成功 [账户:{u}]') 1333 | 1334 | return True 1335 | except Exception as e: 1336 | logger.warning('登出失败:{}', str(e)) 1337 | 1338 | return False 1339 | 1340 | @staticmethod 1341 | def pipeline(*steps): 1342 | """ 1343 | 管道调用 1344 | :param steps: 1345 | :return: 1346 | """ 1347 | return reduce(lambda x, y: y(*x) if isinstance(x, tuple) else y(x), steps) 1348 | 1349 | def __handle_account_name(self, u: str, n: str) -> str: 1350 | """ 1351 | 处理账户名被篡改的问题 1352 | :param u: 1353 | :param n: 1354 | :return: 1355 | """ 1356 | try: 1357 | logger.debug('开始处理用户名被篡改的问题') 1358 | 1359 | self.driver.get(Netflix.MANAGE_PROFILES_URL) 1360 | 1361 | WebDriverWait(self.driver, timeout=3, poll_frequency=0.94).until( 1362 | lambda d: 'ManageProfiles' in d.current_url, 1363 | f'{u} 可能非会员,无法访问 {Netflix.MANAGE_PROFILES_URL} 地址') 1364 | 1365 | # 五个子账户,逐个检查 1366 | success_num = 0 1367 | events_count = 0 1368 | for index in range(5): 1369 | real_name = n + f'_0{index + 1}' 1370 | 1371 | link_el = self.driver.find_elements_by_xpath('//a[@class="profile-link"]')[index] # TODO 多元素 1372 | curr_name = link_el.text 1373 | 1374 | if curr_name != real_name: 1375 | logger.info(f'发现用户名被篡改为 【{curr_name}】') 1376 | events_count += 1 1377 | 1378 | if self.__recover_name(link_el, real_name): 1379 | logger.success(f'程式已将 【{curr_name}】 恢复为 【{real_name}】') 1380 | 1381 | # 成功处理一件,就记录一件 1382 | success_num += 1 1383 | 1384 | if success_num: 1385 | self.find_element_by_xpath('//span[@data-uia="profile-button"]').click() 1386 | 1387 | WebDriverWait(self.driver, timeout=3, poll_frequency=0.94).until( 1388 | lambda d: 'browse' in d.current_url) 1389 | 1390 | logger.success(f'用户名已恢复完成,共 {events_count} 件篡改事件,已成功处理 {success_num} 件') 1391 | 1392 | logger.debug('用户名处理结束') 1393 | except Exception as e: 1394 | logger.warning(f'处理用户名被篡改问题出错:{str(e)} [账户:{u}]') 1395 | finally: 1396 | return u 1397 | 1398 | def is_locked(self, svg_el_xpath: str, time_out: int = 1) -> bool: 1399 | """ 1400 | 账户是否被锁定 1401 | :param svg_el_xpath: 1402 | :return: 1403 | """ 1404 | try: 1405 | self.find_element_by_xpath(svg_el_xpath, timeout=time_out, poll_frequency=0.5, message='不存在 svg 元素') 1406 | 1407 | return True 1408 | except Exception: 1409 | return False 1410 | 1411 | def __unlock_account(self, link_el: WebElement, u: str, p: str) -> bool: 1412 | """ 1413 | 解锁账户 1414 | :param link_el: 1415 | :param p: 1416 | :return: 1417 | """ 1418 | try: 1419 | link_el.click() 1420 | 1421 | input_el = self.find_element_by_xpath('//input[@data-uia="input-account-content-restrictions"]', 1422 | timeout=9.4) 1423 | input_el.clear() 1424 | input_el.send_keys(p) 1425 | 1426 | time.sleep(0.94) 1427 | 1428 | self.find_element_by_xpath('//button[@data-uia="btn-account-pin-submit"]').click() 1429 | 1430 | # 取消勾选锁定 1431 | self.find_element_by_xpath('//label[@for="bxid_lock-profile_true"]').click() 1432 | 1433 | # 提交 1434 | WebDriverWait(self.driver, timeout=3, poll_frequency=0.5).until( 1435 | EC.element_to_be_clickable((By.XPATH, '//button[@data-uia="btn-account-pin-submit"]')), 1436 | '提交按钮不可点击').click() 1437 | 1438 | WebDriverWait(self.driver, timeout=4, poll_frequency=0.94).until(EC.url_contains('YourAccount'), 1439 | '未能正确跳回账户管理画面') 1440 | 1441 | return True 1442 | except Exception as e: 1443 | logger.error(f'尝试解锁账户【{u}】出错:{str(e)}') 1444 | 1445 | return False 1446 | 1447 | def __handle_account_lock(self, u: str, p: str, n: str) -> tuple: 1448 | """ 1449 | 处理账户被锁 PIN 的问题 1450 | :param u: 1451 | :param p: 1452 | :param n: 1453 | :return: 1454 | """ 1455 | logger.debug('开始处理账户被锁 PIN 的问题') 1456 | 1457 | # 五个子账户,逐个检查 1458 | success_num = 0 1459 | events_count = 0 1460 | for index in range(1, 6): 1461 | try: 1462 | # 检查是否账户管理画面 1463 | if 'YourAccount' not in self.driver.current_url: 1464 | self.driver.get(Netflix.ACCOUNT_URL) 1465 | WebDriverWait(self.driver, timeout=4.9, poll_frequency=0.94).until( 1466 | lambda d: 'YourAccount' in d.current_url, f'{u} 可能非会员,无法访问 {Netflix.ACCOUNT_URL} 地址') 1467 | 1468 | # 定位到账户区域 1469 | self.find_element_by_xpath('//div[@class="profile-hub"]', scroll_into_view=True, block='center') 1470 | 1471 | svg_el_xpath = f'(//li[contains(@class, "single-profile")])[{index}]//*[contains(@class, "svg-icon-profile-lock")]' 1472 | if self.is_locked(svg_el_xpath): 1473 | single_profile_el = self.find_element_by_xpath(f'//li[@id="profile_{index - 1}"]') 1474 | 1475 | # 展开列表选项 1476 | single_profile_el.find_element_by_xpath('.//button[@class="profile-action-icons"]').click() 1477 | 1478 | account_name = single_profile_el.find_element_by_xpath('.//div[@class="profile-summary"]/h3').text 1479 | 1480 | logger.info(f'发现【{account_name}】被锁 PIN') 1481 | events_count += 1 1482 | 1483 | # 变更链接 1484 | link_el = single_profile_el.find_element_by_xpath( 1485 | './/a[@data-uia="action-profile-lock"]//div[@class="profile-change"]') 1486 | 1487 | # 解锁 1488 | if self.__unlock_account(link_el, u, p): 1489 | logger.success(f'【{account_name}】已解除锁定') 1490 | success_num += 1 1491 | else: 1492 | logger.debug(f'第 {index} 个账户是正常的,无需解锁') 1493 | except Exception as e: 1494 | logger.warning(f'处理账户被锁 PIN 问题出错:{str(e)} [账户:{u}]') 1495 | 1496 | if success_num: 1497 | logger.success(f'解锁完成,共 {events_count} 件被锁事件,已成功处理 {success_num} 件') 1498 | 1499 | logger.debug('账户被锁 PIN 问题处理结束') 1500 | 1501 | return u, n 1502 | 1503 | def protect_account(self): 1504 | """ 1505 | 保护账户 1506 | 1507 | 防止篡改与锁定 1508 | :return: 1509 | """ 1510 | for item in self.MULTIPLE_NETFLIX_ACCOUNTS: 1511 | u = item.get('u') 1512 | p = item.get('p') 1513 | n = item.get('n') 1514 | 1515 | try: 1516 | self.pipeline((u, p, n), self._login, self.__handle_account_lock, self.__handle_account_name, 1517 | self._logout) 1518 | except UserWarning as e: 1519 | logger.debug(str(e)) 1520 | except Exception as e: 1521 | logger.warning(f'保护用户出错:{str(e)} [账户:{u}]') 1522 | 1523 | def open_new_tab(self, url: str = '') -> None: 1524 | """ 1525 | 打开一个新标签页,并切入 1526 | :param url: 1527 | :return: 1528 | """ 1529 | logger.debug('打开新标签') 1530 | 1531 | if url == '': 1532 | url = 'about:blank' 1533 | 1534 | # 打开一个新标签页 1535 | self.driver.execute_script(f"window.open('{url}', '_blank');") 1536 | 1537 | # 切换到新标签页 1538 | self.driver.switch_to.window(self.driver.window_handles[-1]) 1539 | 1540 | def close_other_tabs(self): 1541 | """ 1542 | 关闭其它标签页 1543 | :return: 1544 | """ 1545 | logger.debug('关闭其它标页') 1546 | 1547 | # 获取所有标签页的句柄 1548 | handles = self.driver.window_handles 1549 | 1550 | # 获取当前标签页的句柄 1551 | current_handle = self.driver.current_window_handle 1552 | 1553 | # 关闭除当前标签页以外的所有标签页 1554 | for handle in handles: 1555 | if handle != current_handle: 1556 | self.driver.switch_to.window(handle) 1557 | self.driver.close() 1558 | 1559 | # 将控制权切换回原始标签页 1560 | self.driver.switch_to.window(current_handle) 1561 | 1562 | @retry(max_retries=5) 1563 | def clear_browser_data(self) -> None: 1564 | """ 1565 | 清除浏览器数据 1566 | :return: 1567 | """ 1568 | try: 1569 | self.driver.get('chrome://settings/clearBrowserData') 1570 | 1571 | time.sleep(0.82011) 1572 | 1573 | time_select_el = self.driver.execute_script( 1574 | """return document.querySelector("body > settings-ui").shadowRoot.querySelector("#main").shadowRoot.querySelector("settings-basic-page").shadowRoot.querySelector("#basicPage > settings-section > settings-privacy-page").shadowRoot.querySelector("settings-clear-browsing-data-dialog").shadowRoot.querySelector("#clearFromBasic").shadowRoot.querySelector("#dropdownMenu");""") 1575 | 1576 | # 时间不限 1577 | Select(time_select_el).select_by_value('4') 1578 | 1579 | time.sleep(0.82019) 1580 | 1581 | # 等待清理按钮可用 1582 | while True: 1583 | try: 1584 | confirm_btn = self.driver.execute_script( 1585 | """return document.querySelector("body > settings-ui").shadowRoot.querySelector("#main").shadowRoot.querySelector("settings-basic-page").shadowRoot.querySelector("#basicPage > settings-section > settings-privacy-page").shadowRoot.querySelector("settings-clear-browsing-data-dialog").shadowRoot.querySelector("#clearBrowsingDataConfirm");""") 1586 | time.sleep(0.62) 1587 | 1588 | break 1589 | except Exception as e: 1590 | pass 1591 | 1592 | # 确认清除 1593 | self.driver.execute_script("arguments[0].click();", confirm_btn) 1594 | 1595 | WebDriverWait(self.driver, timeout=19.17, poll_frequency=1.124).until(EC.url_contains('settings/privacy'), 1596 | '等待清理完成跳转画面超时') 1597 | 1598 | logger.success('浏览器数据清理完成') 1599 | except Exception as e: 1600 | raise Exception(f'清理浏览器数据出错:{str(e)}') 1601 | 1602 | @catch_exception 1603 | def run(self): 1604 | logger.info('当前程序版本为 ' + __version__) 1605 | logger.info('开始监听密码被改邮件') 1606 | 1607 | # 监听密码被改邮件 1608 | last_protection_time = time.time() 1609 | while True: 1610 | real_today = Netflix.today_() 1611 | if self.today != real_today: 1612 | self.today = real_today 1613 | self.__logger_setting() 1614 | 1615 | self.redis = redis.Redis(host=self.REDIS_HOST, port=self.REDIS_PORT, db=0, decode_responses=True) 1616 | self.redis.set_response_callback('GET', int) 1617 | 1618 | with ThreadPoolExecutor(max_workers=self.max_workers) as executor: 1619 | all_tasks = { 1620 | executor.submit(self.pwd_result_mail_listener, item.get('u')): (item.get('u'), item.get('p')) for 1621 | item in 1622 | self.MULTIPLE_NETFLIX_ACCOUNTS} 1623 | 1624 | for future in as_completed(all_tasks): 1625 | try: 1626 | u, p = all_tasks[future] 1627 | 1628 | result = future.result() 1629 | if not result: 1630 | continue 1631 | 1632 | data, event_type = result 1633 | event_reason = Netflix.get_event_reason(event_type) 1634 | start_time = time.time() 1635 | 1636 | self.open_new_tab() 1637 | 1638 | self.close_other_tabs() 1639 | 1640 | self.clear_browser_data() 1641 | 1642 | num = 1 1643 | while True: 1644 | try: 1645 | if event_type == 1: # 用户恶意修改密码 1646 | self.__do_reset(u, p) # 要么返回 True,要么抛异常 1647 | 1648 | logger.success('成功恢复原始密码') 1649 | Netflix.send_mail( 1650 | f'在 {Netflix.format_time(start_time)} 发现有人修改了 Netflix 账户 {u} 的密码,我已自动将密码恢复为初始状态', 1651 | [ 1652 | f'程式在 {self.now()} 已将密码恢复为初始状态,共耗时{Netflix.time_diff(start_time, time.time())},本次自动处理成功。']) 1653 | 1654 | self.set_need_to_do(u, 0) 1655 | 1656 | break 1657 | elif event_type == 2: # Netflix 强迫用户修改密码 1658 | # 重置为随机密码 1659 | logger.info('尝试先修改为随机密码') 1660 | random_pwd = Netflix.gen_random_pwd(8) 1661 | self.__do_reset(u, random_pwd) 1662 | 1663 | # 账户内自动修改为原始密码 1664 | self.__reset_password(random_pwd, p) 1665 | 1666 | self.set_need_to_do(u, 0) 1667 | 1668 | logger.success('成功从随机密码改回原始密码') 1669 | Netflix.send_mail( 1670 | f'在 {Netflix.format_time(start_time)} 发现 Netflix 强迫您修改账户 {u} 的密码,我已自动将密码恢复为初始状态', 1671 | [ 1672 | f'程式在 {self.now()} 已将密码恢复为初始状态,共耗时{Netflix.time_diff(start_time, time.time())},本次自动处理成功。']) 1673 | 1674 | break 1675 | except Exception as e: 1676 | logger.warning( 1677 | f'在执行密码恢复操作过程中出错:{str(e)},将重试,最多不超过 {self.max_num_of_attempts} 次 [{num}/{self.max_num_of_attempts}]') 1678 | self.error_page_screenshot() 1679 | finally: 1680 | # 超过最大尝试次数 1681 | if num >= self.max_num_of_attempts: 1682 | logger.error('重试失败次数过多,已放弃本次恢复密码动作,将继续监听新的密码事件') 1683 | self.set_need_to_do(u, 1) # 恢复检测 1684 | 1685 | Netflix.send_mail(f'主人,抱歉没能恢复 {u} 的密码,请尝试手动恢复', [ 1686 | f'今次触发恢复密码的动作的原因为:{event_reason}。
发现时间:{Netflix.format_time(start_time)}

程式一共尝试了 {num} 次恢复密码,均以失败告终。我已将今天的日志以及这次出错画面的截图作为附件发送给您,请查收。'], 1687 | files=[f'logs/{Netflix.now("%Y-%m-%d")}.log', 1688 | self.error_page_screenshot()]) 1689 | 1690 | break 1691 | 1692 | num += 1 1693 | except Exception as e: 1694 | logger.error('出错:{}', str(e)) 1695 | time.sleep(3) 1696 | 1697 | # 保护账户免受篡改与锁定 1698 | if int(os.getenv('ENABLE_ACCOUNT_PROTECTION', 0)): 1699 | now = time.time() 1700 | if now - last_protection_time >= 124: 1701 | last_protection_time = now 1702 | self.protect_account() 1703 | 1704 | logger.debug('开始下一轮监听') 1705 | 1706 | 1707 | if __name__ == '__main__': 1708 | Netflix = Netflix() 1709 | Netflix.run() 1710 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | selenium==3.141.0 2 | python-dotenv==0.18.0 3 | loguru==0.5.3 4 | redis==3.5.3 5 | urllib3>=1.26.15,<2 -------------------------------------------------------------------------------- /resources/Netflix_Logo_RGB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luolongfei/netflix/e5fb7408cdeb4d6bbb83bf0c85032fd72d1509b3/resources/Netflix_Logo_RGB.png -------------------------------------------------------------------------------- /run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | cd /d %~dp0 3 | python netflix.py -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luolongfei/netflix/e5fb7408cdeb4d6bbb83bf0c85032fd72d1509b3/utils/__init__.py -------------------------------------------------------------------------------- /utils/version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | @author luolongf 6 | @date 2022/1/7 7 | @time 10:02 8 | """ 9 | 10 | __version__ = 'v0.7.3' 11 | -------------------------------------------------------------------------------- /wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # Check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | 148 | WAITFORIT_BUSYTIMEFLAG="" 149 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 150 | WAITFORIT_ISBUSY=1 151 | # Check if busybox timeout uses -t flag 152 | # (recent Alpine versions don't support -t anymore) 153 | if timeout &>/dev/stdout | grep -q -e '-t '; then 154 | WAITFORIT_BUSYTIMEFLAG="-t" 155 | fi 156 | else 157 | WAITFORIT_ISBUSY=0 158 | fi 159 | 160 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 161 | wait_for 162 | WAITFORIT_RESULT=$? 163 | exit $WAITFORIT_RESULT 164 | else 165 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 166 | wait_for_wrapper 167 | WAITFORIT_RESULT=$? 168 | else 169 | wait_for 170 | WAITFORIT_RESULT=$? 171 | fi 172 | fi 173 | 174 | if [[ $WAITFORIT_CLI != "" ]]; then 175 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 176 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 177 | exit $WAITFORIT_RESULT 178 | fi 179 | exec "${WAITFORIT_CLI[@]}" 180 | else 181 | exit $WAITFORIT_RESULT 182 | fi 183 | --------------------------------------------------------------------------------