<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Zhang Lixiang</title>
    <description>The latest articles on DEV Community by Zhang Lixiang (@zhangautomation).</description>
    <link>https://dev.to/zhangautomation</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3844803%2Fdb3e4a63-7ace-4648-865e-2a5d56e53117.jpg</url>
      <title>DEV Community: Zhang Lixiang</title>
      <link>https://dev.to/zhangautomation</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zhangautomation"/>
    <language>en</language>
    <item>
      <title>Sorting Bilibili videos with Python: a 20-result keyboard research sample</title>
      <dc:creator>Zhang Lixiang</dc:creator>
      <pubDate>Wed, 04 Nov 2026 11:47:44 +0000</pubDate>
      <link>https://dev.to/zhangautomation/sorting-bilibili-videos-with-python-a-20-result-keyboard-research-sample-2j9j</link>
      <guid>https://dev.to/zhangautomation/sorting-bilibili-videos-with-python-a-20-result-keyboard-research-sample-2j9j</guid>
      <description>&lt;p&gt;Half of the 20 videos in my Bilibili search sample had fewer than 1,000 views. Nine titles mentioned magnetic switches. Those two details mattered when deciding which videos to review: the results mixed different levels of reach and different kinds of keyboards.&lt;/p&gt;

&lt;p&gt;The search term was &lt;code&gt;机械键盘&lt;/code&gt;, Chinese for "mechanical keyboard". I exported the results as JSON and used Python to make a review list. The script below needs only the standard library.&lt;/p&gt;

&lt;p&gt;I developed the Bilibili Scraper used to collect this sample and sell it on Apify. You can also run the analysis on data you have permission to use, provided it has the same fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Collect a small sample
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/zlexang/bilibili-scraper-no-login/examples/bzhan-guan-jian-ci-sou-suo-shi-li-zui-duo-20tiao?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=bilibili_keyword_research" rel="noopener noreferrer"&gt;This example task&lt;/a&gt; searches for &lt;code&gt;机械键盘&lt;/code&gt;, sorts by relevance, and returns up to 20 video records. It doesn't use a proxy or fetch extra video details. You don't need a Bilibili login or cookies; you do need an Apify account to copy and run the task.&lt;/p&gt;

&lt;p&gt;The input is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"searchKeywords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"机械键盘"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxResultsPerKeyword"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"searchOrder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"totalrank"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enrichSearchResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includePopular"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"proxyConfiguration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"useApifyProxy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the task after signing in, change the keyword if needed, and run it. From the output page, select Export and download JSON as &lt;code&gt;keyword-demo-results.json&lt;/code&gt;. CSV and Excel exports are available too.&lt;/p&gt;

&lt;p&gt;The run on November 4, 2026 took about six seconds and returned 20 distinct video URLs. Each record had a title, creator name, view count, likes, favorites, and publication time. That timing describes one small run; it doesn't establish performance for other keywords or larger batches, which may hit rate limits.&lt;/p&gt;

&lt;p&gt;The tool costs $0.002 per video or creator profile, or $2 per 1,000 records, with platform usage included. At that rate, 20 video results cost $0.04. My own development run is not a customer purchase, and its rounded cost display is not evidence that the product is free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make a review list
&lt;/h2&gt;

&lt;p&gt;For this sample, I filtered out records with fewer than 1,000 views, then sorted the rest by favorites divided by views. The cutoff reduces the effect of very small denominators. It is a choice for this demonstration, not a general research standard.&lt;/p&gt;

