闪星空间 - JavaScript https://shansing.com/tag/JavaScript/ zh-CN Thu, 27 Jun 2024 16:50:20 +0000 Thu, 27 Jun 2024 16:50:20 +0000 ChatGPT Next Web 计费魔改小记 https://shansing.com/read/542/ https://shansing.com/read/542/ Thu, 27 Jun 2024 16:50:20 +0000 闪闪的星 眼看 GPT-4 系列正式开放(general available),OpenAI 还为流式(stream)输出增加 token 统计字段,我决定更新我的 GPT 网页。我一直用 chatgpt-web,自己修改代码加上的按量计费、版本切换(按量计费的 token 数还是估计的,对 GPT-4o 会高估),这时想再支持图片上传。想了几个方案,不是很妥:前端交互复杂,后端还依赖于 chatgpt-api 底层库。换一套“皮”是更好的选择。搜索 GitHub,就 ChatGPT Next Web 吧。

本来我没打算大改 ChatGPT Next Web。每个聊天对话能独立切换模型,它有了。我想要加上的图片输入,也有。我只需要加上计费。然而最终我还是动了很多地方,可以说是“魔改”吧,包括功能和外观的。我 fork 一份,提交了一个 bug 修复到上游,至今没有被合并。既然如此,我也没有后续提交的兴致。索性在这里稍微记录一下。

总的来说,我希望尽可能沿用之前我改的那套逻辑。即全站开启 HTTP Basic Auth,用于用户登录认证。自行定义 SHANSING_MODEL_CHOICES 用来配置模型单价等信息。不推荐直接使用我的项目,但如果要用请注意这些点。

ChatGPT Next Web 原生支持多种大模型,包括 OpenAI 的 GPT、Anthropic 的 Claude、Google 的 Gemini。在搭建过程中,我几乎都用过,所以下面我会顺便提到 GPT 以外的这些大模型。

为了使用 HTTP Basic Auth,需要修改 ./app/api/auth.ts 导出的 auth 函数,注释掉大部分用不到的逻辑,防止 Authorization header 冲突。

按量计费

首先当然是补上按量计费功能。如今,GPT、Claude、Gemini,官方都支持返回 token 用量,无论流式、非流式。OpenAI 的 Chat 接口,需要自行传入 stream_options,方可在流式输出时返回 usage 对象;其他两家无需额外设置。OpenAI 流式输出会在最后返回一个 trunk,这个 trunk 会在之前 finish reason 所在 trunk 的后面,实际报文如下所示:

...上面报文被省略...

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1719493000,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_sxx","choices":[{"index":0,"delta":{"content":" today"},"logprobs":null,"finish_reason":null}],"usage":null}

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1719493000,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_sxx","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}],"usage":null}

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1719493000,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_sxx","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null}

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1719493000,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_sxx","choices":[],"usage":{"prompt_tokens":94,"completion_tokens":9,"total_tokens":103}}

data: [DONE]

在 DONE 之前的 data 段就是 usage 段。这个 usage 对象,实际上跟非流式输出时定义一样。于是可以编写一段通用的解析代码,从报文最后往前查找 "usage",然后解析后面的花括号内容。当然,这里原则上最优雅的做法是按照 HTTP 的 Server-Sent Events(SSE)去一条条解析消息,但是后面我们会看到查找字符串实现起来最简单。由于 GPT 响应 delta 报文中不会包含 "usage"(JSON 字符串里如果出现这个文本,引号会被转义),所以如此处理也不会有 bug。

在 GPT 的帮助下,解析 usage 对象的代码如下:

export function parseUsageObj(
  responseBody: string,
  key: string,
  fromStart: boolean,
) {
  const usageIndex = fromStart
    ? responseBody.indexOf('"' + key + '"')
    : responseBody.lastIndexOf('"' + key + '"');
  if (usageIndex !== -1) {
    const openBracket = responseBody.indexOf("{", usageIndex);
    const closeBracket = responseBody.indexOf("}", openBracket);
    if (openBracket !== -1 && closeBracket !== -1) {
      const jsonString = responseBody.substring(openBracket, closeBracket + 1);
      try {
        return JSON.parse(jsonString);
      } catch (e) {
        return null;
      }
    }
  }
  return null;
}

转到 ./app/api/openai/[...path]/route.ts,在 requestOpenai 调用后面,我们克隆一个 response,然后用异步的方法读取其中 body,交给上述函数解析,然后调用 pay 函数扣款:

response
      .clone()
      .text()
      .then((responseBody) => {
        //console.log("[responseBody]" + responseBody)
        const usage = parseUsageObj(responseBody, "usage", false);
        console.log(
          "[OpenAI Usage]<" + username + ">",
          JSON.stringify(usage),
        );
        if (
          usage &&
          usage.prompt_tokens != null &&
          usage.completion_tokens != null
        ) {
          return {
            promptTokenNumber: usage.prompt_tokens as number,
            completionTokenNumber: usage.completion_tokens as number,
          };
        }
        console.warn(
          "[ATTENTION][openai] unable to find usage, username=" +
            username +
            ", url=" +
            req.url +
            ", responseBody=" +
            responseBody,
        );
      })
      .then((obj) => {
        if (obj) {
          return pay(
            username,
            modelChoice,
            obj.promptTokenNumber + firstPromptTokenNumber,
            obj.completionTokenNumber + firstCompletionTokenNumber,
            config.shansingOnlineSearchSearchPrice
              .mul(searchCount + newsCount)
              .plus(config.shansingOnlineSearchCrawlerPrice.mul(crawlerCount)),
          );
        }
      });

这里需要注意的地方是,不可直接从 response 中读取 body。因为 body 只能读取一次,这边计费读了以后就不能返回给客户端了,所以我们调用 clone() 复制一份。之所以还强调异步,是因为这里的大概含义是,从 OpenAI API 读取到 response header,即可拿到 response 对象,但调用 .text().json() 这种取 body 的函数又是一个异步(promise)过程:header 读完了,body 可以慢慢读。在流式输出中,需要所有的 trunk 都接收完,才算接收完 body,后面的 .then() 才会执行;或者如果改成 await 阻塞的话,等到 body 都接收完毕才会继续执行后面的代码。显然这里不应该同步阻塞,那样流式输出就不是真流式、没有打字机效果了。而这里也可以看到,拿到作为 text 的 body 通过字符串匹配 usage 对象是最简单的,如果还交给 SSE 库的话显然会变复杂。

对于 Gemini,usage 对象名称叫 usageMetadata,其中关注 promptTokenCountcandidatesTokenCount。对于 Claude,关注 usage 对象的 input_tokensoutput_tokens。但是注意 Claude 的流式输出,input_tokens 会在报文开始不久给出,而 output_tokens 的最终值在报文结尾处。那么需要拼接两个对象。详细代码参见这里,不再赘述。

再提一嘴安全问题。GPT 流式输出如果不传 stream_options,将没有 usage 对象返回,所以应当强制要求传入。ChatGPT Next Web 的整体思路是后端聊天 API 几乎只是转发。也就是说,如果用户调用聊天接口,没有传入 stream_options,我们应该在转发时补上相应参数,而不是继续照原样转发。如果不想修改入参,也可以检测然后拒绝

眼神好、记忆棒的人会发现我这边扣费逻辑跟以前不一样。没错,我去除了预扣费。因为我是小范围分享,照预扣费的逻辑,有的用户用到最后会有一定余额不好花掉,所以索性去除。实际上我也没怎么限制并发。在意的人可以自行加上,在接口最开始时检查拥有足够余额,最保险。

流式输出动画

第二个我改动比较大的点,是流式输出动画帧。查找原项目中的 animate response to make it looks smooth,可以看到去年底作者对动画进行了修改,期望使它更平滑。然而,仔细阅读代码,发现所谓平滑的本质是强行压住已经拿到的增量消息(delta),再拆成一节节——最坏情况下,一个字一个字地——显示输出。我不是很喜欢这样。我觉得应该拿到什么 delta,就直接显示出来,看起来会更畅快,也不会一点点慢慢输出结果一会突然输出剩余字符(这是 finish 函数的作用)。

另一方面,可以看到 animateResponseText() 函数最后,再次通过 requestAnimationFrame 调用自身。我不是很懂前端,但我觉得即使函数最开始有条件判断,如果能避免这种不加间隔的类无限嵌套调用会更好。并且在使用过程中,我发现会偶然出现流式输出突然停止的问题,就好像打字机打到一半罢工了。此时查看浏览器控制台,可以看到“Maximum update depth exceeded”的未捕获报错,看着像是 Next.js 的防御机制,不排除跟嵌套调用有关。单单只是用 try catch 捕获其中 options.onUpdate 的报错,似乎就能解决这个问题,没有观察到什么副作用。

这块地方我也改了很多次,最终改成了接近引入“平滑”动画之前的样子,同时保留使用 requestAnimationFrame 这个现代方法。具体来说,接收到一个 delta,就调用一次 requestAnimationFrame 来请求动画帧,在一帧中就将所有增量文本 delta 输出显示。这样应该最直接、性能最好。

          const delta = choices[0]?.delta?.content;
          const textmoderation = json?.prompt_filter_results;

          if (delta) {
            responseText += delta;
            requestAnimationFrame(() => options.onUpdate?.(responseText));
          }

不过在使用时仍然偶发“Maximum update depth exceeded”问题,暂时不知如何进一步解决。

联网搜索

