Fxd应用开发指南

Fxd 是什么

Fxd 是一个「AI和RPA脚本工具链」。它具有以下特点:

  1. 采用继承方式实现重用。所有的应用都直接或间接地继承自核心应用FxdApp,这样一些基础能力无需编码即可重用。当特定的应用满足不了你的需求,你可以简单地继承并进行修改来实现更细分的需求。
  2. 采用 NPM 管理版本和依赖。所有的应用都是一个标准的 NPM Package ,采用语义化版本命名。既可以通过 Fxd-Cli 工具在命令行调用,也可以在其他程序中作为库使用。

授权

Fxd (不包含Fxd Dektop)采用 PolyForm-Noncommercial 协议,允许非商业使用

Fxd 的扩展应用不受上述协议限制,由应用作者自行决定授权。

举个例子:开发者A开发了 Fxd 的扩展应用 B,并将其以MIT协议发布。那么其他用户可以在 MIT 协议下分发不包含 Fxd-cli / Fxd-app-core 的 B。但如果将 Fxd-cli / Fxd-app-core 等应用打包到 B 中,作为一个整体分发,则必须遵守 PolyForm-Noncommercial 协议。

SDK、CLI、Core和官方APP

Fxd的架构遵守以下逻辑:

  1. 工具类和基础功能封装到 SDK 中,如Websocket通信、KV操作、AI调用函数
  2. Core APP提供命令行、云端通信等基础能力
  3. 所有APP从Core继承,亦支持命令行的能力
  4. Browser处理浏览器自动化相关通用功能
  5. CheckChan和KeepLive是官方应用,扩展浏览器自动化的能力

SDK 主要功能

Core 主要功能

Browser 主要功能

Fxd 基础应用列表

基础应用可以通过命令行调用,也可以在代码中 import 来使用

浏览器基础应用 Fxd-app-browser

方法:

  1. getUserDirFullPath(username) // 获得某个用户的浏览器数据存储目录
  2. async getBrowserAndMore( userDirFullPath, options ) // 创建 Playwright 绑定的 Chrome 浏览器对象。返回 { browser, page, context }

页面检测基础应用 Fxd-app-check-chan

方法:

  1. async check(args, opts, command) // 监测网页指定的元素(通过selectors参数指定,支持多个),并返回监测结果。

  2. async watch(args, opts, command) // 先调用check返回监测结果,并针对检测结果判断是否更新,发送通知和发布Feed。

命令行参数说明:

command: watch: --sendkey 检测到变化时发送消息的发送键 --feed_publish 是否发布为RSS Feed (default: false) --feed_as_public Feed 是否公开 (default: true)

command: main|check|watch: --url 要打开的页面 URL (required) --headless 是否使用无头模式 (default: true) --selectors 要检测的元素的 CSS 选择器,多个用逗号分隔 (default: body) --prejs 页面加载完成后执行的自定义 JavaScript 代码 --prejs_args 自定义 JavaScript 代码的参数 --preplay 页面加载完成后执行的自定义 Playwright 代码 --timeout Playwright 操作超时时间,单位毫秒 (default: 60000) --list 选择器是否返回元素列表 (default: false) --user 浏览器使用的用户目录 (default: default) --format 返回的数据格式 (default: text) --wait_type 等待元素出现的方式 (default: domcontentloaded)

实现代码:

import FxdBrowser from '/Users/easy/Code/gitcode/fxd/packages/fxd-app-browser/index.js';
import { FxdSdk, getDirname } from 'fxd-sdk';
import { readPackageSync } from 'read-pkg';
import { diffString, diff } from 'json-diff';

export default class FxdCheckChan extends FxdBrowser {
   constructor() {
       super();
       this.sdk = new FxdSdk(readPackageSync({ cwd: getDirname(import.meta.url) }));
   }

   async main(args, opts, command) {
       return await this.check(args, opts, command);
   }