&lt;p&gt;Save this script beside the JSON file and run it with Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keyword-demo-results.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;review&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;views&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;views&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;favorites&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;favorites&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;favorites&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;favorites&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;views&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;favorites&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;favorites&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;favorite_pct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;favorites&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;publishedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;publishedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;favorites&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;views&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8-sig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DictWriter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fieldnames&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;views&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;favorites&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;favorite_pct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;publishedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeheader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rows to review: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sample produces 10 rows in &lt;code&gt;review.csv&lt;/code&gt;. The first three are shown below. Creator names remain in Chinese so they match the source records. Bilibili calls creators "UP主"; here they are stored in &lt;code&gt;authorName&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Creator&lt;/th&gt;
&lt;th&gt;Views&lt;/th&gt;
&lt;th&gt;Favorites&lt;/th&gt;
&lt;th&gt;Favorites / views&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;键盘推荐2026&lt;/td&gt;
&lt;td&gt;156954&lt;/td&gt;
&lt;td&gt;3133&lt;/td&gt;
&lt;td&gt;2.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;搞机研玩社&lt;/td&gt;
&lt;td&gt;119813&lt;/td&gt;
&lt;td&gt;2311&lt;/td&gt;
&lt;td&gt;1.93%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;歪设长&lt;/td&gt;
&lt;td&gt;337386&lt;/td&gt;
&lt;td&gt;3818&lt;/td&gt;
&lt;td&gt;1.13%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These counts are a snapshot from the collection time. The percentage is calculated locally. It isn't an official Bilibili conversion rate, a measure of buying intent, or a sales estimate. It gives me an order in which to open the links.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review the videos behind the numbers
&lt;/h2&gt;

&lt;p&gt;For product research, I would open each URL and record the price range, keyboard layout, switch type, and use case being discussed. A title that says "review" or "tested" still needs checking against the video itself. Sponsorship also needs a separate check.&lt;/p&gt;

&lt;p&gt;The nine titles mentioning magnetic switches tell us that this search includes that category. They don't tell us its market share. Relevance-ranked search results are not a random sample, and 20 records are too few for a conclusion about the whole keyboard market.&lt;/p&gt;

&lt;p&gt;If the purpose is creator research, the CSV is only a starting point. Recent uploads, audience fit, and public contact information need separate review. I kept &lt;code&gt;publishedAt&lt;/code&gt; because an older video has had more time to accumulate views than a new one.&lt;/p&gt;

&lt;p&gt;For recent content, change &lt;code&gt;searchOrder&lt;/code&gt; to &lt;code&gt;pubdate&lt;/code&gt; and collect another sample. I haven't run that second query for this article. When combining searches, deduplicate by &lt;code&gt;bvid&lt;/code&gt; or URL before counting videos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Missing fields and collection limits
&lt;/h2&gt;

&lt;p&gt;Search results can have &lt;code&gt;null&lt;/code&gt; values for &lt;code&gt;coins&lt;/code&gt; and &lt;code&gt;shares&lt;/code&gt;. Missing is not zero. The optional &lt;code&gt;enrichSearchResults&lt;/code&gt; mode fetches extra details and makes more requests; this demonstration did not test that mode.&lt;/p&gt;

&lt;p&gt;The tool collects public video metadata. It does not download videos or collect comment text, members-only content, or other non-public material. Public visibility does not grant unrestricted collection, redistribution, or commercial-use rights. Check the platform's terms, your data permissions, and applicable law before collecting. Reduce the scope or stop when access is restricted; don't try to bypass verification challenges.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apify.com/zlexang/bilibili-scraper-no-login?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=bilibili_keyword_research" rel="noopener noreferrer"&gt;tool page&lt;/a&gt; lists its fields and input options. For a different research question, decide which fields you need and what you will check manually before increasing the result limit.&lt;/p&gt;

</description>
      <category>python</category>
      <category>data</category>
    </item>
    <item>
      <title>The “quick change” that quietly breaks a freelance project</title>
      <dc:creator>Zhang Lixiang</dc:creator>
      <pubDate>Tue, 03 Nov 2026 16:00:35 +0000</pubDate>
      <link>https://dev.to/zhangautomation/the-quick-change-that-quietly-breaks-a-freelance-project-2hg2</link>
      <guid>https://dev.to/zhangautomation/the-quick-change-that-quietly-breaks-a-freelance-project-2hg2</guid>
      <description>&lt;p&gt;Scope creep rarely starts with a dramatic request. It’s often one extra field, one more version, or another review round. The request itself isn’t the problem; the problem is doing it before both sides agree what changes.&lt;/p&gt;