联网搜索是我一早就想加的功能。我知道可以用 function call 实现,但是想来还是有点复杂,没有底气。直到浏览国内大模型时,看到 Moonshot 的文档指示我们可以用 search2ai。看了一下,挺符合需求。

快速过一遍,search2ai 是利用大模型的 function call 工具调用搜索、爬虫,其中搜索、爬虫的核心逻辑均使用外部 API,旨在以透明方式提供 OpenAI 等接口代理。也就是说,将其独立部署,只需要修改 GPT 前端的 Base URL 就可以快速接入联网搜索。考虑过后,我决定采纳这种方式。

我对 search2ai 魔改挺多,基本只剩个架子跟搜索核心逻辑,也许以后另写一篇详谈。这里只简单说几点。一是原版爬取网页是用外部接口,我不是很欣赏,改成原生 fetch 请求,通过 @mozilla/readability 提取阅读模式一样的主体内容,再转换为 Markdown 格式。后来引入 Jina AI Reader 辅助 PDF 解析。二是自行完善、新增了 Gemini、Claude 支持。根据实际需要,我去除了关于非流式的多余分支,固定第一次请求非流式、第二次流式。而 Claude 很容易链式调用 tool,在第二次如果仍然 call function 则不能返回正确结果(貌似还会回落到非流式,我没有仔细研究),需要额外添加用户消息阻断,这里没有像 GPT 那样的 tool options。另外,计费需要统计两轮请求的费用,加和计算。实际上因为第二次是流式原样返回,简单的做法是此时将第一轮请求的 usage 放到 reponse header,由 GPT 前端加总计费。

接入 ChatGPT Next Web 时,考虑到后端 API 基本是转发,我引入一个自定义的 X-Shansing-Online-Search request header。当这个请求头存在并值为 true 时,就请求本地部署的 search2ai,将真正的 Base Url 以 X-Shansing-Base-Url 发送(因为要支持 OpenAI 兼容接口,如通义千问)。在聊天框上方加一个地球图标,给用户点击开启联网搜索。开启联网搜索就发送 X-Shansing-Online-Search: true 的 header,否则不发送。提取主题、压缩历史记录的请求总是不发送该 header。具体代码比较繁杂,可以自行拉取工程搜索关键词。

之后我还增加联网消息图标显示,用来确定一条回复消息到底有没有使用联网搜索。技术上说,就是到底大模型有没有调用 function call。需要跟 search2ai 的 response header 相配合。

Max token 参数

原项目不给大模型 API 传递 max_tokens 参数,并在注释中称其为 shit。但这样一来,前端界面的“单次回复限制”选项就形同虚设。于是我恢复传入这个参数。

然后我发现,其他地方又在使用(前端)设置的 max_tokens,并且含义跟 OpenAI 等聊天 API 不同。API 的 max_tokens 参数用来限制本次回复的 token 数(也就是输出 token 数)。但 ChatGPT Next Web 认为是总的 token 数,特别是输入 token 数,并以此来限制每次聊天所发送的上下文长度。经过权衡,我决定还是根据大模型 API 的含义修改逻辑。因为前端那个设置选项,默认值才 4000,不适应当前动不动 128K tokens 的上下文;4000 tokens 更像是单纯输出的长度。而且前端界面相应设置,标题和描述感觉自相矛盾;按照标题是控制回复(输出)的才对。

这边改动也有点大,见于 commit 067df37。先是需要理解大模型 API 的逻辑,一般需要我们自行管理上下文(先前我用的那个前端,底层库 chatgpt-api 似乎就干这个)。如果我们设置 4000 的 max token,说明我们期望这么长的输出,那对于一个 128K 上下文的模型,输入可用的就是 124000 tokens。注意输入包括系统消息(system message)、历史上下文(user、assistant)和本次需要发送的消息。统总计算,取合适轮次的上下文。我顺便修改了估计 token 数的方法 estimateTokenLength,采用一个 OpenAI 移植库真实计算 cl100k_base 的 token 数目。这是 GPT-3.5、GPT-4 采用的 tokenizer,GPT-4o 更新了使结果更小。但是没关系,历史上下文 token 计算出来宜多不宜少,宁愿携带更少的上下文也不要突然报错。事实上为了兼容其他模型,保险起见我还在结果乘了 1.1 的系数。

图片上传

所谓图片上传,ChatGPT Next Web 的实现其实是转为 base64 data url,然后传给大模型 API。因为 Local Storage 总大小极其有限,大约只有 5 MiB,所以图片会先经过本地压缩。

文首提到的我提交给上游的 pull request,是用来解决 HEIC 格式的问题。项目原本有 HEIC 支持。但用 Windows Chrome,选择 HEIC 格式的图片,file.type 值会为空导致走不到相应代码分支。用 iOS Safari,这个分支可以走到,但贡献者似乎漏写 else 导致后面会走原分支,抛出错误“上传”中止。另外,Windows Firefox 干脆在“打开”对话框选不到 .heic 文件,我自己把 fileInput.accept 的值从 MIME 类型改为扩展名就好了。

原有的图片压缩逻辑也不太行。压缩目标为 JPEG,采用 JPEG 质量等级,一步一步降低,降到一个阈值后转而缩小尺寸,直到文件大小为 256KiB。然而,根据 OpenAI 文档,GPT 支持的图片有最大尺寸限制,即短边 768px;其他模型类似。所以更好的做法是,不管三七二十一,首先缩小尺寸到短边 768px,然后才开始调降质量等级。我找到一个库,刚好可以同时设置缩放比例、目标文件大小。实测显示,甚至效率更好,肉眼可见变快很多。并且,在最初缩小尺寸之后,只调降质量等级,不再继续缩小图片,压到 256KiB 的图片也还能看。

代码可以在项目中查找 compressImage 函数,或者查看 commit #b26a9e2

通义千问

既然相比原来支持更多模型,我也看了国内的,考虑要不要新增支持。通义千问非常便宜,更重要的是支持 OpenAI 兼容格式。这样配置起来就非常方便。

不过为了共存,我没有直接修改 BaseUrl。而是仿照已有文件,新建 Alibaba 目录。参见 commit #61a1bd6

经过测试,通义千问-Max 的联网搜索效果不错。比 Gemini 之流强多了。不过中间突然改变过 API 行为,使得 tools 参数必须一直传下去,而不是在第二轮可以摘除不传(参见上面“联网搜索”章节),否则用 JSON 字符串作为 function response 会得到相当异常的回复。工单体验不太好,后面我会单独写一篇博客。

Qwen-Long 的回复质量也超出预期。像有的问题 GPT-4o 也不能很好回答,Qwen-Long 一枪命中。我前面说的“非常便宜”就是指降价后的 Qwen-Long,跟白送一样。如果我原价提供给朋友,甚至还要倒贴流量钱。Qwen-Long 这么便宜应该也是因为支持上传文件,鼓励大家多使用大文件、长 token。

文件上传支持起来比较简单,比 OpenAI 要额外调用 Assistant API 强太多。不过,貌似同时上传多个文件容易触发 429 Rate Limit,建议使用单选。有时候也会莫名触发,不是十分稳定。

小修改

接下来是一些小修改,想到哪写到哪。也不一一提供参考链接了。

自动刷新模型缓存。不知出于什么原因,ChatGPT Next Web 前端缓存了模型列表,导致服务端修改环境变量后用户很难看到更新。所以加了一个 resetModels() 函数及调用。

图片放文本前面。根据 Claude 文档,将 image 放到 text 上面有助于获得更好效果。

移除 Gemini 安全内容限制。代码传的 BLOCK HIGH。根据官方文档,相应参数指的是概率而不是严重程度。干脆改成 NONE。很多人觉得 Gemini 审查严格我估计跟这个设置有关。

正确处理 Claude 系统消息。原项目对 Claude 支持比较简略,比如没有正确传递 system 参数。这里要处理。注意这是外层参数,不是 messages / content,也不像 OpenAI 接口可以传递多个 system message。

移除 Claude 响应的 Content-Encoding header。对于非流式输出情况,Claude 似乎会错误地声明自己是 gzip 压缩,实际上并没有压缩。直接移除掉相关的 response header 就好。

所有模型都注入系统消息。虽然不确定 Claude 等模型用什么系统消息最好,但先按 ChatGPT 模板赋予一个,起码解决 LaTex 等问题。Claude 3 貌似是你不给定 LaTex 格式它就以为不支持,就不会吐出 LaTex 公式。

调用 API 时传递用户名 hash。GPT 和 Claude 支持传递用户名,未来如果发现滥用可以提醒我们。做一个 hash 后传过去就行。通义千问 OpenAI 兼容接口不支持,但传过去无害。Gemini 不支持。

为用户消息设定错误标志。相应处理逻辑本身有一句 userMessage.isError = ...,但这边 userMessage 算是一个拷贝对象,要赋给 savedUserMessage 才有用,可能是编码失误。标为错误的消息可以加个红圈圈 emoji,在之后的聊天中不会发送。用户消息也标为错误,就也不会作为后续上下文发送。比较符合直觉,也符合 Claude API 那种 user、assistant 必须一来一回的要求。

更新对话时尽量指定对话。分拆 updateCurrentSession 调用,如果不是确定要调整当前 session(对话),则需要指定。像更新对话主题(topic)的时机,实际上比最开始获取(当前)session 会延后一小段时间,不改可能出现更新到错误的对话上去的情况,因为“当前”session 已经切到另一个了。

改服务端 runtime 为默认。原项目使用 edge runtime,比较轻,但是缺乏很多 node API 支持。我部署 web 端,放心改成默认的 Node.js 运行时。这样可以继续用 fs 读写用户余额。