   async check(args, opts, command) {
       this.setDeaultOpts(opts);
       this.setDeaultCommand(command);
       this.format = this.get('format');
       
       const ret = [];
       let ret_texts = '';
       let ret_htmls = '';
       // 是否使用 headless 模式
       const headless = this.get('headless');

       // 要检测的元素 CSS 选择器,多个选择器用逗号分隔
       const selectors = this.get('selectors') ? this.get('selectors').split(',') : ['body'];
       // process.exit(0);
       
       // URL
       const url = this.get('url');
       if (!url) this.echoError("url is required");

       const { browser, page, context } = await this.getBrowserAndMore(this.getUserDirFullPath(this.get('user')), { headless });

       page.setDefaultTimeout(this.get('timeout')); // 设置超时
       await page.goto(url);// 打开URL
       // console.log('goto', url);
       await page.waitForLoadState( this.get('wait_type')); // 确保页面加载完成
       // console.log('waitForLoadState', 'networkidle end');

       // 执行自定义 playwirght 代码
       const preplay = this.get('preplay');
       if (preplay) {
           // 定义一个异步函数,用于执行动态代码
           const asyncFn = new Function('page', 'context', `return (async () => {${preplay}})();`);

           // 执行异步函数
           await asyncFn(page, context);
       }

       // 执行自定义的 js 代码
       // @Todo 这个地方需要测试下,是否可以正常执行
       const prejs = this.get('prejs');
       const prejsArgs = this.get('prejs_args');
       if (prejs) {
           await page.evaluate(prejs, prejsArgs);
       }

       // 循环 selectors 数组,获取每个元素的 HTML、innerText
       for (const selector of selectors) {
           let elements = await page.locator(selector).all();
           
           if (!this.get('list')) elements = [elements[0]];
           let htmls = [], texts = [];
           // 循环每个元素,push HTML、innerText
           for (const element of elements) {
               htmls.push(await element.innerHTML());
               texts.push(await element.innerText());
           }

           const html = htmls.join('\n');
           const text = texts.join('\n');

           ret.push({ selector, html, text, meta: { html: htmls, text: texts } });

           if (html) ret_htmls += '\n\n' + html;
           if (text) ret_texts += '\n\n' + text;
       }

       // 关闭浏览器
       // await context.close();
       await browser.close();

       // 输出中间数据
       this.log('检测到的数据', JSON.stringify(ret, null, 2));

       return this.return({
           merged_html: ret_htmls,
           merged_text: ret_texts,
           data: [...ret],
       })
   }

   async watch(args, opts, command) {
       const ret = await this.check(args, opts, 'check');
       // 借用 check 命令的参数设置
       const url = this.get('url');

       // 切换为 watch 命令的设置
       this.setDeaultCommand(command);

       // 获取上一次的结果
       const urlSHA = this.sdk.sha1(url);
       const lastRet = await this.sdk.getValue(urlSHA);
       // 保存本次结果
       await this.sdk.setValue(urlSHA, ret);

       if (!lastRet) {
           this.log("不存在历史记录,将当前结果保存为历史记录");
       } else {
           // 比较两次结果
           if (diff(lastRet, ret)) {
               const differenceString = diffString(lastRet, ret);
               const differenceText = diffString(lastRet, ret, { color: false });
               this.log("检测到变动", differenceString);

               // 如果 opts.sendkey 存在,则发送到指定的 key
               const sendkey = this.get('sendkey');
               if (sendkey) {
                   const send_ret = await this.sdk.scSend(`${this.sdk.displayName} 有新的动态`, `\`\`\`diff
${differenceText}
\`\`\``,
                       sendkey);
                   this.log("存在sendkey,发送结果到Server酱", send_ret);
               }

               // 如果 opts.feed_publish 存在,则发布到 feed
               const feed_publish = this.get('feed_publish');
               if (feed_publish) {
                   const feed_ret = await this.feedPublish(ret.merged_text, ret , this.get('feed_as_public'), command);

                   this.log("发布结果到feed列表", feed_ret);
               }
               

               return this.return({
                   "message": "检测到变动",
                   "diff": differenceText,
                   "action": "changed",
                   "data": ret
               });
           } else {
               this.log("目标数据没有变化");
               return this.return({ "message": "目标数据没有变化", "action": "unchanged" });
           }
       }
   }
}