&lt;p&gt;A simple habit helps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check the request against the latest approved scope. Separate a clarification or correction from a new deliverable or revision.&lt;/li&gt;
&lt;li&gt;Name the change plainly: what’s being added, how many revisions it involves, and whether it affects timing or dependencies.&lt;/li&gt;
&lt;li&gt;Offer clear options: swap it for something already in scope, add it for an agreed fee and schedule, or move it to a later phase.&lt;/li&gt;
&lt;li&gt;Get written approval before starting, then update the project record.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A reply could be:&lt;/p&gt;

&lt;p&gt;“Thanks for asking. I checked this against [scope/version]. [Request] is outside [agreed item]. I can swap it for [item] within the current plan, add it for [fee] and move delivery to [date], or put it in a later phase. Which works best? I’ll update the plan once we agree.”&lt;/p&gt;

&lt;p&gt;This isn’t about turning every small request into bureaucracy. It’s about making the trade-off clear before either person is surprised.&lt;/p&gt;

&lt;p&gt;Disclosure: I sell a project-boundaries toolkit on Gumroad. Its free 2-page sample includes a client email, a change-request record, and a checklist. It’s an educational workflow aid, not a contract or legal advice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://gum.co/u/hyubh7ye" rel="noopener noreferrer"&gt;https://lexang.gumroad.com/l/ifqwwp&lt;/a&gt;&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>management</category>
      <category>productivity</category>
    </item>
    <item>
      <title>My agent ran prisma migrate reset. Here's the hook I wrote so it can't again</title>
      <dc:creator>Zhang Lixiang</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:17:18 +0000</pubDate>
      <link>https://dev.to/zhangautomation/my-agent-ran-prisma-migrate-reset-heres-the-hook-i-wrote-so-it-cant-again-4729</link>
      <guid>https://dev.to/zhangautomation/my-agent-ran-prisma-migrate-reset-heres-the-hook-i-wrote-so-it-cant-again-4729</guid>
      <description>&lt;p&gt;Last week I wrote about pgbrake, an MCP server that makes an agent's SQL writes undoable. People pointed out the obvious hole: agents don't only touch the database through SQL. They also run terminal commands, and &lt;code&gt;prisma migrate reset --force&lt;/code&gt; wipes everything without a single SQL statement going through the MCP server.&lt;/p&gt;

&lt;p&gt;pgbrake 0.2.0 adds a Claude Code PreToolUse hook for that. It watches Bash commands for migration tools doing destructive things: &lt;code&gt;prisma migrate reset --force&lt;/code&gt;, &lt;code&gt;drizzle-kit push --force&lt;/code&gt;, &lt;code&gt;alembic downgrade base&lt;/code&gt; and similar. When it sees one, it takes a pg_dump snapshot first (if PGBRAKE_DSN is set), then stops and asks you.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pgbrake install-hook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;That's the whole setup.&lt;/p&gt;

&lt;p&gt;The hook is part of Pro, which is $29 one-time for the first 100 licenses, then $39. No subscription, 14-day refund, and the key is checked offline, so pgbrake never phones home. Pro also lifts the free tier's 20-change undo limit and adds &lt;code&gt;pgbrake log --export csv|json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The SQL side (undo, approvals, audit log) stays free and MIT.&lt;/p&gt;

&lt;p&gt;What it doesn't do yet: Codex hooks, a policy file for custom rules, and online key revocation. If one of those would decide whether you'd use it, tell me which.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/lexang/pgbrake" rel="noopener noreferrer"&gt;https://github.com/lexang/pgbrake&lt;/a&gt;&lt;br&gt;
&lt;a href="https://lexang.github.io/#pricing" rel="noopener noreferrer"&gt;https://lexang.github.io/#pricing&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>ai</category>
      <category>claude</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Sorting website form enquiries with an LLM that can only answer from a list</title>
      <dc:creator>Zhang Lixiang</dc:creator>
      <pubDate>Thu, 29 Oct 2026 02:01:56 +0000</pubDate>
      <link>https://dev.to/zhangautomation/sorting-website-form-enquiries-with-an-llm-that-can-only-answer-from-a-list-36p2</link>
      <guid>https://dev.to/zhangautomation/sorting-website-form-enquiries-with-an-llm-that-can-only-answer-from-a-list-36p2</guid>
      <description>&lt;p&gt;Contact forms collect everything: real sales leads, support questions, partnership pitches, job applications and a steady drip of spam. Someone has to read each one and decide where it goes. An LLM can do that sorting, but if you let it answer freely you get categories like "Potential Sales Inquiry (High Value)" one day and "sales lead" the next, and your spreadsheet filters stop working.&lt;/p&gt;

