1. 先搞清楚屏幕取词到底在取什么窗口句柄、控件文本与三条路径C# 屏幕取词例程源码这件事很多人第一次写都会卡在同一个地方鼠标底下明明有字GetWindowText却返回空字符串。这不是 API 写错了而是你取到的窗口和真正显示文字的控件不是同一个对象。屏幕取词的本质是三步链条先拿到鼠标坐标再用坐标换窗口句柄最后用句柄换文本。任何一步的对象错了结果就是空。我把这条链条拆成三条可落地的路径你可以按目标控件的类型选一条第一条是标准窗口路径。GetCursorPos拿屏幕坐标WindowFromPoint拿该坐标下最顶层的窗口句柄GetWindowText读标题栏或标准控件的文本。这条路对记事本、按钮、静态文本、编辑框都有效代码最短适合做桌面工具的悬浮提示。第二条是子控件路径。WindowFromPoint只返回最顶层窗口如果鼠标停在一个复杂界面的子控件上你拿到的是父窗口句柄文本自然读不到。这时要用ChildWindowFromPointEx或者EnumChildWindows往下钻找到真正承载文字的控件。自动化测试里定位控件、读取断言文本基本都走这条。第三条是消息与剪贴板回退路径。有些控件尤其是密码框、自绘控件、部分浏览器内核渲染区域不响应GetWindowText但对WM_GETTEXT消息有反应再不行就模拟CtrlC把选中内容送进剪贴板再读出来。剪贴板回退是最后的兜底手段代价是会污染用户剪贴板必须做好备份和恢复。这里要提前说清楚一个边界本文的例程只处理你自己有权限操作的窗口比如自己开发的桌面工具、自动化测试中被测程序的公开控件、以及系统允许读取的标准控件。密码框这类控件在正常系统行为下本来就不该被外部程序读出明文例程里我会演示如何识别它并主动跳过而不是绕过。这一点决定了你的工具是正经的辅助软件还是会被杀软盯上的东西。适合谁看正在写桌面悬浮翻译、取词查词、UI 自动化测试框架、无障碍辅助工具的 C# 开发者。你需要会基本的 WinForms 或 WPF 项目创建知道什么是 P/Invoke剩下的跟着代码走就行。整篇会交付可直接编译的声明、句柄遍历代码、验证步骤以及当本地规则取不到文本时如何通过统一的 Key/API 通道接入辅助识别能力把例程补完整。2. 动手前的前置准备项目骨架、P/Invoke 声明与统一 Key/API 通道先把工程搭起来别一上来就贴一堆DllImport。我建议新建一个 .NET 6 或 .NET 8 的 WinForms 项目目标框架选net8.0-windows这样Point、IntPtr、StringBuilder都是现成的不用自己再定义POINTAPI结构体。项目属性里把「允许不安全代码」勾上不是必须的本文的写法全部用托管封送不需要unsafe。第一步是把 P/Invoke 声明集中到一个静态类里命名NativeMethods。集中管理的好处是后面排查EntryPointNotFoundException时只需要看一个文件。核心声明如下字符集统一用CharSet.Auto和系统 API 的宽字符/窄字符自动匹配using System; using System.Runtime.InteropServices; using System.Text; internal static class NativeMethods { [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } [DllImport(user32.dll, SetLastError true)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetCursorPos(out POINT lpPoint); [DllImport(user32.dll, SetLastError true)] public static extern IntPtr WindowFromPoint(POINT point); [DllImport(user32.dll, SetLastError true)] public static extern IntPtr ChildWindowFromPointEx(IntPtr hWndParent, POINT pt, uint uFlags); [DllImport(user32.dll, SetLastError true, CharSet CharSet.Auto)] public static extern int GetWindowTextLength(IntPtr hWnd); [DllImport(user32.dll, SetLastError true, CharSet CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); [DllImport(user32.dll, SetLastError true, CharSet CharSet.Auto)] public static extern IntPtr SendMessage(IntPtr hWnd, uint msg, IntPtr wParam, StringBuilder lParam); [DllImport(user32.dll, SetLastError true)] public static extern IntPtr SendMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); [DllImport(user32.dll, SetLastError true)] public static extern bool EnumChildWindows(IntPtr hWndParent, EnumWindowsProc lpEnumFunc, IntPtr lParam); public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); [DllImport(user32.dll, SetLastError true)] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); [DllImport(user32.dll, SetLastError true)] public static extern bool IsWindowVisible(IntPtr hWnd); public const uint WM_GETTEXT 0x000D; public const uint WM_GETTEXTLENGTH 0x000E; public const uint CWP_SKIPINVISIBLE 0x0001; public const uint CWP_SKIPDISABLED 0x0002; }注意SendMessage我写了两个重载一个接收StringBuilder用于WM_GETTEXT一个接收IntPtr用于WM_GETTEXTLENGTH。混用会导致封送异常这是新手最常见的坑之一。第二步是准备统一 Key/API 通道。本地规则能覆盖标准控件但遇到自绘控件、图片里的文字、或者需要语义理解的场景就得靠外部识别能力。TaoToken 提供统一的 Key 和兼容 OpenAI 风格的接口把模型调用收敛到一个入口省得你在例程里维护多套 SDK。注册和拿 Key 走官网入口https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 登录后在控制台创建 API Key接口基地址用 https://taotoken.net/api 注意这个地址不带任何查询参数。拿到 Key 之后在项目里加一个appsettings.json把配置外置别硬编码{ TaoToken: { BaseUrl: https://taotoken.net/api, ApiKey: sk-你的Key, ModelId: gpt-4o-mini } }三个字段缺一不可Base URL 指向统一网关Key 做鉴权Model ID 决定用哪个模型。后面无论你用 Cline、Codex 还是自己写 HttpClient这三件套都是同一套。如果你的例程要长期跑自动化任务建议单独看一下 Coding Plan 的额度策略按量调用和包周期调用的成本差别不小入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。第三步是给项目加一个ScreenTextExtractor类把三条路径封装成方法。先写坐标和句柄获取这部分不依赖任何外部服务纯本地public static (NativeMethods.POINT point, IntPtr hWnd) GetCursorWindow() { if (!NativeMethods.GetCursorPos(out var pt)) throw new InvalidOperationException(GetCursorPos 失败错误码 Marshal.GetLastWin32Error()); var hWnd NativeMethods.WindowFromPoint(pt); return (pt, hWnd); }到这里骨架就有了。下一步才是真正容易出问题的地方怎么从hWnd稳定地拿到文本。3. 可复制配置句柄遍历、控件文本读取与 settings 片段这一节是整篇的核心我把三条路径的完整实现都写出来你可以直接复制进ScreenTextExtractor.cs。先讲标准读取再讲子控件遍历最后讲消息与剪贴板回退每一段都说明它解决什么问题。标准读取用GetWindowTextLength先探长度再分配StringBuilder。这里有个细节长度返回 0 不代表没有文本可能是控件不响应这个 API所以返回值要区分「空文本」和「读取失败」。我的做法是先判长度长度为 0 时直接走消息路径不要急着返回空串public static string GetTextByWindow(IntPtr hWnd) { if (hWnd IntPtr.Zero) return string.Empty; int len NativeMethods.GetWindowTextLength(hWnd); if (len 0) return string.Empty; var sb new StringBuilder(len 1); int copied NativeMethods.GetWindowText(hWnd, sb, sb.Capacity); return copied 0 ? sb.ToString() : string.Empty; }子控件遍历解决「鼠标停在父窗口上」的问题。WindowFromPoint返回的是最顶层窗口复杂界面里文字往往在深层子控件。用EnumChildWindows递归枚举对每个子控件做一次命中测试判断鼠标坐标是否落在它的矩形内。判断矩形需要GetWindowRect补一个声明[DllImport(user32.dll, SetLastError true)] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }遍历逻辑写成这样注意跳过不可见和禁用的控件否则会读到一堆隐藏的辅助对象public static IntPtr FindDeepestChildAtPoint(IntPtr hWndParent, NativeMethods.POINT pt) { IntPtr result IntPtr.Zero; NativeMethods.EnumChildWindows(hWndParent, (hWnd, _) { if (!NativeMethods.IsWindowVisible(hWnd)) return true; if (!NativeMethods.GetWindowRect(hWnd, out var rect)) return true; bool hit pt.X rect.Left pt.X rect.Right pt.Y rect.Top pt.Y rect.Bottom; if (hit) result hWnd; return true; }, IntPtr.Zero); return result; }消息路径用WM_GETTEXT它对一部分不响应GetWindowText的控件有效。先发WM_GETTEXTLENGTH拿长度再分配缓冲区发WM_GETTEXT。这里必须用SendMessage的StringBuilder重载wParam传缓冲区容量lParam传StringBuilderpublic static string GetTextByMessage(IntPtr hWnd) { if (hWnd IntPtr.Zero) return string.Empty; var lenResult NativeMethods.SendMessage( hWnd, NativeMethods.WM_GETTEXTLENGTH, IntPtr.Zero, IntPtr.Zero); int len lenResult.ToInt32(); if (len 0) return string.Empty; var sb new StringBuilder(len 1); NativeMethods.SendMessage( hWnd, NativeMethods.WM_GETTEXT, (IntPtr)sb.Capacity, sb); return sb.ToString(); }剪贴板回退是兜底。思路是备份当前剪贴板内容向目标窗口发送CtrlC的按键消息等待一小段时间后读剪贴板最后恢复原内容。按键消息用keybd_event或SendInput这里用更简单的keybd_event声明[DllImport(user32.dll)] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); public const byte VK_CONTROL 0x11; public const byte VK_C 0x43; public const uint KEYEVENTF_KEYUP 0x0002;回退方法要加超时和异常保护剪贴板被其他进程占用时Clipboard.GetText会抛异常必须 try/catch 并重试。这段代码我建议只在明确知道目标控件支持复制时调用不要无脑对所有控件发CtrlC否则会干扰用户操作。配置片段方面如果你用appsettings.json管理模型参数读取时用IConfiguration把 Base URL、Key、Model ID 一次性注入到HttpClient的封装类里。下面这个settings片段可以直接放进项目根目录路径和字段名与前面保持一致{ TaoToken: { BaseUrl: https://taotoken.net/api, ApiKey: sk-你的Key, ModelId: gpt-4o-mini, TimeoutSeconds: 30 } }如果你更习惯用环境变量把ApiKey放到TAOTOKEN_API_KEY里代码里用Environment.GetEnvironmentVariable读取避免 Key 进版本库。这一点在团队协作里特别重要我见过太多人把 Key 提交到 Git 然后被迫轮换。4. 验证请求与成功结果从鼠标坐标到文本输出的完整跑通代码写完必须验证不然你不知道拿到的文本是标题栏还是控件内容。我设计一个最小验证流程一个 WinForms 窗体一个定时器每 200 毫秒取一次鼠标下的文本一个多行文本框显示结果再加一个按钮切换「标准读取」和「深度遍历」两种模式。先看主流程代码把三条路径串起来按优先级依次尝试public static string ExtractTextAtCursor(out string debugInfo) { var (pt, hWnd) GetCursorWindow(); debugInfo $坐标({pt.X},{pt.Y}) 顶层句柄0x{hWnd.ToInt64():X}; if (hWnd IntPtr.Zero) return string.Empty; // 路径一标准窗口文本 string text GetTextByWindow(hWnd); if (!string.IsNullOrWhiteSpace(text)) return text; // 路径二向下钻取子控件 var child FindDeepestChildAtPoint(hWnd, pt); if (child ! IntPtr.Zero child ! hWnd) { debugInfo $ 子控件0x{child.ToInt64():X}; text GetTextByWindow(child); if (!string.IsNullOrWhiteSpace(text)) return text; text GetTextByMessage(child); if (!string.IsNullOrWhiteSpace(text)) return text; } // 路径三对顶层窗口再试一次消息 text GetTextByMessage(hWnd); return text ?? string.Empty; }窗体里这样调用定时器回调里更新文本框同时把debugInfo显示在状态栏方便你判断命中了哪条路径private void timer1_Tick(object sender, EventArgs e) { try { string text ScreenTextExtractor.ExtractTextAtCursor(out string debug); txtResult.Text text; lblDebug.Text debug; } catch (Exception ex) { lblDebug.Text 异常 ex.Message; } }验证步骤我建议按这个顺序走每一步都有明确的预期结果第一步打开记事本输入一段文字鼠标悬停在编辑区。预期文本框显示你输入的内容状态栏显示顶层句柄和子控件句柄两个值。如果只显示标题「无标题 - 记事本」说明命中了顶层窗口而不是编辑区检查FindDeepestChildAtPoint的矩形判断。第二步鼠标悬停在记事本的标题栏。预期显示「无标题 - 记事本」。这一步验证标准路径。第三步打开一个带按钮的对话框比如系统自带的「运行」窗口鼠标悬停在按钮上。预期显示按钮文字。如果为空说明按钮不响应GetWindowText看状态栏是否走了消息路径。第四步打开浏览器鼠标悬停在网页正文上。预期大概率读不到因为浏览器内核自绘句柄层级里没有承载文字的控件。这一步不是失败而是告诉你该走外部识别通道了。外部识别通道的验证单独做。用HttpClient向统一网关发一个最小的对话请求确认 Key、Base URL、Model ID 三件套配置正确。请求体用 OpenAI 兼容格式using var client new HttpClient(); client.DefaultRequestHeaders.Authorization new AuthenticationHeaderValue(Bearer, apiKey); var payload new { model modelId, messages new[] { new { role user, content 把下面这段屏幕取词结果整理成一句话Hello Wor } } }; var resp await client.PostAsJsonAsync( https://taotoken.net/api/v1/chat/completions, payload); string body await resp.Content.ReadAsStringAsync();成功时返回 JSON 里choices[0].message.content有内容HTTP 状态码 200。如果返回 401是 Key 无效或没带Bearer前缀如果返回 404检查 Base URL 有没有多写或少写/v1。这一步跑通说明你的例程具备了「本地取词 外部补全」的完整能力。想单独验证模型对话是否正常可以用模型对话入口快速试一条https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。5. 本篇常见错排查401、local proxy failed、reading choices 与 OAuth 报错对照例程跑不起来九成是下面这几类错误。我把真实报错原文和对应原因列出来你对着日志查就行。第一类System.ComponentModel.Win32Exception: 拒绝访问或GetWindowText返回 0 且GetLastError是 5。原因是目标窗口属于更高权限的进程你的程序是普通用户权限跨权限读窗口文本会被系统拦。解决办法是以相同权限运行或者干脆跳过这类窗口。这不是代码 bug是系统的安全边界例程里应该用GetWindowThreadProcessId拿到进程 ID 后做白名单过滤。第二类HTTP 401。报错原文通常是{error:{message:Invalid API key,type:invalid_request_error}}。原因有三个Key 复制时带了空格、请求头没写Authorization: Bearer sk-xxx、或者 Key 已经被删除。排查顺序是先打印请求头注意别把完整 Key 打进日志再确认 Key 在控制台是否有效。API Keys 管理入口https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。第三类local proxy failed或连接超时。这个报错一般出现在你本地配了代理软件、但代理进程没启动或者端口不对的时候。例程里如果用了HttpClient并读取了系统代理就会撞上。处理方式是显式设置HttpClientHandler.UseProxy false或者把BaseUrl指向统一网关后确认网络可达。注意不要在任何配置里写代理地址统一走网关即可。第四类reading choices或Cannot read properties of undefined (reading choices)。这是解析响应时字段路径错了。统一网关返回的是标准 OpenAI 结构choices在根节点下不是data.choices。如果你用的是某些 SDK 的流式接口还要判断delta而不是message。排查方法先把原始响应体完整打印出来肉眼确认结构再写解析代码。第五类OAuth 相关报错比如OAuth token expired或invalid_grant。这类错误出现在你用 OAuth 方式登录某些编码工具比如 Claude Code 的账号体系时。如果你在例程里通过 CLI 工具间接调用模型先确认 CLI 自身的登录态是否有效再检查它读取的配置文件路径。以 Claude Code 为例配置在用户目录下的 settings 文件里Base URL、Key、Model ID 三项要和本文前面给的一致任何一项缺失都会导致鉴权失败。接入文档里有各工具的配置样例https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。第六类EntryPointNotFoundException。原因是DllImport的函数名或 DLL 名写错比如把user32.dll写成User32大小写不敏感但拼错就找不到或者SendMessage用了不存在的重载。对照本文第 2 节的声明逐个核对特别注意CharSet和SetLastError参数。第七类读到的文本是乱码。原因是字符集不匹配。GetWindowText用CharSet.Auto时在 Unicode 系统上走GetWindowTextWStringBuilder按 UTF-16 封送正常不会乱码。乱码通常出现在你自己用byte[]接收WM_GETTEXT结果时需要按Encoding.Unicode解码。本文的写法用StringBuilder已经规避了这个问题如果你改成了IntPtr缓冲区记得手动解码。排查时养成一个习惯每个 P/Invoke 调用后检查Marshal.GetLastWin32Error()把错误码打进日志。GetCursorPos失败返回 falseWindowFromPoint失败返回IntPtr.Zero这些返回值都要判不要假设一定成功。我踩过的坑是定时器频率太高导致SendMessage阻塞 UI 线程后来把取词逻辑放到后台线程用Invoke回主线程更新界面卡顿就消失了。6. 把例程接上统一通道长期编码与自动化的落地建议例程骨架跑通之后下一步是决定它怎么长期运行。如果你只是做一个小工具本地三条路径足够如果你要做自动化测试框架或者长期驻留的辅助工具就得考虑模型调用的稳定性和成本。我的建议是把「本地取词」和「外部识别」做成可插拔的两层。本地层负责标准控件零成本、毫秒级返回外部层只在本地返回空或者需要语义理解时触发比如把取到的碎片文本交给模型做纠错、翻译、结构化。两层之间用一个接口隔开方便你替换实现public interface ITextRecognizer { Taskstring RecognizeAsync(string rawText, CancellationToken ct); }外部层的实现里HttpClient建议做成单例复用连接避免每次请求都新建导致端口耗尽。超时设 30 秒失败重试两次仍然失败就降级返回原始文本不要让整个取词流程卡死。模型选择上短文本纠错用轻量模型就够别一上来就上大模型成本和延迟都不划算。如果你的例程要集成到编码工具链里比如让 AI 帮你读屏幕上的报错再给修复建议那配置方式和本文第 3 节的 settings 片段完全一致Base URL、Key、Model ID 三件套照抄即可。Cline 这类工具在 MCP 配置里填的就是这三项Codex 的auth.json里也是同样的字段。配置好之后工具侧的调用和你在例程里手写的HttpClient请求走的是同一个网关行为一致排查也方便。长期跑自动化任务的话按量计费在低频场景更灵活高频场景包周期更省。Coding Plan 的入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 具体额度以控制台显示为准我不在这里编造价格。控制台里可以看每次调用的 token 消耗用来估算你的例程一天跑下来大概什么量级https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。最后给一个实用技巧把取词结果和debugInfo一起落盘成日志按天切分。出问题时你能回看当时命中的是哪个句柄、走了哪条路径、外部调用返回了什么。这个日志在排查「为什么昨天能读今天读不到」这类问题时特别有用因为窗口层级可能随软件版本变化。例程的价值不在于代码多复杂而在于每条路径都有明确的失败信号你能顺着信号找到原因。