代理缓存 Emoji 和字体。将外部资源用 Nginx 代理并缓存,加快加载。

移除多余路径反代。可能是为打包成 app,ChatGPT Next Web 反向代理了很多接口,包括 OpenAI 聊天 API。我部署 web 端,这些看起来更像是安全漏洞,于是注释掉。我不想自己搭建一个 API 中转站。不过我建议你不要轻易试探访问,指不定有些实例背后是蜜罐。

结语

这个项目总体很好,节省很多工夫,感谢作者和贡献者们。这些修改多半是我个人需求,只是 pull request 能更积极 review、合并就好了。

在此插入一个提取错误信息的方法。从混杂 JSON 串或者 Nginx 默认错误页面中提取错误文案。对于 JSON 字符串,大概逻辑是优先取作为字符串的 messagemsg 值,如没有则取 key 包含“err”(如 errorerrMessageerror-message)的字符串类型的 value。会自动遍历嵌套对象。在这个项目的聊天、上传文件异常时能用到,算是小小万金油。由 Claude 辅助编写。代码比较长,搜索 extractErrorMessage 函数吧。

最后,尽管上面有些改动给了 commit 链接,仍然建议参考最新代码。链接:https://github.com/shansing/ChatGPT-Next-Web

]]>
4 https://shansing.com/read/542/#comments https://shansing.com/feed/tag/JavaScript/
让 chatgpt-web 支持按量计费与版本切换 https://shansing.com/read/540/ https://shansing.com/read/540/ Thu, 17 Aug 2023 12:37:00 +0000 闪闪的星 我用 Chanzhaoyu/chatgpt-web 搭了一个 GPT 的网页端,小范围交流共享。本来这个项目没有计费功能,我也懒得加,GPT-3.5 价格不高,我都自己承担。GPT-4 公开开放以后,眼看费用骤增,于是我琢磨怎么计费。原作者实际上在推一个收费版,但我以成本价共享还是作罢。最终,我设计实现一套分用户、按量计费的简单方案。然后趁热打铁,顺势编写切换模型版本的功能,这样想便宜可以用 3.5,想有高质量用 4。

因为我仍然很懒,同时无意与前述收费版本竞争,所以也没编写用户管理。HTTP Basic Auth 够用了,用 Nginx 即可方便实现访问认证。需要添加用户的时候用 htpasswd 添加。本文预设前提即是这个。当然这只影响获取用户名,其他设计代码可以通用。假设有人用我改好的 fork,请注意这一点。

按量计费

实现按量计费的关键,在于统计输入、输出的 token 数。单价是已知的。如果阅读官方文档,API 的响应 JSON 包含一个 usage 字段,其中 completion_tokens 代表输出(结果)token,prompt_tokens 代表输入(提示词的)的 token。然而,如果设置了流式输出(stream mode,即打字机效果),则不会给出这些数值。寻觅一番,正当我打算自己写估算代码,发现已经有人做好了:chatgpt-web 依赖的底层库 chatgpt-api 已经支持流式输出下的 token 估算。

在 chatgpt-web 工程中,找到服务端的 function chatReplyProcess(options: RequestOptions),这里就是最终调用 chatgpt-api 发出请求的地方。定位 const response = await api.sendMessage(...),其中的 api 就是 chatgpt-api 的 ChatGPTAPI | ChatGPTUnofficialProxyAPI 类型,response 则是其响应返回。一路拿到 response.detail.usage.completion_tokens 的值就好啦,prompt_tokens 同理。

之前我看到过 api2d 这样的中间商,似乎有一个预扣费机制,我也学过来。大体上是最开始扣除单次最大开销,等到请求结束再依实际情况退还多收取的费用。因为实际费用在获取结果之后才能知道,这大概是某种防止余额变为负数的机制,封堵透支漏洞。而我这里后端,如果捕获到网络中断之类的异常,还是会全额返还。我也没有进一步考虑开启长回复(VITE_GLOB_OPEN_LONG_REPLY,如果本次返回收到因 token 问题截断,向 OpenAI API 重新发起请求,效果相当于帮用户说“继续说完”)的预扣费问题,反正目的是小范围使用。

引入 decimal.js 作精确小数运算。示意代码如下:

    if (!prePay(username)) {
        globalThis.console.error(username + "'s quota is not enough, need " + maxPrice);
        return sendResponse({ type: 'Fail', message: '[Shansing Helper] Insufficient pre-deduction quota, need ' + maxPrice })
    }
    //下面是原有代码
    const response = await api.sendMessage(message, {
      ...options,
      onProgress: (partialResponse) => {
        process?.(partialResponse)
      },
    })
    //上面是原有代码
    payback(username, response)
function prePay(username) {
    if (quotaEnabled && username) {
        // globalThis.console.log('prepay:', 'username', username, 'maxPrice', maxPrice)
        return decreaseUserQuota(username, new Decimal(maxPrice))
    }
    return true
}
function payback(username, response : ChatMessage) {
    if (username && quotaEnabled) {
        let plus;
        // globalThis.console.log('response.detail', response.detail)
        if (response && response.detail && response.detail.usage && response.detail.usage.completion_tokens != null) {
            let usage = response.detail.usage;
            //退还费用
            let thisBilling = (new Decimal(promptTokenPrice).mul(usage.prompt_tokens))
                .plus(new Decimal(completionTokenPrice).mul(usage.completion_tokens))
            plus = new Decimal(maxPrice).sub(thisBilling)
        } else {
            //退还所有费用
            plus = new Decimal(maxPrice)
        }
        // globalThis.console.log('payback:', 'username', username, 'plus', plus)
        increaseUserQuota(username, plus)
    }
}
function increaseUserQuota(username : string, delta : Decimal) {
    let quota = readUserQuota(username) //实现用户余额读取逻辑
    // globalThis.console.log(username + '\'s old quota: ' + quota.toFixed())
    let newQuota = quota.plus(delta)
    // globalThis.console.log(username + '\'s new quota: ' + newQuota.toFixed())
    if (newQuota.lt(0)) {
        return false;
    }
    //在此实现费用增加逻辑,负数为扣费
    return true;
}
function decreaseUserQuota(username : string, delta : Decimal) {
    return increaseUserQuota(username, new Decimal(-1).mul(delta))
}

其中的用户名需要在更外层取,具体是 service 下的 src/index.ts。可以让 ChatGPT 编写一个 getUsernameFromHttpBasicAuth 方法,从 req 拿到请求,其中的 authorization header 可以解析出用户名。

问题是计费信息存哪呢?正经的做法是用数据库,并且将每一次的计费记录存储起来。然而我懒,毕竟小范围用,你看我代码就知道,我其实只存了一个余额。而且我不想引入额外依赖,干脆写到文件里,反正 HTTP Basic Auth 的用户也是存到文件的。新建用户的时候也新建用户余额文件;如果需要修改余额,我就编辑余额文件。

具体代码不赘述,可以拉到文章末尾点链接看。注意流式传输下的 token 数是估计得来,可能偏小。

版本切换

要想版本在 GPT-3.5 与 GPT-4 间切换,调用 api.sendMessage 时注意 options.completionParams.model 的值就行了。事实上原项目已经提供了切换,不过是编译时手动在 .env 指定的,不能前端用户动态切换。你可能会想,那我在前端加个选项,后端这里根据不同选项传入不同的 model 值就行了。确实是,但还有一点。

不同的模型支持的 token 数不同。比如 gpt-3-turbo 支持 4k 上下文,gpt-4 支持 8k。严格来说,要判断不同的模型给不同的参数。这里就是代码中刁钻的地方了。翻看 maxModelTokensmaxResponseTokens,定义完后传给了 api = new ChatGPTAPI({ ...options }),也就是说 api 这个对象产生的时候就赋值好了,而模型名称要到每次用 api 请求 OpenAI 时才传入。我想了想,这里不能重构太多,我希望还能方便地从上游合并代码,于是我另外定义了跟 api 相同类型、相同作用的对象。并且由于模型是可选择的,我定义多个这样的对象,放到数组当中,同样预先赋值好。在每次请求 API 时拿出来用。

数组里顺便可以将计费价格相关的字段存起来。示意代码如下:

    if (modelChoices != null) {
        const metaMaxModelTokens = 1000
        for (let modelChoice of modelChoices) {
            let promptTokenPrice = new Decimal(modelChoice.promptTokenPrice)
            let completionTokenPrice = new Decimal(modelChoice.completionTokenPrice)
            let choiceOptions: ChatGPTAPIOptions = JSON.parse(JSON.stringify(options))
            let maxModelTokens
            let maxResponseTokens
            let lowercaseModel = modelChoice.model.toLowerCase()
            if (isNotEmptyString(MAX_TOKEN_TIMES)) {
                const maxTokenTimes = parseInt(MAX_TOKEN_TIMES);
                maxModelTokens = metaMaxModelTokens * maxTokenTimes
                maxResponseTokens = maxModelTokens / 4
            } else if (lowercaseModel.includes('16k')) {
                maxModelTokens = metaMaxModelTokens * 16
                maxResponseTokens = maxModelTokens / 4
            } else if (lowercaseModel.includes('32k')) {
                maxModelTokens = metaMaxModelTokens *32
                maxResponseTokens = maxModelTokens / 4
            } else if (lowercaseModel.includes('64k')) {
                maxModelTokens = metaMaxModelTokens * 64
                maxResponseTokens = maxModelTokens / 4
            } else if (lowercaseModel.includes('gpt-4')) {
                maxModelTokens = metaMaxModelTokens * 8
                maxResponseTokens = maxModelTokens / 4
            } else {
                maxModelTokens = metaMaxModelTokens * 4
                maxResponseTokens = maxModelTokens / 4
            }
            choiceOptions.maxModelTokens = maxModelTokens
            choiceOptions.maxResponseTokens = maxResponseTokens
            let maxPrice = quotaEnabled ? (promptTokenPrice.mul(maxModelTokens - maxResponseTokens)).plus(completionTokenPrice.mul(maxResponseTokens)) : null
            if (modelChoice.maxPrice == null && maxPrice != null)
                modelChoice.maxPrice = maxPrice.toFixed()
            modelChoice.api = new ChatGPTAPI({ ...choiceOptions })
        }
    }