&lt;p&gt;So I built a small n8n workflow where the model gets a fixed list of category ids and may answer with one of them or &lt;code&gt;null&lt;/code&gt;. Anything else is treated as a failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it runs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Form POST -&amp;gt; Webhook (name, email, company, message)
  -&amp;gt; Normalize; no valid email -&amp;gt; HTTP 400, nothing logged
  -&amp;gt; LLM sees: category ids + one-line descriptions, and the enquiry
  -&amp;gt; Code node: is the reply exactly one of my ids?
       yes -&amp;gt; that category
       no (null, unknown id, a sentence, API error) -&amp;gt; needs_human
  -&amp;gt; Append one row to Google Sheets
  -&amp;gt; Webhook answers 200 with the category
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The category list lives in one Code node called &lt;em&gt;Settings (edit me)&lt;/em&gt;, something like &lt;code&gt;sales_inquiry&lt;/code&gt;, &lt;code&gt;support&lt;/code&gt;, &lt;code&gt;partnership&lt;/code&gt;, &lt;code&gt;job_application&lt;/code&gt;, &lt;code&gt;spam&lt;/code&gt;, &lt;code&gt;other&lt;/code&gt;, each with a short description and a priority. The prompt and the check both read from that node, so you can't end up with a prompt that mentions a category the check doesn't know.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check is a few lines of code
&lt;/h2&gt;

&lt;p&gt;The model's reply gets trimmed, lowercased, and stripped of quotes and a trailing full stop. &lt;code&gt;"Partnership."&lt;/code&gt; therefore still counts as &lt;code&gt;partnership&lt;/code&gt;. After that it has to match an id exactly. A reply like "This looks like a partnership request" fails, even though it contains a valid id, because I don't want to guess what the model meant. It goes to &lt;code&gt;needs_human&lt;/code&gt; with the reason written in a &lt;code&gt;check_notes&lt;/code&gt; column.&lt;/p&gt;

&lt;p&gt;An HTTP 500 from the model API goes down the same path. The webhook still answers 200 and the row is still written, so a flaky API doesn't lose enquiries.&lt;/p&gt;

&lt;p&gt;One more thing that tripped me up in an earlier workflow: form text can contain &lt;code&gt;{name}&lt;/code&gt; or &lt;code&gt;{{ $json.x }}&lt;/code&gt;, and n8n's LangChain nodes treat braces as template syntax. The message needs escaping before it goes into the prompt. In this workflow, text with braces is stored in the sheet exactly as sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;I ran it on a real n8n 1.123.87 instance with local stand-ins for the OpenAI and Google Sheets APIs, and 17 checks passed: correct categories logged for sales and support enquiries, form-encoded and JSON bodies, &lt;code&gt;null&lt;/code&gt; / invented id / sentence / HTTP 500 all ending up as &lt;code&gt;needs_human&lt;/code&gt;, braces surviving, missing or invalid emails getting a 400 with no row and no model call, exactly one row per valid request, and no other hosts contacted.&lt;/p&gt;

&lt;p&gt;What I haven't tested: a real model and a real sheet. The checks show that a correct answer gets through and a bad one doesn't, not how well a given model sorts your enquiries. The webhook also has no authentication or dedupe, so add those before you put it on a public form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;p&gt;This workflow is in a free bundle with two others (a WhatsApp FAQ bot that can only send stored answers, and a B2B cold-email drafter that never sends anything): &lt;a href="https://lexang.gumroad.com/l/cqtfxr" rel="noopener noreferrer"&gt;3 free n8n AI workflows&lt;/a&gt;, pay what you want, $0 is fine.&lt;/p&gt;

