From 2a4ce563b0efcceaca3638fb404f60df08f27f09 Mon Sep 17 00:00:00 2001 From: yuming Date: Mon, 27 Apr 2026 09:22:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B9=E7=94=A8=20Playwright=20Headle?= =?UTF-8?q?ss=20Chromium=20=E5=AE=9E=E7=8E=B0=E6=89=AB=E7=A0=81=E7=99=BB?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sso.douyin.com 有 JS 反爬,纯 HTTP 无法通过。 改用 Microsoft.Playwright 驱动 Headless Chromium 打开 creator.douyin.com 登录页,直接提取 QR 码 base64 和登录后 Cookie。 Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile.prod | 49 ++++++---- dy.net.csproj | 1 + service/DyQrCodeService.cs | 183 ++++++++++++++++++------------------- 3 files changed, 122 insertions(+), 111 deletions(-) diff --git a/Dockerfile.prod b/Dockerfile.prod index 7d17063..f0c93f6 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -1,14 +1,11 @@ # 多阶段构建 Dockerfile # 第一阶段:构建前端 -# 使用官方 Docker Hub(mcr 能访问的环境一般也能访问 docker.io) FROM node:18-alpine AS frontend-builder WORKDIR /app/frontend -# 复制前端依赖文件 COPY app/package.json app/pnpm-lock.yaml* ./ -# 配置 npm 和 pnpm 使用国内镜像源 RUN npm config set registry https://registry.npmmirror.com && \ npm install -g pnpm && \ pnpm config set registry https://registry.npmmirror.com && \ @@ -18,33 +15,30 @@ RUN npm config set registry https://registry.npmmirror.com && \ npm install; \ fi -# 复制前端源码 COPY app/ ./ - -# 构建前端 RUN pnpm build || npm run build -# 第二阶段:构建后端 +# 第二阶段:构建后端 + 下载 Playwright Chromium FROM mcr.microsoft.com/dotnet/sdk:6.0 AS backend-builder WORKDIR /src -# 复制项目文件 COPY *.csproj ./ RUN dotnet restore dy.net.csproj -# 复制所有文件 COPY . . - -# 删除 macOS 资源分叉文件(._* 文件) RUN find . -name "._*" -type f -delete -# 复制前端构建产物 COPY --from=frontend-builder /app/frontend/dist ./app/dist -# 发布后端 RUN dotnet publish dy.net.csproj -c Release -o /app/publish +# 安装 Playwright CLI 并下载 Chromium 浏览器到固定路径 +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN dotnet tool install --global Microsoft.Playwright.CLI --version 1.44.0 2>/dev/null || true && \ + /root/.dotnet/tools/playwright install chromium --with-deps 2>/dev/null || \ + dotnet /app/publish/playwright.ps1 install chromium 2>/dev/null || true + # 第三阶段:运行镜像 FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS final @@ -54,16 +48,33 @@ WORKDIR /app ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -# 从构建阶段复制发布文件 +# 安装 Chromium 运行时依赖(Debian Bullseye) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnss3 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libgbm1 \ + libasound2 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgtk-3-0 \ + fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + +# 从构建阶段复制应用和浏览器 COPY --from=backend-builder /app/publish . +COPY --from=backend-builder /ms-playwright /ms-playwright -# 暴露端口 +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright EXPOSE 10101 - -# 设置环境变量 ENV ASPNETCORE_URLS=http://*:10101 ENV ASPNETCORE_ENVIRONMENT=Production -# 启动应用 ENTRYPOINT ["dotnet", "dy.net.dll"] - diff --git a/dy.net.csproj b/dy.net.csproj index 7f08cba..6c65077 100644 --- a/dy.net.csproj +++ b/dy.net.csproj @@ -57,6 +57,7 @@ + diff --git a/service/DyQrCodeService.cs b/service/DyQrCodeService.cs index 030fabf..57480e8 100644 --- a/service/DyQrCodeService.cs +++ b/service/DyQrCodeService.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; -using System.Net; -using Newtonsoft.Json.Linq; +using Microsoft.Playwright; namespace dy.net.service { @@ -10,83 +9,100 @@ namespace dy.net.service private class QrSession { - public CookieContainer CookieJar { get; set; } = new(); + public IPlaywright Playwright { get; set; } + public IBrowser Browser { get; set; } + public IBrowserContext Context { get; set; } + public IPage Page { get; set; } public DateTime CreatedAt { get; set; } = DateTime.Now; - public string? FinalCookies { get; set; } - public string? SecUserId { get; set; } + public string FinalCookies { get; set; } + public string SecUserId { get; set; } } - private const string Fp = "kj5j6uhv_tvKYUFA0_qzgZ_4l9c_9Amt_DCywEfwbVFCJ"; - private const string NextUrl = "https://creator.douyin.com/content/manage"; - private const string Service = "https://creator.douyin.com"; - private const string Aid = "2906"; - private const string Ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"; - private const string Referer = "https://creator.douyin.com/"; + private const string LoginUrl = "https://creator.douyin.com/creator-micro/login"; + // QR 码图片的 class 名含 qrcode_img(后缀随版本变化,用属性选择器匹配) + private const string QrSelector = "img[class*='qrcode_img']"; public async Task<(string token, string qrBase64)> GetQrCodeAsync() { - var jar = new CookieContainer(); - using var client = BuildClient(jar, autoRedirect: true); - - var url = $"https://sso.douyin.com/get_qrcode/?next={Uri.EscapeDataString(NextUrl)}&aid={Aid}&service={Uri.EscapeDataString(Service)}&is_vcd=1&fp={Fp}"; - var response = await client.GetStringAsync(url); - var json = JObject.Parse(response); - - var token = json["data"]?["token"]?.ToString() - ?? throw new Exception("获取二维码失败:响应中无 token"); - var qrBase64 = json["data"]?["qrcode"]?.ToString() - ?? throw new Exception("获取二维码失败:响应中无图片"); - - _sessions[token] = new QrSession { CookieJar = jar }; CleanExpiredSessions(); + var playwright = await Playwright.CreateAsync(); + var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true, + Args = new[] { "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage" } + }); + var context = await browser.NewContextAsync(new BrowserNewContextOptions + { + UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" + }); + var page = await context.NewPageAsync(); + + await page.GotoAsync(LoginUrl, new PageGotoOptions { Timeout = 15000 }); + await page.WaitForSelectorAsync(QrSelector, new PageWaitForSelectorOptions { Timeout = 10000 }); + + // img.src 已经是 data:image/png;base64,... 直接取用 + var src = await page.EvaluateAsync($"() => document.querySelector(\"{QrSelector}\").src"); + var qrBase64 = src.Replace("data:image/png;base64,", ""); + + var token = Guid.NewGuid().ToString("N"); + _sessions[token] = new QrSession + { + Playwright = playwright, + Browser = browser, + Context = context, + Page = page + }; + return (token, qrBase64); } - public async Task<(int status, string? cookies, string? secUserId)> CheckStatusAsync(string token) + public async Task<(int status, string cookies, string secUserId)> CheckStatusAsync(string token) { if (!_sessions.TryGetValue(token, out var session)) return (-1, null, null); - if (DateTime.Now - session.CreatedAt > TimeSpan.FromMinutes(10)) + if (DateTime.Now - session.CreatedAt > TimeSpan.FromMinutes(5)) { - _sessions.TryRemove(token, out _); + await CloseSessionAsync(token, session); return (-1, null, null); } + // 已经登录过,直接返回缓存 if (session.FinalCookies != null) return (3, session.FinalCookies, session.SecUserId); - using var client = BuildClient(session.CookieJar, autoRedirect: false); + var url = session.Page.Url; - var serviceParam = Uri.EscapeDataString($"{Service}/?logintype=user&loginapp=douyin&jump={NextUrl}"); - var url = $"https://sso.douyin.com/check_qrconnect/?next={Uri.EscapeDataString(NextUrl)}&token={token}&service={serviceParam}&loginapp=douyin&jump={Uri.EscapeDataString(NextUrl)}&correct_service={serviceParam}&aid={Aid}&is_vcd=1&fp={Fp}"; - var response = await client.GetStringAsync(url); - var json = JObject.Parse(response); - - var statusStr = json["data"]?["status"]?.ToString() ?? "1"; - int.TryParse(statusStr, out int statusCode); - - if (statusCode == 3) + // 还在登录页 + if (url.Contains("/creator-micro/login") || url.Contains("sso.douyin.com")) { - var redirectUrl = json["data"]?["redirect_url"]?.ToString(); - if (!string.IsNullOrEmpty(redirectUrl)) - { - try - { - using var followClient = BuildClient(session.CookieJar, autoRedirect: true); - await followClient.GetAsync(redirectUrl); - session.FinalCookies = ExtractCookieString(session.CookieJar); - session.SecUserId = await GetSecUserIdAsync(session.CookieJar); - } - catch (Exception ex) - { - Serilog.Log.Error($"跟进登录重定向失败: {ex.Message}"); - } - } + // 判断是否已扫码(页面出现"确认"或"已扫码"文字) + var scanned = await session.Page.EvaluateAsync( + "() => { const t = document.body.innerText; return t.includes('已扫码') || t.includes('确认登录'); }" + ); + return (scanned ? 2 : 1, null, null); } - return (statusCode, session.FinalCookies, session.SecUserId); + // URL 已离开登录页,说明登录成功 + var browserCookies = await session.Context.CookiesAsync(new[] + { + "https://creator.douyin.com", + "https://www.douyin.com", + "https://sso.douyin.com" + }); + var cookieStr = string.Join("; ", browserCookies.Select(c => $"{c.Name}={c.Value}")); + + // 尝试从页面获取 sec_user_id + var secUserId = await GetSecUserIdAsync(session.Page); + + session.FinalCookies = cookieStr; + session.SecUserId = secUserId; + + // 登录完成后关闭浏览器释放资源 + _ = Task.Run(async () => await CloseSessionAsync(token, session)); + + return (3, cookieStr, secUserId); } public (string cookies, string secUserId) GetSessionResult(string token) @@ -96,59 +112,42 @@ namespace dy.net.service return ("", ""); } - public void RemoveSession(string token) => _sessions.TryRemove(token, out _); - - private static HttpClient BuildClient(CookieContainer jar, bool autoRedirect) + public void RemoveSession(string token) { - var handler = new HttpClientHandler - { - CookieContainer = jar, - AllowAutoRedirect = autoRedirect, - UseCookies = true - }; - var client = new HttpClient(handler); - client.DefaultRequestHeaders.Add("User-Agent", Ua); - client.DefaultRequestHeaders.Add("Referer", Referer); - return client; + if (_sessions.TryRemove(token, out var session)) + _ = Task.Run(async () => await CloseSessionAsync(token, session)); } - private static string ExtractCookieString(CookieContainer jar) - { - var domains = new[] { "https://www.douyin.com", "https://creator.douyin.com", "https://sso.douyin.com" }; - var all = new Dictionary(); - foreach (var domain in domains) - { - foreach (Cookie c in jar.GetCookies(new Uri(domain))) - all[c.Name] = c.Value; - } - return string.Join("; ", all.Select(kv => $"{kv.Key}={kv.Value}")); - } - - private static async Task GetSecUserIdAsync(CookieContainer jar) + private static async Task GetSecUserIdAsync(IPage page) { try { - using var client = BuildClient(jar, autoRedirect: true); - var resp = await client.GetStringAsync("https://creator.douyin.com/web/api/userinfo/"); - var json = JObject.Parse(resp); - return json["data"]?["sec_uid"]?.ToString() - ?? json["data"]?["sec_user_id"]?.ToString() - ?? json["sec_uid"]?.ToString(); + // 从页面 URL 或 API 提取 sec_user_id + var resp = await page.EvaluateAsync( + "async () => { const r = await fetch('/web/api/userinfo/'); const j = await r.json(); return j?.data?.sec_uid || j?.sec_uid || ''; }" + ); + return resp ?? ""; } - catch (Exception ex) + catch { - Serilog.Log.Error($"获取 sec_user_id 失败: {ex.Message}"); - return null; + return ""; } } + private static async Task CloseSessionAsync(string token, QrSession session) + { + _sessions.TryRemove(token, out _); + try { await session.Browser?.CloseAsync(); } catch { } + try { session.Playwright?.Dispose(); } catch { } + } + private static void CleanExpiredSessions() { var expired = _sessions - .Where(kv => DateTime.Now - kv.Value.CreatedAt > TimeSpan.FromMinutes(10)) - .Select(kv => kv.Key).ToList(); - foreach (var key in expired) - _sessions.TryRemove(key, out _); + .Where(kv => DateTime.Now - kv.Value.CreatedAt > TimeSpan.FromMinutes(6)) + .ToList(); + foreach (var kv in expired) + _ = Task.Run(async () => await CloseSessionAsync(kv.Key, kv.Value)); } } }