yuno-proofreader 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +435 -0
  5. data/exe/yuno-proofreader +16 -0
  6. data/lib/yuno/proofreader/analysis/adjustments.rb +45 -0
  7. data/lib/yuno/proofreader/analysis/crawler.rb +95 -0
  8. data/lib/yuno/proofreader/analysis/entries.rb +73 -0
  9. data/lib/yuno/proofreader/analysis/extractor.rb +209 -0
  10. data/lib/yuno/proofreader/analysis/graph.rb +115 -0
  11. data/lib/yuno/proofreader/analysis/judge.rb +103 -0
  12. data/lib/yuno/proofreader/analysis/judge.yml +27 -0
  13. data/lib/yuno/proofreader/analysis/judge_prompt.rb +72 -0
  14. data/lib/yuno/proofreader/analysis/judgments.rb +70 -0
  15. data/lib/yuno/proofreader/analysis/links.rb +54 -0
  16. data/lib/yuno/proofreader/analysis/list_order.rb +33 -0
  17. data/lib/yuno/proofreader/analysis/reasons.rb +49 -0
  18. data/lib/yuno/proofreader/analysis/report.rb +110 -0
  19. data/lib/yuno/proofreader/analysis/runner.rb +171 -0
  20. data/lib/yuno/proofreader/analysis/scorer.rb +128 -0
  21. data/lib/yuno/proofreader/analysis/settings.rb +90 -0
  22. data/lib/yuno/proofreader/analysis/signals.rb +102 -0
  23. data/lib/yuno/proofreader/analysis/structured_data.rb +57 -0
  24. data/lib/yuno/proofreader/analysis/url_tree.rb +49 -0
  25. data/lib/yuno/proofreader/analysis/weights.rb +77 -0
  26. data/lib/yuno/proofreader/analysis/weights.yml +39 -0
  27. data/lib/yuno/proofreader/audit/agenda.rb +86 -0
  28. data/lib/yuno/proofreader/audit/agenda_report.rb +65 -0
  29. data/lib/yuno/proofreader/audit/answers.rb +74 -0
  30. data/lib/yuno/proofreader/audit/attempt.rb +69 -0
  31. data/lib/yuno/proofreader/audit/batch.rb +64 -0
  32. data/lib/yuno/proofreader/audit/batch_report.rb +87 -0
  33. data/lib/yuno/proofreader/audit/cadence.rb +67 -0
  34. data/lib/yuno/proofreader/audit/instructions.rb +30 -0
  35. data/lib/yuno/proofreader/audit/log_fields.rb +28 -0
  36. data/lib/yuno/proofreader/audit/prompt.rb +157 -0
  37. data/lib/yuno/proofreader/audit/prompts/instructions.yml +46 -0
  38. data/lib/yuno/proofreader/audit/prompts/steps.yml +67 -0
  39. data/lib/yuno/proofreader/audit/report.rb +153 -0
  40. data/lib/yuno/proofreader/audit/runner.rb +144 -0
  41. data/lib/yuno/proofreader/audit/steps.rb +56 -0
  42. data/lib/yuno/proofreader/cli/analysis_commands.rb +43 -0
  43. data/lib/yuno/proofreader/cli/audit_commands.rb +92 -0
  44. data/lib/yuno/proofreader/cli/command_log.rb +42 -0
  45. data/lib/yuno/proofreader/cli/flags.rb +48 -0
  46. data/lib/yuno/proofreader/cli/init_command.rb +116 -0
  47. data/lib/yuno/proofreader/cli/model_commands.rb +34 -0
  48. data/lib/yuno/proofreader/cli/queue_commands.rb +89 -0
  49. data/lib/yuno/proofreader/cli/sheet_commands.rb +57 -0
  50. data/lib/yuno/proofreader/cli.rb +218 -0
  51. data/lib/yuno/proofreader/config.rb +25 -0
  52. data/lib/yuno/proofreader/context.rb +55 -0
  53. data/lib/yuno/proofreader/env_file.rb +76 -0
  54. data/lib/yuno/proofreader/errors.rb +17 -0
  55. data/lib/yuno/proofreader/findings/finding.rb +95 -0
  56. data/lib/yuno/proofreader/findings/matching.rb +34 -0
  57. data/lib/yuno/proofreader/findings/merger.rb +43 -0
  58. data/lib/yuno/proofreader/findings/outline.rb +73 -0
  59. data/lib/yuno/proofreader/findings/plan.rb +24 -0
  60. data/lib/yuno/proofreader/findings/reconciler.rb +219 -0
  61. data/lib/yuno/proofreader/findings/source.rb +79 -0
  62. data/lib/yuno/proofreader/findings/text.rb +37 -0
  63. data/lib/yuno/proofreader/http/client.rb +131 -0
  64. data/lib/yuno/proofreader/http/net_http_transport.rb +55 -0
  65. data/lib/yuno/proofreader/http/robots.rb +72 -0
  66. data/lib/yuno/proofreader/init/ask.rb +67 -0
  67. data/lib/yuno/proofreader/init/detector.rb +100 -0
  68. data/lib/yuno/proofreader/init/interview.rb +210 -0
  69. data/lib/yuno/proofreader/init/parsing.rb +47 -0
  70. data/lib/yuno/proofreader/init/web.rb +33 -0
  71. data/lib/yuno/proofreader/llm/client.rb +140 -0
  72. data/lib/yuno/proofreader/llm/defaults.rb +22 -0
  73. data/lib/yuno/proofreader/llm/defaults.yml +6 -0
  74. data/lib/yuno/proofreader/llm/errors.rb +26 -0
  75. data/lib/yuno/proofreader/llm/factory.rb +45 -0
  76. data/lib/yuno/proofreader/llm/http_transport.rb +41 -0
  77. data/lib/yuno/proofreader/llm/json_text.rb +23 -0
  78. data/lib/yuno/proofreader/llm/model_commands.rb +34 -0
  79. data/lib/yuno/proofreader/llm/provider.rb +58 -0
  80. data/lib/yuno/proofreader/llm/providers/gemini.rb +146 -0
  81. data/lib/yuno/proofreader/llm/providers.rb +38 -0
  82. data/lib/yuno/proofreader/llm/types.rb +13 -0
  83. data/lib/yuno/proofreader/llm/usage.rb +17 -0
  84. data/lib/yuno/proofreader/log.rb +129 -0
  85. data/lib/yuno/proofreader/pages/fetcher.rb +75 -0
  86. data/lib/yuno/proofreader/pages/report.rb +45 -0
  87. data/lib/yuno/proofreader/pages/snapshot.rb +48 -0
  88. data/lib/yuno/proofreader/pages/trimmer.rb +36 -0
  89. data/lib/yuno/proofreader/settings_file.rb +51 -0
  90. data/lib/yuno/proofreader/site.rb +74 -0
  91. data/lib/yuno/proofreader/site_reader.rb +131 -0
  92. data/lib/yuno/proofreader/sitemap/aligner.rb +78 -0
  93. data/lib/yuno/proofreader/sitemap/importer.rb +31 -0
  94. data/lib/yuno/proofreader/sitemap/loader.rb +127 -0
  95. data/lib/yuno/proofreader/sitemap/mismatch.rb +76 -0
  96. data/lib/yuno/proofreader/sitemap/pairer.rb +223 -0
  97. data/lib/yuno/proofreader/sitemap/parser.rb +73 -0
  98. data/lib/yuno/proofreader/sitemap/report.rb +80 -0
  99. data/lib/yuno/proofreader/store/cells.rb +67 -0
  100. data/lib/yuno/proofreader/store/google_gateway.rb +198 -0
  101. data/lib/yuno/proofreader/store/memory.rb +90 -0
  102. data/lib/yuno/proofreader/store/records.rb +28 -0
  103. data/lib/yuno/proofreader/store/schema.rb +109 -0
  104. data/lib/yuno/proofreader/store/sheets.rb +183 -0
  105. data/lib/yuno/proofreader/store/tab_setup.rb +74 -0
  106. data/lib/yuno/proofreader/version.rb +7 -0
  107. data/lib/yuno/proofreader.rb +101 -0
  108. data/proofreader.example.yml +134 -0
  109. metadata +194 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2b9c1ba0d2da16cc90dd1280f5c90ed7174c16402def0aa09707495d0bccd0a2