&lt;p&gt;If you need the WhatsApp side done properly, with signature checks, dedupe, opt-out and human handoff, the full kit is &lt;a href="https://lexang.gumroad.com/l/ecvlr" rel="noopener noreferrer"&gt;$19&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>ai</category>
      <category>automation</category>
      <category>llm</category>
    </item>
    <item>
      <title>pgbrake: an undo button for Postgres when an AI agent is driving</title>
      <dc:creator>Zhang Lixiang</dc:creator>
      <pubDate>Wed, 28 Oct 2026 03:52:37 +0000</pubDate>
      <link>https://dev.to/zhangautomation/pgbrake-an-undo-button-for-postgres-when-an-ai-agent-is-driving-33g2</link>
      <guid>https://dev.to/zhangautomation/pgbrake-an-undo-button-for-postgres-when-an-ai-agent-is-driving-33g2</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh1plesunmh3s1n715h0i.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh1plesunmh3s1n715h0i.gif" alt="pgbrake demo" width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I've started letting Claude Code and Codex work against my dev databases, and the part that worried me was the database itself. Claude Code checkpoints rewind files. A bad DELETE stays done. Permission prompts help, but they live in the client, and an agent can always reach for psql instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/lexang/pgbrake" rel="noopener noreferrer"&gt;pgbrake&lt;/a&gt; is a small MCP server the agent uses in place of direct database access. Every statement goes through the real Postgres parser (pglast). Normal writes run and return a change id. Before an UPDATE or DELETE it saves the affected rows locally, so &lt;code&gt;pgbrake undo &amp;lt;id&amp;gt;&lt;/code&gt; puts them back, and it refuses if those rows changed since.&lt;/p&gt;

&lt;p&gt;DDL, UPDATE/DELETE without WHERE, and anything touching more than 100 rows wait for a human: the client's own prompt, or &lt;code&gt;pgbrake approve &amp;lt;id&amp;gt;&lt;/code&gt; in a terminal. That command refuses to run without a TTY, so the agent can't approve its own SQL. Every statement and decision lands in a local audit log. Nothing leaves your machine.&lt;/p&gt;

&lt;p&gt;MIT, Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;pgbrake
claude mcp add pgbrake &lt;span class="nt"&gt;--env&lt;/span&gt; &lt;span class="nv"&gt;PGBRAKE_DSN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"postgresql://me@localhost/app"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; uvx pgbrake serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Limits: undo only covers changes made through pgbrake. TRUNCATE and DDL can be held but not undone. It doesn't replace backups. I've only tested on Postgres 17 so far.&lt;/p&gt;

&lt;p&gt;I'd like to hear where it breaks. I'm also thinking about a paid add-on that catches migration commands like &lt;code&gt;prisma migrate reset --force&lt;/code&gt; (snapshot first, then ask). Would that be worth a one-time $29 to you?&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/lexang/pgbrake" rel="noopener noreferrer"&gt;https://github.com/lexang/pgbrake&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>ai</category>
      <category>mcp</category>
      <category>opensource</category>
    </item>
    <item>
      <title>n8n orchestrates, LangGraph writes, a human approves: an AI cold-email pipeline that can't spam</title>
      <dc:creator>Zhang Lixiang</dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:11:37 +0000</pubDate>
      <link>https://dev.to/zhangautomation/n8n-orchestrates-langgraph-writes-a-human-approves-an-ai-cold-email-pipeline-that-cant-spam-2o8j</link>
      <guid>https://dev.to/zhangautomation/n8n-orchestrates-langgraph-writes-a-human-approves-an-ai-cold-email-pipeline-that-cant-spam-2o8j</guid>
      <description>&lt;p&gt;"AI SDR" demos usually end with an agent firing off a few hundred emails. In real B2B sales that burns your domain within a week, and the emails are full of claims nobody can back up.&lt;/p&gt;

&lt;p&gt;I built a lead-generation pipeline around an export manufacturer's use case, with three rules. Every claim in an email has to come from data the APIs returned, quoted verbatim. A rule checker rejects bad drafts and sends them back for a rewrite, at most twice. And nothing is sent until a person clicks Approve.&lt;/p&gt;

&lt;p&gt;The part I found most useful was deciding what goes in n8n and what goes in LangGraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  n8n does the plumbing
&lt;/h2&gt;

