|
|
import hashlib |
|
|
import sys |
|
|
from base64 import urlsafe_b64encode |
|
|
from secrets import token_urlsafe |
|
|
from typing import Any, Callable, Optional, TypeVar |
|
|
from urllib.parse import parse_qs, urlencode |
|
|
|
|
|
import requests |
|
|
from loguru import logger as _logger |
|
|
from PyQt6.QtCore import QUrl |
|
|
from PyQt6.QtNetwork import QNetworkCookie |
|
|
from PyQt6.QtWebEngineCore import ( |
|
|
QWebEngineUrlRequestInfo, |
|
|
QWebEngineUrlRequestInterceptor, |
|
|
) |
|
|
from PyQt6.QtWebEngineWidgets import QWebEngineView |
|
|
from PyQt6.QtWidgets import ( |
|
|
QApplication, |
|
|
QHBoxLayout, |
|
|
QMainWindow, |
|
|
QPlainTextEdit, |
|
|
QPushButton, |
|
|
QVBoxLayout, |
|
|
QWidget, |
|
|
) |
|
|
|
|
|
USER_AGENT = "PixivAndroidApp/5.0.234 (Android 11; Pixel 5)" |
|
|
REDIRECT_URI = "https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback" |
|
|
LOGIN_URL = "https://app-api.pixiv.net/web/v1/login" |
|
|
AUTH_TOKEN_URL = "https://oauth.secure.pixiv.net/auth/token" |
|
|
CLIENT_ID = "MOBrBDS8blbauoSck0ZfDbtuzpyT" |
|
|
CLIENT_SECRET = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj" |
|
|
|
|
|
|
|
|
app = QApplication(sys.argv) |
|
|
logger = _logger.opt(colors=True) |
|
|
|
|
|
|
|
|
class RequestInterceptor(QWebEngineUrlRequestInterceptor): |
|
|
code_listener: Optional[Callable[[str], None]] = None |
|
|
|
|
|
def __init__(self): |
|
|
super().__init__() |
|
|
|
|
|
def interceptRequest(self, info: QWebEngineUrlRequestInfo) -> None: |
|
|
method = info.requestMethod().data().decode() |
|
|
url = info.requestUrl().url() |
|
|
|
|
|
if ( |
|
|
self.code_listener |
|
|
and "app-api.pixiv.net" in info.requestUrl().host() |
|
|
and info.requestUrl().path().endswith("callback") |
|
|
): |
|
|
query = parse_qs(info.requestUrl().query()) |
|
|
code, *_ = query["code"] |
|
|
self.code_listener(code) |
|
|
|
|
|
logger.debug(f"<y>{method}</y> <u>{url}</u>") |
|
|
|
|
|
|
|
|
class WebView(QWebEngineView): |
|
|
def __init__(self): |
|
|
super().__init__() |
|
|
|
|
|
self.cookies: dict[str, str] = {} |
|
|
|
|
|
page = self.page() |
|
|
assert page is not None |
|
|
profile = page.profile() |
|
|
assert profile is not None |
|
|
profile.setHttpUserAgent(USER_AGENT) |
|
|
page.contentsSize().setHeight(768) |
|
|
page.contentsSize().setWidth(432) |
|
|
|
|
|
self.interceptor = RequestInterceptor() |
|
|
profile.setUrlRequestInterceptor(self.interceptor) |
|
|
cookie_store = profile.cookieStore() |
|
|
assert cookie_store is not None |
|
|
cookie_store.cookieAdded.connect(self._on_cookie_added) |
|
|
|
|
|
self.setFixedHeight(896) |
|
|
self.setFixedWidth(414) |
|
|
|
|
|
self.start("about:blank") |
|
|
|
|
|
def start(self, goto: str): |
|
|
self.page().profile().cookieStore().deleteAllCookies() |
|
|
self.cookies.clear() |
|
|
self.load(QUrl(goto)) |
|
|
|
|
|
def _on_cookie_added(self, cookie: QNetworkCookie): |
|
|
domain = cookie.domain() |
|
|
name = cookie.name().data().decode() |
|
|
value = cookie.value().data().decode() |
|
|
self.cookies[name] = value |
|
|
logger.debug(f"<m>Set-Cookie</m> <r>{domain}</r> <g>{name}</g> -> {value!r}") |
|
|
|
|
|
|
|
|
class ResponseDataWidget(QWidget): |
|
|
def __init__(self, webview: WebView): |
|
|
super().__init__() |
|
|
self.webview = webview |
|
|
|
|
|
layout = QVBoxLayout() |
|
|
|
|
|
self.cookie_paste = QPlainTextEdit() |
|
|
self.cookie_paste.setDisabled(True) |
|
|
self.cookie_paste.setPlaceholderText("得到的登录数据将会展示在这里") |
|
|
|
|
|
layout.addWidget(self.cookie_paste) |
|
|
|
|
|
copy_button = QPushButton() |
|
|
copy_button.clicked.connect(self._on_clipboard_copy) |
|
|
copy_button.setText("复制上述登录数据到剪贴板") |
|
|
|
|
|
layout.addWidget(copy_button) |
|
|
|
|
|
self.setLayout(layout) |
|
|
|
|
|
def _on_clipboard_copy(self, checked: bool): |
|
|
if paste_string := self.cookie_paste.toPlainText().strip(): |
|
|
app.clipboard().setText(paste_string) |
|
|
|
|
|
|
|
|
_T = TypeVar("_T", bound="LoginPhrase") |
|
|
|
|
|
|
|
|
class LoginPhrase: |
|
|
@staticmethod |
|
|
def s256(data: bytes): |
|
|
return urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode() |
|
|
|
|
|
@classmethod |
|
|
def oauth_pkce(cls) -> tuple[str, str]: |
|
|
code_verifier = token_urlsafe(32) |
|
|
code_challenge = cls.s256(code_verifier.encode()) |
|
|
return code_verifier, code_challenge |
|
|
|
|
|
def __init__(self: _T, url_open_callback: Callable[[str, _T], None]): |
|
|
self.code_verifier, self.code_challenge = self.oauth_pkce() |
|
|
|
|
|
login_params = { |
|
|
"code_challenge": self.code_challenge, |
|
|
"code_challenge_method": "S256", |
|
|
"client": "pixiv-android", |
|
|
} |
|
|
login_url = f"{LOGIN_URL}?{urlencode(login_params)}" |
|
|
url_open_callback(login_url, self) |
|
|
|
|
|
def code_received(self, code: str): |
|
|
response = requests.post( |
|
|
AUTH_TOKEN_URL, |
|
|
data={ |
|
|
"client_id": CLIENT_ID, |
|
|
"client_secret": CLIENT_SECRET, |
|
|
"code": code, |
|
|
"code_verifier": self.code_verifier, |
|
|
"grant_type": "authorization_code", |
|
|
"include_policy": "true", |
|
|
"redirect_uri": REDIRECT_URI, |
|
|
}, |
|
|
headers={"User-Agent": USER_AGENT}, |
|
|
) |
|
|
response.raise_for_status() |
|
|
data: dict[str, Any] = response.json() |
|
|
|
|
|
access_token = data["access_token"] |
|
|
refresh_token = data["refresh_token"] |
|
|
expires_in = data.get("expires_in", 0) |
|
|
|
|
|
return_text = "" |
|
|
return_text += f"access_token: {access_token}\n" |
|
|
return_text += f"refresh_token: {refresh_token}\n" |
|
|
return_text += f"expires_in: {expires_in}\n" |
|
|
|
|
|
return return_text |
|
|
|
|
|
|
|
|
class MainWindow(QMainWindow): |
|
|
def __init__(self): |
|
|
super().__init__() |
|
|
self.setWindowTitle("Pixiv login helper") |
|
|
|
|
|
layout = QHBoxLayout() |
|
|
|
|
|
self.webview = WebView() |
|
|
layout.addWidget(self.webview) |
|
|
|
|
|
self.form = ResponseDataWidget(self.webview) |
|
|
layout.addWidget(self.form) |
|
|
|
|
|
widget = QWidget() |
|
|
widget.setLayout(layout) |
|
|
|
|
|
self.setCentralWidget(widget) |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
window = MainWindow() |
|
|
window.show() |
|
|
|
|
|
def url_open_callback(url: str, login_phrase: LoginPhrase): |
|
|
def code_listener(code: str): |
|
|
response = login_phrase.code_received(code) |
|
|
window.form.cookie_paste.setPlainText(response) |
|
|
|
|
|
window.webview.interceptor.code_listener = code_listener |
|
|
window.webview.start(url) |
|
|
|
|
|
LoginPhrase(url_open_callback) |
|
|
|
|
|
exit(app.exec()) |
|
|
|