(理论上所谓 4k tokens 应该是有 4096,但是底层库的估计似乎不总准确。实际使用偶尔会出现 OpenAI API 报错说超出数目,故我采用 1000 的倍数。)

请求接口时接收用户入参 modelName,相关代码为:

    let modelChoice = null;
    if (modelName && modelChoices) {
        modelChoice = modelChoices.find(choice => choice.name === modelName);
    }
    if (modelChoices && modelChoice == null) {
        return sendResponse({ type: 'Fail', message: '[Shansing Helper] Invalid model choice' })
    }

    ...

    let processApi = api;
    if (modelChoice) {
        processApi = modelChoice.api
        options.completionParams.model = modelChoice.model
        if (!prePay(username, modelChoice)) {
            globalThis.console.error(username + "'s quota is not enough, need " + modelChoice.maxPrice);
            return sendResponse({ type: 'Fail', message: '[Shansing Helper] Insufficient pre-deduction quota, need ' + modelChoice.maxPrice })
        }
    }
    const response = await processApi.sendMessage(message, {
        ...options,
        onProgress: (partialResponse) => {
            process?.(partialResponse)
        },
    })
    payback(username, response, modelChoice)

我也做了可配置的选择框,将相关 json 字符串写到 .env,如此修改模型可选项就不用再改代码。

#余额文件的目录(需要权限)
SHANSING_QUOTA_PATH=/opt/quota/
#模型数组,包含计费单价
SHANSING_MODEL_CHOICES='[{"name": "GPT-4", "model": "gpt-4", "promptTokenPrice": "0.0002", "completionTokenPrice": "0.0005"}, {"name": "GPT-3.5", "model": "gpt-3.5-turbo", "promptTokenPrice": "0.000013", "completionTokenPrice": "0.000018"}]'

完整代码

我维护了自用的 fork,上述改动可见于:https://github.com/shansing/chatgpt-web

README 就没写了,.env.example 我有提交改动。如果需要使用,可以参考范例。

2024-06-16 P.S.迟来的更新。OpenAI 官方已支持流式输出返回 usage,但格式需要注意。项目所用类库应该不直接支持。

]]>
2 https://shansing.com/read/540/#comments https://shansing.com/feed/tag/JavaScript/
用油猴脚本自动检测网页水印 https://shansing.com/read/533/ https://shansing.com/read/533/ Mon, 05 Sep 2022 11:16:00 +0000 闪闪的星 在 V2EX 看到帖子,知乎在网页等全端加上隐写水印,水印信息包括用户 ID 及时间戳;肉眼很难察觉,几乎只能通过专业分析还原。截至本文发布,知乎似乎已经下线水印。帖子下面一些回复很有价值,网友给出各种分析与应对方式:在网页端,包括用 uBlock Origin 等插件以去广告方式去除,提醒网友对截图进行二值化处理等等。其中一个方法是用油猴脚本检测。

本文假定你已经了解 HTML、CSS、JavaScript 以及油猴脚本。油猴脚本是用于 GreaseMonkey 等浏览器扩展组件的脚本,本质是用户附加在网页上的一段 JavaScript 代码。用油猴脚本检测,即是用 JavaScript 检测。

根据 #7 @ZhiyuanLin 给出的信息,知乎这次的隐写水印承载于 HTML 网页上的 div 元素,以 svg 图片作为背景:

.css-xxxxxx {
    position: fixed;
    top: 0px;
    width: 100%;
    height: 100%;
    background: url("data:image/svg+xml;base64,此处是 base64 编码的 svg 图片,已移除") space;
    pointer-events: none;
}

svg 不同于一般理解的图片,它不是位图,而是一系列规则生成的矢量图片(也可以引用包含位图)。非常容易生成给定文本的 svg 图片,作为水印使用也就不难理解了。#90 @coolzjy 写了一个油猴脚本,可以检测并提示类似的水印:

// ==UserScript==
// @name         Detect Watermark
// @version      0.3
// @description  Detect invisible watermark on the page to avoid track
// @author       You
// @match        https://*/*
// @grant        none
// @run-at       document-idle
// @namespace    https://greasyfork.org/users/474693
// ==/UserScript==
 
(function () {
    'use strict';
 
    function isWatermarkElement(el) {
        const style = getComputedStyle(el);
        return (style.pointerEvents === "none" &&
                style.position === "fixed" &&
                style.backgroundImage.toLowerCase().includes("data:"));
    }
 
    const LANG = {
        "zh-CN": {
            warn: "⚠️ 当前页面可能含有水印,请注意保护个人信息!",
            dismiss: "知道了",
        },
    };
    function getText(key) {
        const text = LANG[navigator.language] ?? LANG["zh-CN"];
        return text[key];
    }
 
    async function detect() {
        return new Promise((resolve) => {
            const elements = document.querySelectorAll("*");
            let cursor = 0;
            const run = ({ didTimeout }) => {
                for (; cursor < elements.length; cursor++) {
                    const element = elements[cursor];
                    if (isWatermarkElement(element)) {
                        resolve(element);
                        return;
                    }
                    if (didTimeout) {
                        requestIdleCallback(run);
                        return;
                    }
                }
                resolve();
            };
            requestIdleCallback(run);
        });
    }
    function report(el) {
        const shadowHost = document.createElement("div");
        const shadowRoot = shadowHost.attachShadow({ mode: 'closed' });
        document.body.appendChild(shadowHost);
        const notice = document.createElement('div');
        notice.setAttribute("style", [
            "position: fixed",
            "z-index: 99999",
            "top: 10px",
            "right: 10px",
            "left: 10px",
            "display: flex",
            "justify-content: space-between",
            "align-items: center",
            "color: white",
            "background: red",
            "border-radius: 8px",
            "padding: 8px",
        ].join(";"));
        notice.innerText = getText("warn");
        const button = document.createElement("button");
        button.innerText = getText("dismiss");
        button.addEventListener("click", () => {
            document.body.removeChild(shadowHost);
        });
        notice.appendChild(button);
        shadowRoot.appendChild(notice);
    }
    setTimeout(async () => {
        const watermarkEl = await detect();
        if (watermarkEl == null)
            return;
        report();
    }, 5000);
 
})();

实测有效。

不过要说“类似”,有点过于“类似”了。查看 isWatermarkElement(el) 方法,水印元素 el 的位置一定是固定的吗(position:fixed)?不见得。所以可以去除这个条件。水印一定是使用 background 的元素吗?<svg> 也可以是独立的标签元素,也允许使用 <embed><object><iframe> 标签嵌入 svg。另外,可以使用 canvas 绘制水印,于是我的修改版如下:

// ==UserScript==
// @name         Detect Watermark
// @version      0.3
// @description  Detect invisible watermark on the page to avoid track (mod by Shansing)
// @author       You
// @match        http://*/*
// @match        https://*/*
// @grant        none
// @run-at       document-idle
// @namespace    https://greasyfork.org/users/474693
// ==/UserScript==

(function () {
    'use strict';

    function isWatermarkElement(el) {
        const style = getComputedStyle(el);
        return (style.pointerEvents === "none" &&
                style.backgroundImage.toLowerCase().includes("data:") //&&
                //style.backgroundImage.toLowerCase().includes("image/svg")
               )
               ||
            (style.pointerEvents === "none" &&
                el.tagName.toLowerCase() === "canvas")
               ||
            (style.pointerEvents === "none" &&
                el.tagName.toLowerCase() === "svg")
               ||
            (style.pointerEvents === "none" &&
                el.tagName.toLowerCase() === "embed")
               ||
            (style.pointerEvents === "none" &&
                el.tagName.toLowerCase() === "object")
               ||
            (style.pointerEvents === "none" &&
                el.tagName.toLowerCase() === "iframe")
        ;
    }

    const LANG = {
        "zh-CN": {
            warn: "⚠️ 当前页面可能含有水印,请注意保护个人信息!",
            dismiss: "知道了",
        },
    };
    function getText(key) {
        const text = LANG[navigator.language] ?? LANG["zh-CN"];
        return text[key];
    }

    async function detect() {
        return new Promise((resolve) => {
            const elements = document.querySelectorAll("*");
            let cursor = 0;
            const run = ({ didTimeout }) => {
                let foundElement = null;
                for (; cursor < elements.length; cursor++) {
                    const element = elements[cursor];
                    if (isWatermarkElement(element)) {
                        console.log("Detect Watermark", element);
                        //resolve(element);
                        //return;
                        foundElement = element;
                    }
                    if (didTimeout) {
                        requestIdleCallback(run);
                        return;
                    }
                }
                resolve(foundElement);
            };
            requestIdleCallback(run);
        });
    }
    function report(el) {
        const shadowHost = document.createElement("div");
        const shadowRoot = shadowHost.attachShadow({ mode: 'closed' });
        document.body.appendChild(shadowHost);
        const notice = document.createElement('div');
        notice.setAttribute("style", [
            "position: fixed",
            "z-index: 99999",
            "top: 10px",
            "right: 10px",
            "left: 10px",
            "display: flex",
            "justify-content: space-between",
            "align-items: center",
            "color: blank",
            "background: #ccc",
            "border-radius: 8px",
            "padding: 8px",
        ].join(";"));
        notice.innerText = getText("warn");
        const button = document.createElement("button");
        button.innerText = getText("dismiss");
        button.addEventListener("click", () => {
            document.body.removeChild(shadowHost);
        });
        notice.appendChild(button);
        shadowRoot.appendChild(notice);
    }
    setTimeout(async () => {
        const watermarkEl = await detect();
        if (watermarkEl == null)
            return;
        report();
    }, 6000);

})();