&lt;p&gt;The n8n side is 55 nodes and handles everything deterministic: manual, schedule and webhook triggers; Apollo to find people and enrich emails; Hunter to verify them; dedupe against the CRM and qualification filters; HubSpot or a Google Sheet as the CRM; an approval link per draft, an unsubscribe link, and a suppression list that is checked again right before sending. Sending goes through SendGrid, Gmail or SMTP, behind a &lt;code&gt;SEND_ENABLED&lt;/code&gt; flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  LangGraph does the parts that need a model
&lt;/h2&gt;

&lt;p&gt;A small FastAPI service runs the graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;research -&amp;gt; write -&amp;gt; check -&amp;gt; (rewrite, max 2) -&amp;gt; draft for approval
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The research step writes a short note about the company. Every reason in it must carry a verbatim quote from the lead data, otherwise it's cleared. The write step drafts the email from that note and nothing else.&lt;/p&gt;

&lt;p&gt;The check step is plain code. It blocks numbers that don't appear in the research or the approved facts, unsupported claim phrases, placeholders and empty fields, a missing unsubscribe line, and drafts that run too long. A failing draft goes back to a rewrite step with the checker's reasons. After two failed rewrites it's flagged &lt;code&gt;needs_human_fix&lt;/code&gt; and never sent.&lt;/p&gt;

&lt;p&gt;The model can be any OpenAI-compatible endpoint: OpenAI, DeepSeek, Qwen, OpenRouter.&lt;/p&gt;

&lt;p&gt;I made the checker code rather than a second LLM because an LLM grading an LLM tends to agree with itself. A regex and a list of allowed numbers don't. It's dull, and it's the reason I'd trust the drafts enough to put them in front of a salesperson.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching industries
&lt;/h2&gt;

&lt;p&gt;Target industry, buyer type, product lines and selling points live in one profile file, and a small script turns it into the n8n variables. The kit includes a battery-storage example and an LED-lighting example.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;There are 21 pytest tests for the LangGraph service and 8 JS tests for the n8n Code nodes. A live run on a real n8n instance (1.123.87) with local mock Apollo, Hunter, HubSpot and SendGrid passed 26 of 26 checks. Ten fake leads end to end gave 6 drafts waiting for approval, 2 disqualified, 1 deduped and 1 blocked by the unsubscribe list.&lt;/p&gt;

&lt;p&gt;Those runs used mocked vendor APIs and a deterministic fake model, so I haven't seen how drafts look with a real model and real Apollo data. You bring your own Apollo, Hunter, HubSpot, SendGrid and LLM keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  The template
&lt;/h2&gt;

&lt;p&gt;The full kit (importable n8n workflow, LangGraph + FastAPI service, Docker Compose, tests, sample profiles) is $29 on Gumroad: &lt;a href="https://lexang.gumroad.com/l/lwnqd" rel="noopener noreferrer"&gt;B2B AI Lead Generation: n8n + LangGraph template&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I also wrote about a WhatsApp bot where the LLM may only pick an FAQ id. There's a free version in the n8n template library (&lt;a href="https://n8n.io/workflows/21700" rel="noopener noreferrer"&gt;workflow 21700&lt;/a&gt;) and a full &lt;a href="https://lexang.gumroad.com/l/ecvlr" rel="noopener noreferrer"&gt;kit for $19&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>langgraph</category>
      <category>ai</category>
      <category>automation</category>
    </item>
    <item>
      <title>How I stopped my n8n WhatsApp bot from inventing prices (the "LLM picks an ID" pattern)</title>
      <dc:creator>Zhang Lixiang</dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:06:50 +0000</pubDate>
      <link>https://dev.to/zhangautomation/how-i-stopped-my-n8n-whatsapp-bot-from-inventing-prices-the-llm-picks-an-id-pattern-4p1j</link>
      <guid>https://dev.to/zhangautomation/how-i-stopped-my-n8n-whatsapp-bot-from-inventing-prices-the-llm-picks-an-id-pattern-4p1j</guid>
      <description>&lt;p&gt;Most "AI WhatsApp bot" tutorials pass the customer's message to an LLM and send back whatever it writes. Fine for a demo. For a real small business it's a liability, because the model will quote a price, a warranty or an opening hour that doesn't exist.&lt;/p&gt;