4
+ data.tar.gz: 3c174ff4a4cf13f8864a1b10ff09374d488b4b14dd818569b1926a091c77b56d
5
+ SHA512:
6
+ metadata.gz: cfa0b61cd10747628ce5dce680d3c2b1b01daaaeeb706ec936b80c71e576b7a581c5c43e0c86ed3254a8163b2efa9654806ff6877eca7e23b42b9b2bfdd8dd8c
7
+ data.tar.gz: e99babee2e1dae3e11f808048647fb6bd6b972e16ffe7f922c6f8bcefc5d1054093bb047f87fa8f834bdb21af00dc934792934aca9e1aa040be1f8ddafd48ad6
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0]
4
+ 2026/10/26
5
+
6
+ First release. It is early, and details may still change before 1.0.
7
+
8
+ - Reads your sitemap and language versions, and helps you set up your settings file.
9
+ - Ranks your pages by importance and works out which are due for another audit, so the most important ones are checked most often.
10
+ - Audits the pages of a website with an AI model (Gemini) and an independent second review, and lists the suggested fixes in a Google Sheet for manual review.
11
+ - Checks that every suggestion quotes text that is really on the page, and closes suggestions whose text is gone from the live page.
12
+
13
+ [0.1.0]: https://gitlab.com/alsen-ca/yuno-proofreader/-/tags/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sebastian Alsen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,435 @@
1
+ # yuno-proofreader
2
+
3
+ AI-powered website proofreader that collects suggestions in Google Sheets.
4
+
5
+ It reads your public web pages, asks an AI model (only Gemini so far) to look for typos, grammar problems, HTML structure issues, SEO basics and differences between language versions, and lists the suggested fixes in a Google Sheet for you to review.
6
+ Only Makes suggestions and doesn't do any actual changes to the website.
7
+
8
+ ## How it works
9
+
10
+ 1. Reads your sitemap and pairs each page with its language versions.
11
+ 2. Audits one page per run in two independent passes: a step-by-step audit, then a second review by a different model that has not seen the first result. A page that has not changed since its last complete audit is skipped without calling a model.
12
+ 3. Merges both answers, checks that every quoted text really appears on the page, and writes new suggestions to the sheet. Suggestions it already knows are not added again.
13
+ 4. On later runs, closes suggestions whose text is no longer on the live page.
14
+
15
+ You review the sheet and mark each suggestion as `open`, `on staging`, `done` or `rejected`.
16
+
17
+ ## What you need
18
+
19
+ - Ruby 4.0 or newer, or a container runtime such as Podman or Docker
20
+ - An API key for a supported AI model provider (Gemini so far)
21
+ - A Google Cloud service account with the Sheets API enabled, and a spreadsheet shared with that account
22
+ - A sitemap on the site you want to check
23
+
24
+ ## Getting started
25
+
26
+ ```
27
+ gem install yuno-proofreader
28
+ yuno-proofreader init # asks about your site and writes proofreader.yml
29
+ yuno-proofreader settings # what is still needed for an audit
30
+ yuno-proofreader sitemap # check that your pages are found and paired by language
31
+ ```
32
+
33
+ The commands that only read your site (`init`, `settings`, `sitemap`, `fetch` and `prompt`) need nothing more. The ones that use your sheet and your model need access, which lives outside `proofreader.yml`. You can put it in a `.env` file in the folder where you run the tool (see "Configuration"):
34
+
35
+ ```
36
+ SPREADSHEET_ID=...
37
+ GEMINI_API_KEY=...
38
+ GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json
39
+ ```
40
+
41
+ `SPREADSHEET_ID` is the part of your sheet's address between `/d/` and `/edit`. `GEMINI_API_KEY` is the API key of your model provider. `GOOGLE_APPLICATION_CREDENTIALS` points to the credentials file of a Google account that may edit the sheet, so share the sheet with that account as an editor. On Google Cloud it should not be needed, because the service account the job runs as is used instead. See "Connecting to Google". `yuno-proofreader init` lists which of these are set, `yuno-proofreader models` tests your model key, and `prepare` is the first command that tests the access to the sheet.
42
+
43
+ ```
44
+ yuno-proofreader prepare # creates the Pages, Suggestions and Analysis tabs in your sheet
45
+ yuno-proofreader pages import # adds your pages to the sheet
46
+ yuno-proofreader analyze # ranks the pages by importance (optional, but it orders the audits)
47
+ yuno-proofreader queue # shows which pages are due, and why
48
+ yuno-proofreader audit --next # audits the page that is due first
49
+ ```
50
+
51
+ Start with `audit --next --dry-run` and a single page, and read what it would do: every audit makes two model calls, so cost depends on your pages and your models.
52
+ Review the suggestions in the sheet, set each one's `status`, and call `audit --next` again whenever you like: it picks the next page that is due. A cron job, a Kubernetes CronJob or Cloud Scheduler can do that for you.
53
+
54
+ ## Privacy and responsible use
55
+
56
+ - The content of your pages, and the text of the settings that describe your site, is sent to the model provider you configure. Providers' terms sometimes differ between free and paid use, including whether prompts may be used to improve their products, so read the current terms before you start.
57
+ - Only check sites you own or have permission to fetch.
58
+ - You pay for the model calls yourself. Cost depends on the model and on the size of your pages.
59
+ - `yuno-proofreader prompt PAGE` shows exactly what is sent to the model for a page, so you can check it before any request is made.
60
+ - `yuno-proofreader audit` sends the pages and the settings that describe your site to the model provider, and writes to your sheet. Try it on one small page first, and read `yuno-proofreader prompt PAGE` to see what is sent.
61
+
62
+ ## Usage
63
+
64
+ ```
65
+ yuno-proofreader <command>
66
+ ```
67
+
68
+ | Command | What it does |
69
+ |---|---|
70
+ | `help` | Shows the commands |
71
+ | `version` | Prints the version |
72
+ | `doctor` | Checks that `SPREADSHEET_ID` is set and prints the configuration |
73
+ | `sitemap [FILE] [--all]` | Reads the sitemap of the site described in `FILE` (default `./proofreader.yml`) and shows how its pages were paired by language. `--all` lists every page. Needs no API keys. |
74
+ | `fetch PAGE [FILE] [--html=LANG]` | Downloads all language versions of one page and shows status, size and a content fingerprint of each. `PAGE` is a name from `sitemap --all`, or any address of the page. `--html=LANG` also prints the cleaned HTML that would be sent to the model. |
75
+ | `settings [FILE]` | Shows which of the settings that describe your site are filled in, and whether an audit can start. Exits with 1 when it cannot. |
76
+ | `models [FILE]` | Lists the models your API key can use for text generation, marks the ones in use, and shows whether the list comes from your settings or the built-in default. Needs the API key of your provider. |
77
+ | `prompt PAGE [FILE] [--review] [--full]` | Shows exactly what would be sent to the model for one page, with a size estimate, without calling it. Needs no API key. Refuses to run while the settings that describe your site are unfinished. |
78
+ | `audit PAGE [FILE] [--dry-run] [--force] [--verbose]` | Audits one page in all its language versions and updates the sheet. `--dry-run` writes nothing, but the model is still called and costs the same. `--force` audits a page that has not changed. `--verbose` lists everything that was decided. Needs a model API key, the sheet and the finished settings. |
79
+ | `prepare [FILE]` | Creates the `Pages` and `Suggestions` tabs in your Google Sheet. Safe to run again. |
80
+ | `pages import [FILE]` | Adds the pages of the sitemap to the `Pages` tab. Safe to run again. |
81
+ | `analyze [FILE] [--dry-run] [--no-ai] [--reuse-ai]` | Ranks every page by importance and writes `importance`, `tier` and `importance_notes` to the Pages tab, and the measurements to the Analysis tab. `--dry-run` writes nothing, `--no-ai` skips the AI models, `--reuse-ai` takes their judgement from the last run instead of asking again. |
82
+ | `init [FILE] [OPTIONS]` | Asks about your site and writes a settings file (default `proofreader.yml`). With `--template` it shows the full commented template instead, or writes it to `FILE` when one is named. For scripts: `--website`, `--language CODE[:PREFIX]`, `--note CODE=TEXT`, `--business`, `--audience`, `--tone`, `--sitemap`, `--skip-path`, `--rule`, `--ignore-term` and `--difference`. `--yes` accepts what was detected, `--offline` makes no request. Does not overwrite a file. |
83
+
84
+ Exit codes: `0` success, `1` an error such as missing configuration, `2` unknown command.
85
+
86
+ How to run it:
87
+
88
+ - As an installed gem: `gem install yuno-proofreader`, then `yuno-proofreader help`
89
+ - In a checkout or the dev container: `bundle exec exe/yuno-proofreader help`
90
+
91
+ ## Configuration
92
+
93
+ Secrets and ids come from environment variables. The settings of the site you check come from a YAML file.
94
+
95
+ | Variable | Required | Meaning |
96
+ |---|---|---|
97
+ | `GEMINI_API_KEY` | for the Gemini models | The API key of your model provider. Each provider names its own variable, and only the one you use is needed. |
98
+ | `SPREADSHEET_ID` | for the sheet commands and `doctor` | The id of the Google Sheet that receives the suggestions |
99
+ | `GOOGLE_APPLICATION_CREDENTIALS` | locally | Path to a Google credentials file. Where the tool runs on Google Cloud, the attached service account is used instead. |
100
+ | `LOG_LEVEL` | no | `debug`, `info`, `warn` (default) or `error`. At `info`, every run, batch and audited page gets a line. At `debug`, each model call gets a line as well, with its model, seconds and tokens. At `warn`, only problems. At `error`, only failures. |
101
+ | `LOG_FORMAT` | no | `text` (default, `key=value` pairs) or `json` (one JSON object per line, which Google Cloud Logging reads as a structured entry) |
102
+
103
+ **Where the variables come from.** Set them the way you set any environment variable: in your shell, in the configuration of your CI or job, or in the secret settings of your platform (on Google Cloud, Secret Manager). On your own machine you can also put them in a `.env` file in the folder where you run the tool, which it reads when it starts:
104
+
105
+ ```
106
+ SPREADSHEET_ID=...
107
+ GEMINI_API_KEY=...
108
+ GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json
109
+ ```
110
+
111
+ A variable that is already set always wins over the file, and `PROOFREADER_ENV_FILE=/path/to/file` names another file.
112
+
113
+ For the site, run `yuno-proofreader init` (see "Setting up your site"). The commands read `proofreader.yml` from the current folder, or from the path you give as `FILE`. `yuno-proofreader init --template` shows every setting with an explanation. Keep your `proofreader.yml` out of version control if it holds anything you don't want to share.
114
+
115
+ ## Setting up your site
116
+
117
+ ```
118
+ yuno-proofreader init
119
+ ```
120
+
121
+ Asks about your site and writes `proofreader.yml` in the current folder, or the file you name. It does not overwrites a file that already exists. What it does:
122
+
123
+ 1. Asks for the address of the site, and reads the homepage once, honoring. If the address leads to another host, such as the `www` form, it offers to use that one.
124
+ 2. Proposes the languages from the `hreflang` tags of the homepage, with the prefix of each and the default first. If the page announces none, or you decline, you type them: `de, es:/es, en:/en`, the default first.
125
+ 3. Asks for a note per language (variant and form of address), what the site offers (suggesting the meta description of the homepage), the audience and the tone, and then four lists with one entry per line: pages to skip, writing rules, words that must never be flagged, and intended differences between the languages. An empty answer skips what is optional.
126
+ 4. Looks for your sitemap. If none is found, asks for its address.
127
+ 5. Writes a short file with your answers. Then it says what an audit still needs, which of `SPREADSHEET_ID` and the API key of your provider are set, and what to run next.
128
+
129
+ ### Without questions
130
+
131
+ Without a terminal, or with `--yes`, `init` asks nothing and takes everything from options:
132
+
133
+ | Option | What it sets |
134
+ |---|---|
135
+ | `--website ADDRESS` | The address of the site. Needed when nobody can be asked. |
136
+ | `--language CODE[:PREFIX]` | A language of the site, once for each, the default first: `--language de --language es:/es`. When left out, the languages the homepage announces are used. |
137
+ | `--note CODE=TEXT` | A note for one language: its variant and form of address |
138
+ | `--business TEXT` | What the site is and offers. An audit needs it. |
139
+ | `--audience TEXT`, `--tone TEXT` | Who reads the site, and how the text should sound |
140
+ | `--sitemap ADDRESS` | Your sitemap, when it is not found on its own |
141
+ | `--skip-path PATH`, `--rule TEXT`, `--ignore-term TEXT`, `--difference TEXT` | One entry of a list each. Repeat the option for more entries. |
142
+ | `--yes` | Accepts what was detected and asks nothing, also on a terminal |
143
+ | `--offline` | Makes no request, so nothing is detected: give `--website` and `--language` |
144
+ | `--template` | Shows the full commented template instead, with every setting explained. With a file name (`--template NAME.yml`) it writes the template there |
145
+
146
+ ```
147
+ yuno-proofreader init --website example.com --language de --language en:/en --business "A small shop." --yes
148
+ ```
149
+
150
+ This reads the homepage and the sitemap of the site, which is how the languages and the sitemap are checked. Add `--offline` to make no request at all.
151
+
152
+ Good to know:
153
+
154
+ - Languages are told apart by the start of the address (`/en/...`). A version on another domain, or deeper than one path segment, can't be described, and `init` says which versions it left out.
155
+ - Language codes are written in lowercase (`de-DE` becomes `de-de`). Run `yuno-proofreader sitemap` afterwards to check that your pages are paired.
156
+ - With `--yes`, the meta description of your homepage becomes the description of the business, unless you give `--business`. It is marketing copy, so read it. If the page has no description, or one longer than 300 characters, the business stays empty, and `yuno-proofreader settings` says that an audit still needs it.
157
+ - The file holds only your answers. Settings you were not asked about, such as the models, `analysis` and `cadence`, are not in it and use their defaults. `yuno-proofreader init --template` shows every setting.
158
+
159
+ ## Describing your site
160
+
161
+ Besides the technical settings, `proofreader.yml` tells the tool what your site is about, so the model can judge text in context. Everything here is sent to the AI model together with each page, so keep it short and free of secrets. Every setting is explained in `proofreader.example.yml`.
162
+
163
+ | Setting | Required | Purpose |
164
+ |---|---|---|
165
+ | `business` | for an audit | What the site is and offers, in a sentence or two |
166
+ | `audience` | no | Who reads the site |
167
+ | `tone` | no | How the text should sound overall, such as warm and professional |
168
+ | `rules` | no | Writing rules that apply to every language, one per entry, such as how prices or dates are written |
169
+ | `ignore_terms` | no | Words that are correct as written and must not be flagged, such as brand and place names |
170
+ | `intentional_differences` | no | Deliberate differences between language versions, such as a section that exists in one language only |
171
+ | `notes` (inside each language) | no | What is specific to that language: its variant (Latin American Spanish) and its form of address (Sie or du, usted or tú) |
172
+
173
+ The example file marks each of these with `[FILL IN]`. The audit refuses to start while any value still contains that text: replace it with your own, or delete the setting. `yuno-proofreader settings` shows what is set and what is still missing.
174
+
175
+ Limits: 500 characters per text, 200 per list entry or language note, and 50 entries per list.
176
+
177
+
178
+ ## Choosing the models
179
+
180
+ The tool calls an AI model through a small wrapper for each family of models. Only `gemini` is included so far. Which models it uses is up to you, in `proofreader.yml`:
181
+
182
+ ```yaml
183
+ provider: gemini
184
+ models:
185
+ - first-choice-model
186
+ - second-choice-model
187
+ review_models:
188
+ - another-model
189
+ ```
190
+
191
+ - `models` is tried in order. A model that is overloaded, over its limit or not available to your key is skipped, and the next one is used.
192
+ - `review_models` is optional. The independent second review prefers a model different from the first audit. Without this setting it uses `models`.
193
+ - Without `models`, the tool falls back on a built-in list. Model names change often, so set your own. `yuno-proofreader models` shows which models your key can use.
194
+ - Leave out `provider` while only one is installed.
195
+
196
+ ## Previewing what is sent
197
+
198
+ ```
199
+ yuno-proofreader prompt PAGE [FILE] [--review] [--full]
200
+ ```
201
+
202
+ Shows exactly what would be sent to the model for one page, before any model is called.
203
+ It makes no model request and needs neither an API key nor a spreadsheet.
204
+ It only fetches the page (its `robots.txt`, your sitemap and each language version of the page), so use it on sites you own.
205
+
206
+ - `PAGE` is a page name from `sitemap --all`, or any address of the page.
207
+ - `FILE` is your settings file. Without it, `proofreader.yml` in the current folder is read.
208
+ - `--review` shows the prompt of the independent second review instead of the audit.
209
+ - `--full` prints the whole HTML of each language version. Without it, each version is shortened to its first 300 characters on screen. The sizes in the first lines always count the full text.
210
+
211
+ The command refuses to run while the settings that describe your site are unfinished (see "Describing your site"), because the audit would refuse too.
212
+ `yuno-proofreader settings` shows what is missing.
213
+
214
+ ## Auditing a page
215
+
216
+ ```
217
+ yuno-proofreader audit PAGE [FILE] [--dry-run] [--force] [--verbose]
218
+ ```
219
+
220
+ Audits one page, in all its language versions, and updates the sheet. It needs the tabs from `prepare`, the pages from `pages import`, a model API key and the finished settings that describe your site. `PAGE` is a page name from the first column of the `Pages` tab, or any address of the page.
221
+
222
+ What happens:
223
+
224
+ 1. The page is fetched in every language. If its content has not changed since its last complete audit, it is skipped and no model is called.
225
+ 2. A model audits it step by step. A second model, different from the first when your `models` list has another, reviews it independently without seeing the first result.
226
+ 3. Both lists are merged. Each new suggestion records who found it in `found_by`: `both`, `audit`, `review`, or `audit (unreviewed)` when the review failed.
227
+ 4. The findings are compared with the suggestions already in the sheet for that page:
228
+ - A suggestion that is already `open` or `on staging` is counted as seen again (`last_seen`, `amount_seen`), not added twice.
229
+ - One you marked `rejected` is not added again. One you marked `done` whose text comes back is added as a new row, with "[Reappeared after being marked done]" in the reason.
230
+ - An `open` or `on staging` suggestion whose quoted text is no longer on the live page is closed and set to `done`. It only closes when the page loaded completely and the suggestion's text had been found on the page when it was written.
231
+ 5. The page's row records `last_audited` and, after a complete audit, a `fingerprint`.
232
+
233
+ Reading a suggestion: `verified` says whether the quoted text is on the page (`yes`), only matches when spaces are ignored (`loose`) or is not there at all (`no`, which usually means the model misquoted). `approximate_place` is the heading path of the text, worked out by the tool.
234
+
235
+ An audit is complete when every language version was fetched and not cut at the size limit, every audit step was answered, and the review answered. Only then is the fingerprint stored. Anything less is written, but the page is audited again next time. `last_audited` means the last time the tool checked the page, and it is also set when an unchanged page is skipped.
236
+
237
+ Options:
238
+
239
+ - `--dry-run` runs everything except the writing. The model is still called, so it costs the same as a real run.
240
+ - `--force` audits the page even if it has not changed. Use it after you change your settings or the prompt files, which the fingerprint does not notice.
241
+ - `--verbose` lists every decision, including suggestions that were seen again or suppressed, and the items in the model answers that could not be used.
242
+
243
+ Each audit makes two model calls with the whole HTML of every language version, so cost depends on your pages and models. The report shows the tokens of each call.
244
+
245
+ ### What is in the output
246
+
247
+ The first lines give the size of the prompt: its characters and a very rough token estimate (four characters per token).
248
+ Then come two parts:
249
+
250
+ - **System prompt:** the same for every page of your site. It holds the role, the description of your site and your language notes, your rules, ignore terms and intentional differences (each only if you set them), the rules about effort and about what to report, the audit steps with the categories they may use, and the exact JSON shape the answer must have. For the review, the steps give way to a checklist and one flat list.
251
+ - **User message:** one block per language version of the page, from `=== DE | address ===` to `=== END DE ===`. The header names the versions included. A version that could not be fetched is listed as not available, so the model does not report differences it cannot check. A version that was cut at the size limit says so.
252
+
253
+ The text the model sees is the page's HTML with scripts, styles, SVG images and comments removed, and runs of whitespace joined. Everything else stays, including the head, the navigation and the footer.
254
+
255
+ The explanation the model gives for each suggestion (the reason column) is written in English, even when your site is in German or Spanish.
256
+ The quoted text and the suggested replacement stay in the language of your page.
257
+
258
+ ### Changing the wording
259
+
260
+ The instructions are plain files in `lib/yuno/proofreader/audit/prompts/`:
261
+
262
+ - `instructions.yml`: the role, the rules and the two procedures.
263
+ - `steps.yml`: the checks of the audit, in order. Each has a `number`, a `name`, the `categories` its findings may have, and a `text` that says what to look for. A step with the category `parity` is left out for a site with one language.
264
+
265
+ Edit them in a checkout or a fork, since an installed gem keeps them in its own folder. Run `prompt` to see the effect.
266
+
267
+ Everything in the output is sent to your model provider. See "Privacy and responsible use".
268
+
269
+ ### Respecting robots.txt
270
+
271
+ Before it fetches a page of your site, the tool checks your `robots.txt`. It identifies itself as `yuno-proofreader`, and it does not fetch a page that the rules for that name, or for all crawlers (`User-agent: *`), disallow. When rules conflict, the longest matching path wins, and `Allow` wins a tie. It also waits half a second between requests. There is nothing to configure, and no setting turns this off.
272
+
273
+ A page that is blocked is reported as "blocked by robots.txt". `fetch` shows it, an audit treats that language version as not available (the model is told, no differences involving it are reported, and the audit does not count as complete), and the analysis leaves the page out.
274
+
275
+ If the tool cannot read a page of your own site, check your `robots.txt`. To let it in while keeping other crawlers out, add a group for it:
276
+
277
+ ```
278
+ User-agent: yuno-proofreader
279
+ Allow: /
280
+ ```
281
+
282
+ ## Ranking the pages by importance
283
+
284
+ ```
285
+ yuno-proofreader analyze [FILE] [--dry-run] [--no-ai] [--reuse-ai]
286
+ ```
287
+
288
+ Ranks every page of the site by how much it matters, so the most important pages can be audited most often. It needs the tabs from `prepare` and the pages from `pages import`.
289
+
290
+ How a page is scored:
291
+
292
+ - **The structure of the site** (most of the score): how the homepage leads to the page through its navigation and the lists of the index pages, where earlier entries count more; whether the homepage shows it; how many pages list it; and how recently it changed. Lists are read from structured data (`ItemList`, `OfferCatalog`), and from the cards of a page when there is none. Only the default language is read, usually a few dozen pages.
293
+ - **The judgement of an AI model:** how well known the places are among your audience, and how much a reader would want each page. It sees only the names the site lists the pages under, not the pages themselves. A second model judges independently. The less the structure of the site says, the more the judgement counts.
294
+ - **Your own points:** `priority_paths` in the `analysis:` block of `proofreader.yml`, and the `boost` column of the Pages tab.
295
+
296
+ What is written:
297
+
298
+ - **Pages tab:** `importance` (0 to 100), `tier` (1 is the most important, about the top tenth), `importance_notes` (why, in a few words) and `analyzed_at`. `boost` is yours: a number of points added to the score. The tool reads it and never writes it.
299
+ - **Analysis tab:** one row per page with each measurement and the model's view (kind, place, value, certainty, reason, and how far apart the two models were). It is rewritten on every run.
300
+
301
+ The homepage always ranks first. Run it again after big changes to the site. To try other weights, `priority_paths` or boosts without asking the models again, use `--reuse-ai`, which takes their last judgement from the Analysis tab. The weights are in `lib/yuno/proofreader/analysis/weights.yml`, and the model's wording in `analysis/judge.yml`.
302
+
303
+ ## Logging
304
+
305
+ The tool writes a log to stderr, next to the report it prints on stdout. It is meant for runs nobody watches: it says which page was audited, what came of it and what the models used. Set `LOG_LEVEL=info` for one line per run, batch and page, or `debug` to add a line per model call. The default, `warn`, shows only problems.
306
+
307
+ ```
308
+ time=2026-10-25T12:00:05Z level=info msg="page audited" run=3fa91c2e page=about status=audited complete=true added=3 closed=1 model_calls=2 input_tokens=62000 output_tokens=9000
309
+ ```
310
+
311
+ With `LOG_FORMAT=json` each line is one JSON object with `severity`, `message` and `time`, plus the same fields. Every line of a run carries the same `run` id.
312
+
313
+ | Message | Level | What it tells you |
314
+ |---|---|---|
315
+ | `run started`, `run finished` | info | The command, and `result=ok` with the exit code and seconds |
316
+ | `run failed` | error | A run that did not succeed, with its exit code (and the error, when it reached the log) |
317
+ | `page audited`, `page skipped` | info | The page, what was added, seen again, closed or suppressed, and the models' tokens |
318
+ | `audit incomplete` | warn | Why the page's fingerprint was not recorded, so it will be tried again |
319
+ | `page failed` | error | A page that failed for its own reason and was set aside |
320
+ | `batch finished` | info | Pages audited, skipped and failed, how many are still due, and the tokens |
321
+ | `batch stopped` | error | A problem that ended the batch, such as no model being available |
322
+ | `nothing is due` | info | The queue was empty |
323
+ | `analysis finished` | info | What `analyze` ranked and wrote |
324
+ | `model failed`, `model paused`, `model answer unusable` | warn | A model that errored, was set aside for a while, or answered without usable JSON |
325
+ | `model answered` | debug | One model call: the model, seconds and tokens |
326
+
327
+ Page content, prompts, model answers and keys are never logged.
328
+
329
+ On Google Cloud, set `LOG_LEVEL=info` and `LOG_FORMAT=json`. Cloud Logging reads a JSON line on stdout or stderr as a structured entry: `severity` becomes the entry's severity, `message` its text, and the other fields can be searched, for example `jsonPayload.page="about"`. An alert on entries with `severity>=ERROR` catches failed runs and failed pages. The exit code (`0` when a run succeeded, `1` for an error, `2` for a usage error) is what marks a Cloud Run job execution as failed.
330
+
331
+ ## How the sitemap is read
332
+
333
+ The tool needs your sitemap to know which pages exist and which of them are language versions of each other.
334
+
335
+ **Finding it.** Unless `sitemap_url` is set, the tool reads the `Sitemap:` lines of your `robots.txt` (all of them), and falls back to `/sitemap.xml`.
336
+ A sitemap index is followed two levels deep. At most 25 sitemaps and 50,000 pages are read.
337
+ Sitemaps on another host are ignored, and `www.example.com` counts as the same host as `example.com`.
338
+ Compressed sitemaps (`.xml.gz`) are not supported yet.
339
+ The `sitemap` command shows what was found and any warnings.
340
+
341
+ **Pairing the languages.** Each page must be matched with its other language versions. The tool uses the first method that fits:
342
+
343
+ 1. **hreflang alternates in the sitemap:** if at least half of the entries list alternates, they decide the pairs.
344
+ 2. **Language prefix and position:** otherwise, pages are grouped by the prefixes in your `languages` setting and matched by their position. All pages of one language must be listed in the same order as in the other languages, and every language must have the same number of pages.
345
+
346
+ Pages that exist in one language only go under `default_language_only_paths`, and pages you don't want checked under `skip_paths`. If the lists don't match up, the tool says so and writes nothing, because a wrong pairing would produce wrong findings on every page.
347
+
348
+ ## Working with the sheet
349
+
350
+ The tool keeps its work in one Google Sheet: a queue of pages and a list of suggestions.
351
+ Both commands below need `SPREADSHEET_ID` and working Google access (see "Connecting to Google"), and neither needs a Gemini key.
352
+
353
+ ```
354
+ yuno-proofreader prepare
355
+ yuno-proofreader pages import
356
+ ```
357
+
358
+ Run them in this order, from the folder that contains your `proofreader.yml` (or pass the path of the settings file as the last argument).
359
+
360
+ ### prepare
361
+
362
+ Creates two tabs in your spreadsheet:
363
+
364
+ - **Pages**: one row per page of your site. Columns: `page_name`, one `<code>_url` column per language of your settings, `last_audited`, `fingerprint`, `note` and `sitemap_lastmod`.
365
+ - **Suggestions**: one row per suggested fix. Columns: `timestamp`, `page`, `language`, `category`, `effort`, `approximate_place`, `original`, `suggested`, `reason`, `verified`, `status`, `found_by`, `last_seen` and `amount_seen`. `status` is a dropdown with `open`, `on staging`, `done` and `rejected`.
366
+
367
+ Both tabs get a frozen header row and date formats. An empty default tab (`Sheet1`) is removed.
368
+
369
+ It is safe to run again. A tab that already has a header row is left as it is, and the tool only checks that its required columns exist.
370
+ To apply the formatting again, delete the tab and run `prepare` once more.
371
+
372
+ ### pages import
373
+
374
+ Reads your sitemap and adds each page to the `Pages` tab, with its address in every language. It needs the tabs from `prepare`.
375
+
376
+ - Pages that are already in the sheet are skipped (they are recognised by their address in the default language), so running it again only adds new pages.
377
+ - Nothing is overwritten. Pages that have left the sitemap stay in the sheet, and the command tells you how many.
378
+ - If the pages cannot be paired by language, it writes nothing. The `sitemap` command shows why.
379
+ - A row you delete comes back at the next import. To exclude a page for good, add it to `skip_paths` in your settings file.
380
+
381
+ ### Editing the sheet
382
+
383
+ Columns are found by their header names, so you can reorder them or add columns of your own. Don't rename the columns listed above. Times are stored in UTC.
384
+
385
+ ## Development
386
+
387
+ Assuming Podman:
388
+
389
+ ```
390
+ cp .env.example .env # then fill in your values
391
+ podman build -t yuno-proofreader:dev -f Dockerfile.dev .
392
+ sh start-dev.sh
393
+ podman exec -it proofreader-dev bash
394
+ ```
395
+
396
+ Inside the container:
397
+
398
+ ```
399
+ bundle exec rake test
400
+ bundle exec rubocop
401
+ gem build yuno-proofreader.gemspec
402
+ ```
403
+
404
+ To build the production image:
405
+
406
+ ```
407
+ podman build -t yuno-proofreader .
408
+ ```
409
+
410
+ ### Trying it on your own site
411
+
412
+ ```
413
+ bundle exec ruby -Ilib scripts/try_sitemap.rb
414
+ ```
415
+
416
+ The sitemap command shows how the pages were paired.
417
+ This script also fetches a sample of pages in every language and compares each page’s own hreflang tags with the pairing.
418
+ Options are environment variables:
419
+
420
+ | Variable | Default | Meaning |
421
+ |---|---|---|
422
+ | `SAMPLE` | `2,2,4` | First pages, last pages and random pages to check (`0,0,0` skips the page checks) |
423
+ | `SEED` | random | Repeat a random sample |
424
+ | `DELAY` | `0.5` | Seconds to wait between requests |
425
+
426
+ To see how the tool reads one page (its headings, and where a text sits under them), run `bundle exec ruby -Ilib scripts/try_place.rb PAGE LANG ["quoted text"]`.
427
+
428
+ ## Connecting to Google
429
+ This gem connects to Google Sheets and an AI model provider (Gemini so far).
430
+ I will not try to document the way to do it, because it differs by permissions, type of Accounts, etc.
431
+ Misconfiguring it could become costly, so make sure you do it correctly.
432
+
433
+ ## Contributing
434
+
435
+ See CONTRIBUTING.md. It is provided as is, and replies may take a while.
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "yuno/proofreader"
5
+
6
+ $stdout.sync = true
7
+ $stderr.sync = true
8
+
9
+ begin
10
+ Yuno::Proofreader::EnvFile.load&.warnings&.each { |warning| warn "Warning: #{warning}" }
11
+ rescue Yuno::Proofreader::ConfigError => e
12
+ warn "Error: #{e.message}"
13
+ exit 1
14
+ end
15
+
16
+ exit Yuno::Proofreader::CLI.run(ARGV)
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Analysis
6
+ # Points a person adds to the score of a page, and where they come from.
7
+ Adjustment = Data.define(:points, :notes)
8
+
9
+ # Turns priority_paths from the settings and the boost of each page into adjustments, keyed by page key.
10
+ # The most specific path wins, and a boost adds to it. Pages without either get no adjustment.
11
+ module Adjustments
12
+ module_function
13
+
14
+ # boosts: the boost of each page, keyed by page key.
15
+ def call(pages:, language:, settings:, boosts: {})
16
+ pages.each_with_object({}) do |page, found|
17
+ key = Links.key(page.urls[language] || page.urls.values.first)
18
+ parts = [priority(Reasons.shown(key), settings.priority_paths), boost(boosts[key])].compact
19
+ found[key] = Adjustment.new(points: parts.sum(&:first), notes: parts.map(&:last)) unless parts.empty?
20
+ end
21
+ end
22
+
23
+ def priority(path, rules)
24
+ match = rules.select { |prefix, _points| covers?(prefix, path) }.max_by { |prefix, _points| prefix.length }
25
+ return if match.nil? || match.last.zero?
26
+
27
+ [match.last, "#{signed(match.last)} from priority_paths (#{match.first})"]
28
+ end
29
+
30
+ def covers?(prefix, path) = prefix == "/" || path == prefix || path.start_with?("#{prefix}/")
31
+
32
+ def boost(value)
33
+ return if value.nil? || value.zero?
34
+
35
+ [value, "#{signed(value)} from boost"]
36
+ end
37
+
38
+ def signed(number)
39
+ shown = number == number.round ? number.round : number.round(1)
40
+ number.positive? ? "+#{shown}" : shown.to_s
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Analysis
6
+ # What the crawl found. facts and failures are keyed by Links.key of a page's address. unread holds the index
7
+ # pages that were left out because the limit was reached.
8
+ Inventory = Data.define(:home, :facts, :failures, :tree, :unread) do
9
+ def facts_for(url) = facts[Links.key(url)]
10
+ def read?(url) = facts.key?(Links.key(url))
11
+ end
12
+
13
+ # Reads the structure of a site, starting at its homepage: the homepage, the pages its header, navigation and
14
+ # footer link to, any index pages named in the settings, the pages that have other pages under them, and the
15
+ # entries of the lists it reads that are index pages themselves. Only the default language is read, because
16
+ # the pairing already knows each page's other language versions
17
+ class Crawler
18
+ MAX_PAGES = 80
19
+ INDEX_TYPES = %w[CollectionPage ItemList].freeze
20
+
21
+ def initialize(site:, pages:, fetcher:, extra_hubs: [], max_pages: MAX_PAGES, progress: ->(_message) {})
22
+ @site = site
23
+ @language = site.default_language
24
+ @tree = UrlTree.new(pages.filter_map { |page| page.urls[@language] || page.urls.values.first })
25
+ @fetcher = fetcher
26
+ @extra = extra_hubs.map { |path| "#{site.website}/#{path.to_s.delete_prefix('/')}" }
27
+ @max = max_pages
28
+ @progress = progress
29
+ end
30
+
31
+ def call
32
+ @facts = {}
33
+ @failures = {}
34
+ read(home)
35
+ queue = unique([*from_home, *@extra, *@tree.hubs])
36
+ until queue.empty? || visited >= @max
37
+ url = queue.shift
38
+ next if done?(url)
39
+
40
+ read(url)
41
+ queue = unique(queue + listed_index_pages(url))
42
+ end
43
+ Inventory.new(home:, facts: @facts, failures: @failures, tree: @tree,
44
+ unread: queue.reject { |url| done?(url) })
45
+ end
46
+
47
+ private
48
+
49
+ # The default language's homepage, written as the sitemap writes it when the sitemap has it.
50
+ def home
51
+ @home ||= begin
52
+ address = "#{@site.website}#{@site.prefixes[@language]}"
53
+ @tree.urls.find { |url| Links.key(url) == Links.key(address) } || address
54
+ end
55
+ end
56
+
57
+ def from_home
58
+ facts = @facts[Links.key(home)]
59
+ facts ? facts.chrome_links.values.flatten.select { |url| @tree.include?(url) } : []
60
+ end
61
+
62
+ # Entries of the lists a page publishes that are index pages themselves: typed as a collection, or with
63
+ # pages under them. Only pages the tool tracks are followed.
64
+ def listed_index_pages(url)
65
+ facts = @facts[Links.key(url)]
66
+ return [] unless facts
67
+
68
+ entries = facts.lists.flat_map(&:list_members)
69
+ entries.select { |entry| @tree.include?(entry.url) && index_like?(entry) }.map(&:url)
70
+ end
71
+
72
+ def index_like?(entry)
73
+ entry.types.intersect?(INDEX_TYPES) || !@tree.children(entry.url).empty?
74
+ end
75
+
76
+ def read(url)
77
+ @progress.call("Reading #{url}")
78
+ page = Sitemap::Page.new(name: url, urls: { @language => url }, lastmod: "", notes: [])
79
+ snapshot = @fetcher.fetch(page).snapshots.first
80
+ if snapshot.ok?
81
+ @facts[Links.key(url)] = Extractor.call(snapshot.raw, url:, base: snapshot.final_url)
82
+ else
83
+ @failures[Links.key(url)] = snapshot.problem
84
+ end
85
+ end
86
+
87
+ def done?(url) = @facts.key?(Links.key(url)) || @failures.key?(Links.key(url))
88
+
89
+ def visited = @facts.size + @failures.size
90
+
91
+ def unique(urls) = urls.uniq { |url| Links.key(url) }
92
+ end
93
+ end
94
+ end
95
+ end