你可能注意到 isWatermarkElement(el),我在第一个条件中要求背景图片的地址不仅包含 data:,还要包含 image/svg,然后我又注释掉了。这是因为我想有些网页元素确实只是想显示用 base64 编码的 png 图片(data:image/png),而不旨在 svg 打水印。你会想,普通图片不能是生成的水印吗?完全可能,所以我最后没有应用该条件,不过理论上这时也要提防普通的 <img> 元素。我就没有一步到位考虑周全了,毕竟猫捉老鼠相互促成。比如,你现在认为 pointer-events:none 是水印必要样式,我如果是打水印的网站,知道你的想法,难道不能用别的值吗?更进一步,水印一定是图片吗?这些就留给各位自己思考,本文算作抛砖引玉。

除了完备性外,当然也有正确性的问题,也就是误报。所以我加上一行 console.log("Detect Watermark", element),如果看到提示说检测到水印,可以打开控制台排查详情。

2022-09-05 P.S.更新代码,使控制台输出所有疑似水印元素,而不只是第一个。

]]>
0 https://shansing.com/read/533/#comments https://shansing.com/feed/tag/JavaScript/
闪星花密v2:对花密的试改进 https://shansing.com/read/477/ https://shansing.com/read/477/ Tue, 25 Jun 2019 07:55:00 +0000 闪闪的星 花密是我介绍过的一款可记忆的独立密码解决方案。不同于随机生成密码然后存储起来管理的方案,花密不需要用户储存管理,也能为每个账户提供高强度的独立密码

当初介绍花密的时候,我还是个初中生,只觉得花密好厉害,但没有查阅其算法的能力。如今,我已学过一些算法,也算是懂得一点密码学的知识。在建立自己的花密镜像后,觉得花密还有提升空间。于是斗胆试着改进一下。

花密算法简介

原版花密算法可以用 JavaScript 这样表示:

    var str = '01279abe' //sunlovesnow1990090127xykab

    var md5one = md5(password, key);
    var md5two = md5(md5one, 'snow');
    var md5three = md5(md5one, 'kise');
    
    var rule = md5three.split('');
    var source = md5two.split('');
    for (var i=0; i<=31; i++) { 
        if (isNaN(source[i])) {
            if (str.search(rule[i]) !== -1) {
                source[i] = source[i].toUpperCase();
            }
        }
    }
    var code32 = source.join('');
    if (isNaN(source[0])) {
        var code16 = code32.slice(0, 16);
    }else{
        var code16 = 'K' + code32.slice(1, 16);
    }

    //code16 is the result

代码量其实没多少,也很简单。它的思路是,根据用户输入的记忆密码(password)和区分代号(key)生成散列值(哈希值或杂凑值,取决于你怎么翻译 hash value) md5one,再对得到的散列值 md5one 散列得到不同值的 md5two 和 md5three,md5two 是生成密码(code16)的主体,md5three 用来调整生成密码中字母的大小写。最后,保证生成密码第一位是字母;如果不是,替换为 K。

这里用到的核心算法是 HMAC-MD5。MD5 是常见的散列函数。如果你还不了解,散列函数根据一个字符串生成固定长度的值,这个过程是不可逆的,并且(一般来说)输入字符串的微小改变都会导致散列值的不同。而 HMAC 函数可以在散列的时候加上 key,在这里实际上相当于加盐。加盐是为了缓解彩虹表攻击。用 HMAC 加盐相比直接追加字符串更安全,比如能预防哈希长度拓展攻击。总之,进行这些操作后,攻击者几乎不可能从生成密码逆推出记忆密码和区分代号——至少理论上是这样的。

原版算法的瑕疵

我们知道,花密的生成密码是以字母开头的、包含大小写字母和数字的 16 位字符串(事实上花密原本也提供 32 位的生成密码,本文不讨论)。而通过算法进一步分析出,因为生成密码会是十六进制形式的哈希值,所以所谓的“大小写字母”其实只包含 AaBbCcDdEeFf,再往后的字母是不会出现的(除了首字符可能是 K)。于是乎,生成密码的可能组合数是 13*(22^15),约为 1.78e+21 或者 2^70.6。这样的强度其实算高了,但 26 个字母只会出现 6 个,显然不能说充分利用了所有空间。理论上,16 位含所有大小写字母和数字的组合数是 62^16,约为 4.77e+28 或 2^95.3,所以提升空间很大。换句话说,攻击者得知你的密码是 16 位大小写字母和数字的组合,会认为最多要尝试 2^95.3 次才算完,然而知道你是在用花密后,马上坍缩到 2^70.6 次,水平只是略接近于 12 位大小写字母和数字的密码

再次说明,这个强度已经算高了。只是,如果我们提升组合数,就能进一步增加暴力破解的难度,符合我们对 16 位密码的期望。同时,如果我们把增加的组合数用于表达散列值,也更能减少生成密码“碰撞”的几率。

另外一个比较明显的瑕疵是,花密使用的散列函数是 MD5。MD5 不安全。我由于水平原因,没法具体解释怎么不安全,大概是已知有手段更容易发生碰撞,但不确定这里是否受影响。不过,既然要进行改进,无妨使用更安全的散列函数。

改进版:闪星花密v2

首先,用 SHA-256 替代 MD5。SHA-256 是目前推荐的安全的散列算法,使用广泛,是 MD5 和 SHA-1 的主要替代者。

编码的部分,不再使用十六进制形式。容易想到一种常见的编码方式是 Base64,把 3 个 8 位字节转化为 4 个 6 位的字节,即一个字符(8 位)能表达 6 位信息,比起 bin2hex 式的只表达 4 位信息要高效许多。但 Base64 编码表的 64 个字符中,包含两个特殊符号,需要特别考虑。

一不做二不休,我索性决定不仅容纳两个特殊符号,还强制使每个生成密码包含特殊符号(说强制是因为 Base64 编码后的串并不一定出现那两个特殊字符)。我将强制引入的特殊符号放到第一位。于是生成密码由原版的以字母开头,变成以符号开头。我想,如今似乎已经见不到那种要求密码以字母开头的网站,却有更多的网站要求密码中包含特殊符号,因而这么做是合理的。事实上,这样做降低了密码强度,只有在别人不知晓你用闪星花密v2的情形下才能说更“安全”(根据现代密码学思想,我们不能依赖算法的保密性,因此最好不要依赖这一点),算是牺牲一点安全性来换取便利,在要求特殊字符的网站不必手动添加符号。特殊符号不放到末尾则是为了裁剪密码时不丢失特殊符号。

特殊符号理论上可以使用所有 ASCII 可打印符号。但我遇到的一些网站,只认其中一部分,否则仍然认为你不符合它们的强度要求。另外考虑到具体的场景如 Oracle Identity Manager、Microsoft Active Directory,有些符号不能出现。于是我决定,首字符限定从 !@#$% 中产生,因为网站应该都至少认这几个在键盘上连续的符号;其后的符号只允许出现 /\,代替 Base64 原本的 +/,这算是为了好看,只在很小的程度上能避免让人发现这是 Base64 编码(这个目的其实没意义,原因同上)。

原本还想使用诸如 PBKDF2 的方式,多次迭代使得彩虹表更难产生,想来意义不大,还增加耗电,作罢。

实现代码如下:

(注:由于所使用库不同,这里的 sha256.hmac 参数顺序为 sha256.hmac('key', 'Message to hash'),与上例中 md5 相反)

    var symbol = "!@#$%!@#$%!@#$%!@#$%!@#$%!@#$%";
    var head, hash, code16;
    hash = sha256.hmac(key, password);
    hash = sha256.hmac.update("ShansingPv2", hash).array();
    code16 = hash.slice(0, 12);
    code16 = btoa(String.fromCharCode.apply(null, code16));
    head = code16.charCodeAt(0);
    if (head >= 65 && head <= 90) head = symbol[head-65];
    else if (head >= 97 && head <= 122) head = symbol[head-97];
    else if (head >= 48 && head <= 57) head = symbol[head-47];  //-48+1, history issue
    else if (head === 43) head = symbol[3]; // + -> $
    else if (head === 47) head = symbol[4]; // / -> %
    code16 = head + code16.slice(1).replace(/\+/g, "/").replace(/\//g, "\\");

    //code16 is the result

基于 Base64 的特性,要生成 16 位(字符)的密码,就取 12 字节的值。这里的问题是,SHA-256 原本提供的散列值是 256 (二进制)位的,直接截断为 12*8=96 位是否安全。我找寻到的资料说是安全的,除了显然会增加碰撞概率外。(这是相对于 256 位来说的。而相对于原版最多 4*16=64 位,这里的碰撞可能更小。)

因为 Base64 已经能提供大写和小写字母,所以不需要另外决定生成密码中字母的大小写。

容易算出,闪星花密v2 生成密码可能的组合数是 5*(64^15),约 6.19e+27 或 2^92.3,非常接近我们对 16 位密码的期望。

简单比较

根据我的简单测试,闪星花密v2 与原版算法速度差不多。

列出特性对比如下:

比较项花密闪星花密v2
核心算法HMAC-MD5HMAC-SHA256
首字符类型字母AaBbCcDdEeFfK特殊符号!@#$%
允许的非首字符AaBbCcDdEeFf和所有数字所有大小写字母和数字,和符号/\
log2(组合数)*≈70.6≈92.3

*参考值:16位含大小写字母和数字的密码 ≈95.3。

在线试用:https://shansing.com/passwords/ (勾选“使用闪星花密v2算法”)

注意,尽管我想尽力设计出完善的算法,但成品的相关效果(包含安全性)没有保证。本人不是密码学专业人士,水平可能非常业余,请谨慎使用。当然,如果发现不妥,非常欢迎在下面留言指出。

2019-6-25 17:28 P.S.更新闪星花密v2代码,但未作实际变动。

]]>
39 https://shansing.com/read/477/#comments https://shansing.com/feed/tag/JavaScript/
iOS QQ 内置浏览器 AJAX 失效的两个原因及解决方法 https://shansing.com/read/465/ https://shansing.com/read/465/ Fri, 27 Jul 2018 07:29:00 +0000 闪闪的星 今天总算解决了网站的两个 BUG。症状表现都是,用 iOS 上的手机 QQ 内置浏览器,AJAX(通过 jQuery 或是原生 XMLHttpRequest 使用)出现错误。如果不用手机 QQ 内置浏览器,用 iOS 系统自带 Safari,甚至微信内置浏览器,都能正常工作。一个是 GET 请求,另一个是带数据(请求体)的 POST 请求。

后来研究发现,理论上在别的环境下也可能有错,尤其是旧版本的 Safari 等,这是后话了。我们进入正题。

HTTPS 下 GET 请求失败

我的网站大多是强制使用 HTTPS 的,HTTP 请求都会 301 重定向到 HTTPS 页面去。我在 iOS QQ 发了一个网址,点击进去,发现通过 XMLHttpRequest 的 GET 请求失败。通过 status 属性得到状态号是 0,就好像 XMLHttpRequest 没有初始化或连接突然中断一样。换用 jQuery 提交 GET 请求,问题依旧。

在我这里,解决方法是如下的 HTML 代码:

<script>
    if(window.location.protocol == 'http:') window.location.protocol = 'https:';
</script>

实际上是用 JavaScript 代码判断协议,如果发现是 HTTP 协议,就跳转(重定向)到 HTTPS 协议相应的网址。

那我访问的明明是 https:,怎么会成了 http:?仔细观察,我发现自己在 iOS QQ 发的网址没有带协议(或者带 http:// 道理也是一样的),则默认使用 HTTP 协议。尽管可以发现实际访问了 HTTPS 的文档,但浏览器仍然认为是 HTTP 协议:如果用 JS 获取相应的标识,你会得到 HTTP;或者也可以获取相对路径资源的 src,也得到 HTTP;使用 QQ 内置浏览器的复制网址功能,得到的也是 HTTP 的网址。这又是怎么回事?最终我做了个实验发现,是 HSTS 的缘故。推测在 iOS QQ 内置浏览器中,HSTS 并不像其他浏览器那样产生重定向,那么通过 http: 网址进入时,虽然实际访问了 https: 的文档和资源,URL 模式(scheme)却仍然保留着为 http。而 http: 和 https: 的资源不属于同一个源(Origin),就会产生意料之外的跨源问题(参见同源策略),导致 AJAX 请求失败。

带数据 POST 请求失败

我另一个网站遇到的问题是,在 iOS QQ 内置浏览器上,POST 请求失败。相关的程序报告了 xhr.responseText:undefined, type:error 错误,错误码 -1001。于是我自己构造了相关请求,发现把 data 设为 null 时,可以提交;一旦我传上 POST 参数,就会出现前述错误。

这个问题困扰了我好几天。网上有说是因为 XMLHttpRequest 没有初始化,说要定时,延迟发送请求;试过,似乎不起作用。后来我看人家同样的网站内容,却没有问题。那会不会是 HTTPS 的问题呢?可我再找了一个别人的 HTTPS 网站,也是正常的。我就再分析 HTTP 请求头(Request Header)和响应头(Response Header)。虽然我想是没有跨源,但我加上了跨源的相关头也无济于事。突然发现我的响应头名称是小写的,而非一般的单词首字母大写的写法,查询得知这是 SPDY 和 HTTP/2 的规范特性。于是使用“iOS QQ post http2”的关键词查询,看到一篇眼熟的文章:《谈谈Nginx 的HTTP/2 POST Bug》。就是它让我解决了这个问题。(后来翻了 Nginx 的错误日志,发现每次 AJAX 请求失败都有 worker process 23415 exited on signal 11 的提示)

文章的问题与我遇到的相似,尽管它提到的受影响范围更大一些,我只专注于观察 iOS QQ 内置浏览器去了。说原因是 Nginx 的实现与 HTTP/2 规范不符,不能正确接收 POST 请求的 DATA 帧,而浏览器可能不会重试,最终导致 POST 请求失败。至于我,早就没在用那旧版本的 Nginx,想了一下发觉是打了 CloudFlare 提供的兼容 SPDY 的补丁,才造成此问题。

我的解决方案就是重新编译安装 Nginx,不要那个鬼补丁了。如果你也遇到相似的问题,还是先看一下自己的 Nginx 版本是不是在 1.10.2 以下,要不要升级版本。新版本已经默认能正确接受 DATA 帧。

以上就是我遇到的问题和解决方法。造成相关问题的原因可能不止一种,如果你从别处过来却没能解决问题,还请继续探寻,或者在下方留言交流。

]]>
0 https://shansing.com/read/465/#comments https://shansing.com/feed/tag/JavaScript/
用 JavaScript 判断用户是否启用了请勿追踪(DNT) https://shansing.com/read/464/ https://shansing.com/read/464/ Thu, 19 Jul 2018 14:31:00 +0000 闪闪的星 在几年前我发布了《多语言判断用户是否启用了请勿追踪(Do Not Track)》一文,介绍了“请勿追踪”的功能,并引用资料给出了多种语言的代码,判断用户是否启用了该功能。在我洋洋得意给出 PHP 优化版的代码后,又介绍了 JavaScript 的方法,但并未实际给出代码,只是说非常麻烦,不建议使用。

今天,我在给这个博客加入统计代码时考虑了这个功能:当访客开启 DNT 时,本站不会插入统计代码;否则会有第三方统计脚本载入。于是又看了 DNT 的一些现状,回顾了那篇文章。我发现,尽管我那么说,但用 JavaScript 其实也是可以写的。当然,我的立场是一贯的:优先使用 HTTP 头(header)判断是最好的。JavaScript 的相关特性仍处于实验阶段。

JavaScript 代码

理想条件下

在理想条件下,我们可以直接这样写:

if(navigator.doNotTrack != '1'){
    // 用户没开启请勿追踪,做点什么
}

但正如我在那篇文章中所说,各家浏览器甚至各个版本,对 DNT 的实现千奇百怪。所以这样是不推荐的。

现实中

参考 MDN 英文版中文版太老了,尽管英文版也有点不符合事实,如 Edge 实际上已经使用 navigator.doNotTrack 而不是 window.doNotTrack 了),我写了这样一段:

var DNT = 'unspecified'; //default
if(typeof navigator.doNotTrack != 'undefined'){
    DNT = navigator.doNotTrack;
}else if(typeof window.doNotTrack != 'undefined'){
    DNT = window.doNotTrack;
}else if(typeof navigator.msDoNotTrack != 'undefined'){
    DNT = navigator.msDoNotTrack;
}  // DNT can be null, 'unspecified', '1', '0' 'yes' or 'no'
if(DNT == 'yes'){
    DNT = '1'; // Do not track
}else if(DNT == 'no'){
    DNT = '0'; // Please track
}

if(DNT != '1'){
    // 用户没开启请勿追踪,做点什么
}

如此一来就可照顾到各种属性实现、各种值的取法,是兼容性最好的。在 Firefox、Chrome、Edge 和 Internet Explorer 中初步测试通过。

精简版

似乎没有什么浏览器实现了/还留有“我被追踪”(DNT = 0)的选项。根据这个现状,再考虑浏览器实际的属性值,我们也可以缩成一句话:

if(!!navigator.doNotTrack && navigator.doNotTrack!='unspecified' || !!window.doNotTrack || !!navigator.msDoNotTrack){
    // 请勿追踪
}else{
    // 用户没开启请勿追踪,做点什么
}

本文可以结束了。下面只是一些参考信息。

参考信息

用户可以到 BrowserLeaks 判断自己浏览器的 DNT 开启情况。下面我会给出自己稍微测的一些结果。我这边竟然发现 Edge 不发送相关的 HTTP 头,IE 的头表现也有点怪异,不知是不是标签页的相互干扰,大家参考一下就行,我不敢保证正确性。

以下测试用的操作系统均为 Windows 10 (1803) 64 位,前面一节为浏览器启用 DNT,后面一节为禁用时的情况。

Firefox 61.0.1 (64 位)