&lt;p&gt;I built a WhatsApp FAQ bot for small service businesses (the sample data is an aircon-servicing company) with one rule: the LLM never writes the reply. It only gets to pick which stored answer to send.&lt;/p&gt;

&lt;h2&gt;
  
  
  The LLM picks an ID
&lt;/h2&gt;

&lt;p&gt;The FAQ lives in a table, a Google Sheet or a CSV, with &lt;code&gt;id, question, keywords, answer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every message first goes through plain keyword scoring. Most messages ("how much for a general service?") match without any AI, which is fast and costs nothing. Only when nothing matches does the bot send the question plus the list of FAQ ids and questions to the LLM, asking for one id or &lt;code&gt;null&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;p&gt;The response is then checked. If it isn't exactly a known id, it counts as unknown. A valid id means the stored answer goes out word for word. Unknown, &lt;code&gt;null&lt;/code&gt;, free text or an API error all trigger a human handoff: the customer gets one "a staff member will reply" message, the owner gets a WhatsApp alert, and the bot stays quiet in that chat until someone resumes it.&lt;/p&gt;

&lt;p&gt;So the worst case is a human answering a question the bot could maybe have handled. I'll take that over a made-up price.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the flow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer message -&amp;gt; Webhook (GET verify / POST messages)
  -&amp;gt; X-Hub-Signature-256 HMAC check (bad signature -&amp;gt; 401, nothing processed)
  -&amp;gt; Dedupe on message id (Meta retries webhooks)
  -&amp;gt; STOP / START opt-out handling
  -&amp;gt; Chat in human handoff? -&amp;gt; stay silent
  -&amp;gt; Asked for "agent / human" or sent a photo/voice note? -&amp;gt; handoff
  -&amp;gt; FAQ match (keywords) -&amp;gt; fallback: LLM picks an FAQ id
  -&amp;gt; Reply via Graph API POST /{phone_number_id}/messages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some things that bit me:&lt;/p&gt;

&lt;p&gt;Check the signature before anything else. Meta signs every webhook with your app secret, and in n8n you need the raw body for the HMAC, so turn on the raw body option in the Webhook node.&lt;/p&gt;

&lt;p&gt;Dedupe on the message id. Meta retries when you're slow, and without dedupe a customer gets the same answer two or three times.&lt;/p&gt;

&lt;p&gt;Handle STOP and START with a confirmation message. Opted-out chats get nothing after that, reminders included.&lt;/p&gt;

&lt;p&gt;I kept the Code node logic in separate &lt;code&gt;.js&lt;/code&gt; files and generated the workflow JSON from them, so the logic could be unit tested outside n8n. The workflow then passed a 16/16 live run on a real n8n instance against a mocked Graph API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Booking reminders
&lt;/h2&gt;

&lt;p&gt;The same workflow can send an approved WhatsApp template to customers whose booking starts within 24 hours, once per booking, skipping anyone who opted out. Outside the 24-hour customer-service window WhatsApp only allows template messages, so it has to be a template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;A free, smaller version (keyword matching plus the OpenAI id-picking fallback) is in the official n8n template library: &lt;a href="https://n8n.io/workflows/21700" rel="noopener noreferrer"&gt;Answer WhatsApp FAQs with keyword matching and an OpenAI fallback&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The full kit adds signature checks, dedupe, opt-out, handoff and reminders, plus an equivalent Python FastAPI service (SQLite state, 31 pytest tests), a Docker + Caddy HTTPS setup and a step-by-step Meta Cloud API guide. It's $19 on Gumroad: &lt;a href="https://lexang.gumroad.com/l/ecvlr" rel="noopener noreferrer"&gt;WhatsApp AI Customer Service Bot kit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For B2B outreach there's also an n8n + LangGraph lead-generation template where nothing is sent until you click Approve: &lt;a href="https://lexang.gumroad.com/l/lwnqd" rel="noopener noreferrer"&gt;B2B AI Lead Generation kit, $29&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The id-picking trick isn't specific to WhatsApp. I'd use it for ticket routing or intent classification too: anywhere you can limit the model's output to a set you can check.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>ai</category>
      <category>whatsapp</category>
    </item>
  </channel>
</rss>