DNT:✔ True (1)
navigator.doNotTrack:✔ True (1)
window.doNotTrack:× False (undefined)
navigator.msDoNotTrack:× False (undefined)

DNT:× False
navigator.doNotTrack:× False (unspecified)
window.doNotTrack:× False (undefined)
navigator.msDoNotTrack:× False (undefined)

Chrome 67.0.3396.99(正式版本) (64 位)

DNT:✔ True (1)
navigator.doNotTrack:✔ True (1)
window.doNotTrack:× False (undefined)
navigator.msDoNotTrack:× False (undefined)

DNT:× False
navigator.doNotTrack:× False (null)
window.doNotTrack:× False (undefined)
navigator.msDoNotTrack:× False (undefined)

Microsoft Edge 42.17134.1.0

可能有误,仅供参考。

DNT:× False
navigator.doNotTrack:✔ True (1)
window.doNotTrack:× False (undefined)
navigator.msDoNotTrack:× False (undefined)

DNT:× False
navigator.doNotTrack:× False (null)
window.doNotTrack:× False (undefined)
navigator.msDoNotTrack:× False (undefined)

Internet Explorer 11.165.17134.0

可能有误,仅供参考。

DNT:× False
navigator.doNotTrack:× False (undefined)
window.doNotTrack:✔ True (1)
navigator.msDoNotTrack:× False (undefined)

DNT:✔ True (1)
navigator.doNotTrack:× False (undefined)
window.doNotTrack:× False (null)
navigator.msDoNotTrack:× False (undefined)

2018-7-20 P.S.增加精简版代码。

]]>
0 https://shansing.com/read/464/#comments https://shansing.com/feed/tag/JavaScript/
2015 年网页设计潮流趋势会是? https://shansing.com/read/402/ https://shansing.com/read/402/ Sun, 01 Mar 2015 14:56:00 +0000 闪闪的星 网页设计是个充满活力以及时刻更新迭代的行业,变化和发展非常之快,就像时尚圈的宠儿,人们总是还来不及适应这这种设计,马上就会有新的设计更新出来。回顾 2014 年的网页设计,扁平化设计逐步走向成熟,卡片化、瀑布流、时间轴设计方式层出不穷。在此,笔者想斗胆分析及揣测一下网页设计的现状和 2015 年网页设计的趋势,如若有不足之处,还望大家勇敢地批评与指正。

一、自然摄影

2014 年许多网站启用精美的摄影图片为网站的首页,给人一种眼前一亮并充满无限美好的视觉享受,这无疑是一个好的趋势,个性化的摄影图片就像在宣誓着“do whatever you want”(做你想做的)的个性宣言。

“自然摄影”式网页设计示例

二、单色优势

更为大胆的设计师会采用单色平铺的设计方式,背景、按钮、图像叠加、类型,对一种颜色的独特偏好使其令人难以忘怀,更容易让人记住它并与相关的品牌颜色相连接。2014 年有许多网站采用这样一种设计方式,但是这种设计比较极端,要求设计师需要有较强的配色能力和大胆的想法,好的配色大胆的思维,能让网站标新立异,反之,就会给人感觉很俗。

“单色优势”式网页设计示例

三、视频背景

视频背景是描绘网站的情感倾诉与网站维护者用心的极致体验,相较于自然摄影图片更能抓住人们的心思,留住更多的用户,我们在 2014 年很快看到了这一上升趋势,相信到 2015 年会是一个真正的高峰。

“视频背景”式网页设计示例

四、独特的导航菜单

设计师也会在导航菜单上做足功夫,开辟新的路径给人浏览网站的用户新的视觉体验,向下滑动,向上滑动,弹出,影像,动画,更满足于用户在不同终端的浏览体验。

“独特的导航菜单”网页设计示例

五、卡片化瀑布流

在美国社交大佬 Pinterest 这种设计潮流的领导下,无论是博客,购物网站,分享导购网站等这种瀑布流的设计方式如雨后春笋一般冒出来,相信在 2015 年应该会有更多的效仿者与追随者吧。

“卡片化瀑布流”网页设计示例

六、全屏滚动

毋庸置疑我们将在 2015 看到更多的网页设计采用全屏的滚动方式,图文结合,超长的画幅,让用户不厌其烦的滚动手中鼠标的滚轮。有数据研究表明,相较于充满文字与链接,需要仔细阅读与不断点击切换而言,用户更愿意不断的滚动滚轮,浏览整个网站。

“全屏滚动”式网页设计示例

七、插图

插图一直是网页设计的一部分,简而明了的图形就能代替一长串的文字描述,越来越多的设计师愿意花时间绘制精美的图标,插图。这是一个图形化的时代,相信用插图的形式讲一个故事,来引导用户,会比纯文字的描述更吸引读者。

插图网页设计示例

八、互动之旅

互动的数字体验将会得到更多的运用,设计者运用多种交互方式:播放声音,连接摄像头和麦克风为用户创造独特的,个性化的体验。浏览网页不再只是你看着屏幕发呆,而会想游戏一样参与进来。

互动式网页设计示例

九、个人网站

越来越多的设计师,工程师,博客作者,企业家开始搭建自己的网站,创建个人的品牌,这将在网上变得更具权威性更容易被用户找到,我们将看到更多的网站侧重于个人品牌的展现。

个人网站设计示例

总体而言,我们将看到网页设计的发展趋势越来越大,但焦点仍然是在扁平化的设计、全屏的排版和高质量的图像上,这是令人激动的,相信2015年的设计将会给用户带来更加完美的视觉体验。

本文版权

参考文章:http://designmodo.com/web-design-trends-2015/ (英文)

转自:http://www.aips.me/web-design-trands-2015.html

]]>
6 https://shansing.com/read/402/#comments https://shansing.com/feed/tag/JavaScript/
多语言判断用户是否启用了请勿追踪(Do Not Track) https://shansing.com/read/291/ https://shansing.com/read/291/ Wed, 24 Jul 2013 11:05:00 +0000 闪闪的星 请勿追踪(英语:Do Not Track,简称 DNT,又译为“请勿跟踪”)是一项被提名的 HTTP 头。当用户提出启用“请勿追踪”功能后,具有“请勿追踪”功能的浏览器会在 http 数据传输中添加一个“头信息”(headers),这个头信息向商业网站的服务器表明用户不希望被追踪。这样,遵守该规则的网站就不会追踪用户的个人信息来用于更精准的在线广告。

自 Firefox 浏览器成为世界上首个支持“请勿追踪”功能的浏览器后,Internet Explorer(9及以上)、Safari 和 Opera 浏览器也相继添加了对“请勿追踪”功能的支持。最后 Chrome 23 浏览器也宣布支持此功能。当前,国内许多浏览器也已经支持了“请勿追踪”功能,甚至一些浏览器设置成默认启用。

那么,身为网站技术人员或开发者,我们若要遵守该规则,就需要判断用户的浏览器是否开启了这个功能。其思路为,判断 HTTP 头信息中是否含有 HTTP-DNT 参数。有的话,获得其值,进行判断,进而就能判断“请勿追踪”的开启与否了。

Donottrack.us 给我们提供了各种语言的完全代码:

ASP (VBScript)

<?
Dim DoNotTrackHeader
DoNotTrackHeader = "DNT"
Dim DoNotTrackValue
DoNotTrackValue = "1"

Dim aspHeader
aspHeader = "HTTP_" & UCase(Replace(DoNotTrackHeader, "-", "_"))

Dim headerValue
headerValue = Request.ServerVariables(aspHeader)

If (Not IsNull(headerValue)) And (headerValue = DoNotTrackValue) Then
   // 用户启用了请勿追踪
Else
   // 用户没有启用请勿追踪
End If
?>

Java Servlet / JSP

String DoNotTrackHeader = "DNT";
String DoNotTrackValue = "1";

// 需要用到 javax.servlet.http.HttpServletRequest
String headerValue = request.getHeader(DoNotTrackHeader);

if((headerValue != null) && headerValue.equals(DoNotTrackValue))
{
   // 用户启用了请勿追踪
}
else
{
   // 用户没有启用请勿追踪
}

Perl CGI

$DoNotTrackHeader = "DNT";
$DoNotTrackValue = "1";

$perlHeader = "HTTP_" . strtoupper(str_replace("-", "_", $DoNotTrackHeader));

if((exists $ENV{$perlHeader}) and ($ENV{$perlHeader} == $DoNotTrackValue))
{
   // 用户启用了请勿追踪
}
else
{
   // 用户没有启用请勿追踪
}

PHP

<?php
  $DoNotTrackHeader = "DNT";
  $DoNotTrackValue = "1";

  $phpHeader = "HTTP_" . strtoupper(str_replace("-", "_", $DoNotTrackHeader));

  if((array_key_exists($phpHeader, $_SERVER)) and ($_SERVER[$phpHeader] == $DoNotTrackValue))
  {
         // 用户启用了请勿追踪
  }

  else
  {
        // 用户没有启用请勿追踪
  }
?>

Python (Django)

DoNotTrackHeader = "DNT"
DoNotTrackValue = "1"

pyHeader = "HTTP_" + DoNotTrackHeader.replace("-", "_").upper()

# 需要 HttpRequest
if (pyHeader in request.META) and (request.META[pyHeader] == DoNotTrackValue):
   # 用户启用了请勿追踪
else:
   # 用户没有启用请勿追踪

PHP 优化版

可以看到,上面的代码还有很大的精简空间。

并且,Firefox 支持告诉网站“我想被追踪”,当用户使用这个选项时,浏览器会表示 HTTP_DNT: 0。加上这条信息,我们可以以 PHP 为例,得出优化版本:

<?php
/* 优化版开始 */
  if((array_key_exists('HTTP_DNT', $_SERVER))){
     switch($_SERVER['HTTP_DNT']){
        case '1':
          // 用户不愿被追踪!
          break;
        case '0':
          // 用户很愿意被追踪啊~
          break;
        default:
          // 用户意愿表达有误。
     }
  }else{
     // 用户没有表达意愿。
  }
/* 优化版结束 */
?>

至于其他语言的代码,我相信你们定能依葫芦画瓢的。我就不轻举妄动,乱装权威了。

JavaScript

至少我是找不到用 JavaScript 读出我想要的东西的方法了,其读出来的所谓的“HTTP 头信息”不完整,很多东西都是用其它属性实现的;JavaScript 的属性具有“多样性”——对,是不是有专门的属性来判断“请勿追踪”呢?我终于找到了这个属性,但还是让我大失所望。

首先,火狐运行这 JavaScript 代码,“我想被追踪”也会被判为“我不想被追踪”(这个 BUG 将在 32 版本中得到修复);其次,Chrome 似乎不支持该属性;再一个,navigator.doNotTrack 的值并不是 HTTP 请求中 do-not-track 请求头的值,然而 IE9、Opera 12和 Safari 5.1 遵循了旧规范,navigator.doNotTrack 的值就是 do-not-track 请求头中的值;最后,Internet Explorer 使用专门的 navigator.msDoNotTrack 属性。

因此在此就不列出代码了。若要实现我们想实现的,必定会要写一大坨的。建议不要使用 JavaScript 来判断,可以变通一下,使用上面 5 种语言的其中一种判断后,定义一个 JavaScript 变量,给它赋值,然后下面就能判断了。

当然,如果你实在有兴趣写一大坨代码,可以使用下面的链接了解这两个 JavaScript 属性:navigator.doNotTrack | navigator.msDoNotTrack

“请勿追踪”让互联网更美好

如果网站都遵守这个规则,互联网可以变得更美好。

英文好的人需要进一步了解的话,可以查看 donottrack.us维基百科的“请勿追踪”词条也是不错的选择,不过请看英文版,中文版的简介就是本文第一段。

2014-7-31 P.S.更新火狐 BUG 修正信息。

2018-7-19 P.S.有关 JavaScript 的实现可以阅读新文章《用 JavaScript 判断用户是否启用了请勿追踪(DNT)》。

]]>
12 https://shansing.com/read/291/#comments https://shansing.com/feed/tag/JavaScript/
微软:4个建立友好触控网站的指引 https://shansing.com/read/246/ https://shansing.com/read/246/ Fri, 15 Feb 2013 10:33:00 +0000 闪闪的星 (大家应该有了解过 Windows 8 与 Internet Explorer 10 的革命性改变吧?尤其是浏览器支持触控操作了。去年呢,微软撰文来指引我们如何“讨好”触控用户。那关于微软撰写的这篇文章呢,似乎只有一个概括版的中文版本,于是我运用我极不专业的英语水平给大家翻译全文过来了。欢迎批评指正!——译者注)

这篇文章提供了四个简单的指引,以确保您的触控用户可以最有效地使用您的网站。前两个确保触控用户可以访问您网站的全部功能,最后两个则使得触控更容易。

1.不要让悬浮物遮住了内容

鼠标可以悬停这些东西(指向它)而不用激活它(点击它)。然而,触控式屏幕的一次轻敲既是悬停动作也是激活动作。因此需要悬停的功能并不能为触控用户所用,取而代之地应该让用户能够使用点击(敲打)来完成操作。(说白了就是触控设备不能识别悬停这个动作。——译者注)

2.配置网站的触控手势

用户希望可以通过触控来平移与缩放网站。因而,用户的各种动作(如滑动、双指缩放、双击)可能会产生“相互作用”(指浏览器设置与网站功能的冲突。——译者注)。如果您的网站要提供特别的功能,您必须配置 IE10 只提供任何的您想要的默认行为。当用户触控了一个元素,-ms-touch-action 这个 CSS 属性决定了 IE10 提供的默认行为。

  -ms-touch-action: auto | none | manipulation | double-tap-zoom | inherit; 

下面这个表格描述了五个可能的值:

描述
auto 由浏览器决定元素的行为。这是 -ms-touch-action 的默认值。
none 任何默认行为都不被允许。
manipulation 只允许滑动、双指缩放和重击(panning, pinch zoom, and swiping)来前进、后退。
double-tap-zoom 只允许双击缩放(double-tap zooming)。
inherit 从父级继承 -ms-touch-action 属性值。

举个例子,一个帆布绘画的组件也许会这样使用:

canvas {
  -ms-touch-action: double-tap-zoom;
} 

在这个配置中,用户能双击缩放 canvas 元素,但是手指滑动却不会使得页面平移。

3.使用 HTML5 区分输入框类型

IE10 支持 HTML5 的输入控件,所有这一切都是触控优化。对于文本输入,可以通过区分特定的、适合的输入类型,进一步改善用户的触控体验。在 Windows 8 上,Internet Explorer 将显示一个专门定制的触控式键盘布局。

<input type="email"> 

触控式键盘会专门为输入电子邮箱显示“@”和“.com”按钮。

<input type="tel"> 

触控式键盘就会专门为输入电话号码显示小键盘。

<input type="url"> 

触控式键盘就会专门为输入网址而显示斜线和“.com”按钮。

(此文的中文概括版将此条说成“如果网站需要输入文字的,则设计者需要在触控屏中留足屏幕键盘的空间”,我认为是不对的,况且触控屏的键盘也不是让我们网页设计者设计的。——译者注)

4.为用户的手指留够空间

要建立 Windows 8 触控为上的体验,我们已经进行了大量的研究,为开发人员制定了一些有用的指引。一个手指的平均宽度为 11 毫米。(一张图还告诉我们婴儿的手指宽最小是 7 点几毫米,而一位篮球运动员的手指宽最大有 19 毫米以上。——译者注)如果要点击的目标变得更大,错失点击的机率就急速下降。

在理想的情况下,目标至少要有 11毫米(约 40px)的正方形,它周围至少要填充 2毫米(约 10px)。(可能是说把这些可点击的目标分隔开来。——译者注)

如果您只是想为触控用户而调整,可使用特征检测来检测一个在用触控设备的用户:(这应该是 Javascript 代码。——译者注)

if (navigator.msMaxTouchPoints && navigator.msMaxTouchPoints > 1) {
  // 支持 multi-touch(多点触控,又称多重触控、多点感应、多重感应。——译者注)
} 

超越这些基本知识

您可以做更多的事情,创造触控为上的极致体验。例如,您可以选择为触控用户优化,支持自定义的多点触控互动或手势。这里有一点链接来引导你:(这些链接请参见英文原文。——译者注。)

我们将在未来撰写更多关于这些方法的博文,来确保您的网站在 IE10 触控下工作得更好。

原作雅各布·罗西(Jacob Rossi)(Internet Explorer 程序经理)

翻译闪闪的星(转载时务必保留译文链接或译者主页链接)

2013-2-16 P.S.我进行了若干次的翻译修正。

]]>
18 https://shansing.com/read/246/#comments https://shansing.com/feed/tag/JavaScript/
jQuery 版解一元二次方程 https://shansing.com/read/220/ https://shansing.com/read/220/ Fri, 05 Oct 2012 09:53:00 +0000 闪闪的星 嗯,之前在 Visual Basic 上写过一个“解一元二次方程”的程序——虽然没有发表。之后也写了个 PHP 的,也没有发表。那么今天就发个 jQuery 版的“解一元二次方程”。

jQuery 版有什么优点呢?因为是网页程序,所以是跨系统的,只需浏览器就能执行;因为是采用 jQuery 技术,所以是实时计算的哦。话不多说,放出代码:

<h1>解一元二次方程 β</h1>
  <input type="text" id="ka" size="3" value="1">x<sup>2</sup>+<input type="text" id="kb" size="3" value="1">x+<input type="text" id="kc" size="3" value="0">=0<br>
   x<sub>1</sub>=<input type="text" id="kx1" size="25" readonly="something"><br>
   x<sub>2</sub>=<input type="text" id="kx2" size="25" readonly="duapp">
   <script>$(document).ready(function(){
function jie(){
   a = $("#ka")[0].value;
   b = $("#kb")[0].value;
   c = $("#kc")[0].value;
   delta = Math.pow(b,2) - 4 a c;
   if (delta > 0) {
       $("#kx1").val((-b + Math.sqrt(Math.pow(b,2) - 4 a c)) / (2 a));
       $("#kx2").val((-b - Math.sqrt(Math.pow(b,2) - 4 a c)) / (2 a));
   } else if (delta == 0) {
       $("#kx1").val(-b / (2 * a));
       $("#kx2").val("同上!");
   } else if (delta < 0) {
       $("#kx1").val("判别式小于0,方程无实数根!");
       $("#kx2").val("判别式的值为 " + delta);
   }
}
$("#ka").keyup(function(){jie();});
$("#kb").keyup(function(){jie();});
$("#kc").keyup(function(){jie();});
   });</script>
<!-- //shansing.com -->

注意,我没有进行 BUG 处理哦!更为重要的是,你需要自行载入 jQuery 库。效果演示猛击此

]]>
14 https://shansing.com/read/220/#comments https://shansing.com/feed/tag/JavaScript/