diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ed2e42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +ENV/ +.eggs/ +*.egg-info/ +.mypy_cache/ +.pytest_cache/ + +# Generated reports +rapport_*.pdf +rapport/ + +# Unlighthouse +.unlighthouse/ +node_modules/ + +# IDE & editors +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +webbanalys.json + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f218cf8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,509 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute +verbatim copies of this license document, but changing it is not allowed. + + Preamble + +The GNU General Public License is a free, copyleft license for software and other kinds of works. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the +works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all +versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use +the GNU General Public License for most of our software; it applies also to any other work released this way by its +authors. You can apply it to your programs, too. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make +sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive +source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and +that you know you can do these things. + +To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. +Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: +responsibilities to respect the freedom of others. + +For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients +the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must +show them these terms so they know their rights. + +Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer +you this License giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. +For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems +will not be attributed erroneously to authors of previous versions. + +Some devices are designed to deny users access to install or run modified versions of the software inside them, although +the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the +software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely +where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we stand ready to extend this provision to those +domains in future versions of the GPL, as needed to protect the freedom of users. + +Finally, every program is threatened constantly by software patents. States should not allow patents to restrict +development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger +that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". +"Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, +other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work +"based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily +liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. +Propagation includes copying, distribution (with or without modification), making available to the public, and in some +countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction +with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and +prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no +warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this +License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +1. Source Code. + +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means +any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, +or, in the case of interfaces specified for a particular programming language, one that is widely used among developers +working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in +the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to +enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is +available to the public in source code form. A "Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a +compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and +(for an executable work) run the object code and to modify the work, including scripts to control those activities. +However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs +which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding +Source includes interface definition files associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data +communication or control flow between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the +Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. + +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided +the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. +The output from running a covered work is covered by this License only if the output, given its content, constitutes a +covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license +otherwise remains in force. You may convey covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do not control copyright. Those thus making or running +the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that +prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not +allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling +obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the +extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you +disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, +your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices +of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for +a fee. + +5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source +code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of +the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or +distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the +access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, +need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used +for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In +determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a +particular product received by a particular user, "normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way in which the particular user actually uses, or +expects or is expected to use, the product. A product is a consumer product regardless of whether the product has +substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of +the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information +required to install and execute modified versions of a covered work in that User Product from a modified version of its +Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code +is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the +conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to +the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding +Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not +apply if neither you nor any third party retains the ability to install modified object code on the User Product (for +example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support +service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product +in which it has been modified or installed. Access to a network may be denied when the modification itself materially +and adversely affects the operation of the network or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format +that is publicly documented (and with an implementation available to the public in source code form), and must require +no special password or key for unpacking, reading or copying. + +7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of +its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were +included in this License, to the extent that they are valid under applicable law. If additional permissions apply only +to part of the Program, that part may be used separately under those permissions, but the entire Program remains +governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or +from any part of it. (Additional permissions may be written to require their own removal in certain cases when you +modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have +or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by +the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the +Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with +a term that is a further restriction, you may remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of +that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a +statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as +exceptions; the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to +propagate or modify it is void, and will automatically terminate your rights under this License (including any patent +licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated +(a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days +after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you +of the violation by some reasonable means, this is the first time you have received notice of violation of this License +(for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or +rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a +covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not +require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered +work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, +modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third +parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or +subdividing an organization, or merging organizations. If propagation of a covered work results from an entity +transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work +the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with +reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For +example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, +and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent +claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the +Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already +acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or +selling its contributor version, but do not include claims that would be infringed only as a consequence of further +modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent +sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential +patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its +contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not +to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). +To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent +against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not +available for anyone to copy, free of charge and under the terms of this License, through a publicly available network +server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or +(2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a +manner consistent with the requirements of this License, to extend the patent license to downstream recipients. +"Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in +a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in +that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring +conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing +them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is +automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, +or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You +may not convey a covered work if you are a party to an arrangement with a third party that is in the business of +distributing software, under which you make payment to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from +those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered +work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to +infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this +License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to +satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence +you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further +conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License +would be to refrain entirely from conveying the Program. + +13. Use with the GNU Affero General Public License. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work +licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special +requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply +to the combination as such. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to +time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new +problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the +GNU General Public License "or any later version" applies to it, you have the option of following the terms and +conditions either of that numbered version or of any later version published by the Free Software Foundation. If the +Program does not specify a version number of the GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, +that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the +Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed +on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING +THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR +IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. +THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU +ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO +MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, +INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO +LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to +their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil +liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program +in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve +this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to +most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer +to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of +course, your program's commands might be different; for a GUI interface, you would use an "about box". + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for +the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see +. + +The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is +a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If +this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read +. diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..4bf44de --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1,2 @@ +# Webbplatsanalys Report Generator Library + diff --git a/lib/analyzer.py b/lib/analyzer.py new file mode 100644 index 0000000..f3a0f01 --- /dev/null +++ b/lib/analyzer.py @@ -0,0 +1,118 @@ +""" +Data analysis module for Unlighthouse reports. +""" + +import json +from pathlib import Path +from collections import defaultdict + + +def load_ci_result(ci_result_path: Path) -> dict: + """Load the main CI result JSON.""" + with open(ci_result_path, "r", encoding="utf-8") as f: + return json.load(f) + + +def load_all_audits(reports_dir: Path, site_url: str = ""): + """ + Load ALL detailed audit data from lighthouse.json files. + + Returns: + tuple: (all_audits, resource_issues, element_issues, page_count) + """ + all_audits = defaultdict(lambda: { + "count": 0, "pages": [], "score_sum": 0, "title": "", "description": "", + "total_wasted_bytes": 0, "display_values": [] + }) + + resource_issues = defaultdict(lambda: { + "count": 0, "wasted_bytes": 0, "total_bytes": 0, "pages": [] + }) + + element_issues = defaultdict(lambda: { + "count": 0, "audit": "", "pages": [] + }) + + page_count = 0 + site_domain = site_url.replace("https://", "").replace("http://", "").split("/")[0] + + for report_dir in reports_dir.iterdir(): + if not report_dir.is_dir(): + continue + + lighthouse_json = report_dir / "lighthouse.json" + if not lighthouse_json.exists(): + continue + + try: + with open(lighthouse_json, "r", encoding="utf-8") as f: + report = json.load(f) + + page_path = report.get("finalDisplayedUrl", report_dir.name) + page_path_short = page_path.replace(site_url, "") or "/" + audits = report.get("audits", {}) + page_count += 1 + + for audit_id, audit_data in audits.items(): + score = audit_data.get("score") + if score is None: + continue + + if score < 1: + all_audits[audit_id]["count"] += 1 + all_audits[audit_id]["title"] = audit_data.get("title", audit_id) + all_audits[audit_id]["description"] = audit_data.get("description", "") + all_audits[audit_id]["score_sum"] += score + + if len(all_audits[audit_id]["pages"]) < 20: + all_audits[audit_id]["pages"].append(page_path_short) + + display_val = audit_data.get("displayValue", "") + if display_val: + all_audits[audit_id]["display_values"].append(display_val) + + details = audit_data.get("details", {}) + items = details.get("items", []) + + for item in items: + if not isinstance(item, dict): + continue + + url = item.get("url", "") + if url and site_domain and site_domain in url: + url_short = url.replace(site_url, "") + key = (audit_id, url_short) + resource_issues[key]["count"] += 1 + resource_issues[key]["wasted_bytes"] += item.get("wastedBytes", 0) + resource_issues[key]["total_bytes"] += item.get("totalBytes", 0) + if len(resource_issues[key]["pages"]) < 5: + resource_issues[key]["pages"].append(page_path_short) + + node = item.get("node", {}) + snippet = node.get("snippet", "") if isinstance(node, dict) else str(node) if node else "" + + if snippet: + key = (audit_id, snippet[:80]) + element_issues[key]["count"] += 1 + element_issues[key]["audit"] = audit_id + if len(element_issues[key]["pages"]) < 5: + element_issues[key]["pages"].append(page_path_short) + + wasted = item.get("wastedBytes", 0) + if wasted: + all_audits[audit_id]["total_wasted_bytes"] += wasted + + except Exception: + continue + + return dict(all_audits), dict(resource_issues), dict(element_issues), page_count + + +def format_bytes(bytes_val: int) -> str: + """Format bytes to human readable.""" + if bytes_val >= 1024 * 1024: + return f"{bytes_val / (1024*1024):.1f} MB" + elif bytes_val >= 1024: + return f"{bytes_val / 1024:.0f} KB" + return f"{bytes_val} B" + diff --git a/lib/charts.py b/lib/charts.py new file mode 100644 index 0000000..c5f461e --- /dev/null +++ b/lib/charts.py @@ -0,0 +1,324 @@ +""" +Chart generation module for website analysis reports. +Uses consultancy brand colors: lime-500 and lime-900. +""" + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import io + +# Consultancy brand colors (lime palette) +BRAND = { + "primary": "#84cc16", # lime-500 (oklch 76.8% 0.233 130.85) + "primary_dark": "#365314", # lime-900 (oklch 40.5% 0.101 131.063) + "accent": "#a3e635", # lime-400 + "dark": "#1a2e05", # lime-950 +} + +# Score colors (traffic light) +SCORE_COLORS = { + "excellent": "#22c55e", # green-500 + "needs_work": "#f59e0b", # amber-500 + "poor": "#ef4444", # red-500 +} + +# UI colors +UI = { + "text": "#1f2937", # gray-800 + "muted": "#6b7280", # gray-500 + "light_bg": "#f9fafb", # gray-50 + "border": "#e5e7eb", # gray-200 + "white": "#ffffff", +} + +# Swedish category names +CATEGORY_NAMES_SV = { + "performance": "Prestanda", + "accessibility": "Tillgänglighet", + "best-practices": "Bästa praxis", + "seo": "Sökmotoroptimering (SEO)", +} + + +def get_score_color(score: float) -> str: + """Get color based on score (0-100).""" + if score >= 90: + return SCORE_COLORS["excellent"] + elif score >= 50: + return SCORE_COLORS["needs_work"] + return SCORE_COLORS["poor"] + + +def create_gauge_chart(score: float, label: str) -> io.BytesIO: + """Create a semi-circular gauge chart with brand colors.""" + fig, ax = plt.subplots(figsize=(6, 6), dpi=150) + + color = get_score_color(score) + + # Background arc + theta_bg = np.linspace(np.pi, 0, 100) + r = 0.75 + ax.plot(r * np.cos(theta_bg), r * np.sin(theta_bg), + color=UI["border"], linewidth=25, solid_capstyle="round", zorder=1) + + # Score arc + score_ratio = score / 100 + theta_score = np.linspace(np.pi, np.pi - (np.pi * score_ratio), 100) + ax.plot(r * np.cos(theta_score), r * np.sin(theta_score), + color=color, linewidth=25, solid_capstyle="round", zorder=2) + + # Score text + ax.text(0, 0.05, f"{int(score)}", fontsize=56, fontweight="bold", + ha="center", va="center", color=color) + ax.text(0, -0.4, label, fontsize=16, ha="center", va="center", + color=UI["muted"]) + + # Scale markers + for val in [0, 50, 100]: + angle = np.pi - (np.pi * val / 100) + x = (r + 0.15) * np.cos(angle) + y = (r + 0.15) * np.sin(angle) + ax.text(x, y, str(val), fontsize=12, ha="center", va="center", + color=UI["muted"]) + + ax.set_xlim(-1.1, 1.1) + ax.set_ylim(-0.6, 1.1) + ax.axis("off") + ax.set_aspect("equal") + + buf = io.BytesIO() + plt.savefig(buf, format="png", bbox_inches="tight", transparent=True, dpi=150) + plt.close() + buf.seek(0) + return buf + + +def create_overview_chart(categories: dict, category_names: dict = None) -> io.BytesIO: + """Create main overview chart with 0-100 scale.""" + if category_names is None: + category_names = CATEGORY_NAMES_SV + + fig, ax = plt.subplots(figsize=(10, 4), dpi=150) + + names = [category_names.get(k, k) for k in categories.keys()] + scores = [v["averageScore"] * 100 for v in categories.values()] + + y_pos = np.arange(len(names)) + + # Background bars (100%) + ax.barh(y_pos, [100] * len(names), color=UI["border"], height=0.5, zorder=1) + + # Score bars with traffic light colors + colors_list = [get_score_color(s) for s in scores] + bars = ax.barh(y_pos, scores, color=colors_list, height=0.5, zorder=2) + + ax.set_yticks(y_pos) + ax.set_yticklabels(names, fontsize=11, color=UI["text"]) + ax.set_xlim(0, 100) + ax.set_xlabel("Poäng (0-100)", fontsize=11, color=UI["muted"]) + + for i, (bar, score) in enumerate(zip(bars, scores)): + ax.text(score + 2, i, f"{score:.0f}", va="center", fontsize=11, + fontweight="bold", color=UI["text"]) + + # Threshold lines + ax.axvline(x=90, color=SCORE_COLORS["excellent"], linestyle="--", alpha=0.5, linewidth=1) + ax.axvline(x=50, color=SCORE_COLORS["needs_work"], linestyle="--", alpha=0.5, linewidth=1) + + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color(UI["border"]) + ax.spines["bottom"].set_color(UI["border"]) + + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format="png", bbox_inches="tight", facecolor="white", dpi=150) + plt.close() + buf.seek(0) + return buf + + +def create_issues_chart(all_audits: dict, total_pages: int, top_n: int = 12) -> io.BytesIO: + """Create horizontal bar chart of most common issues.""" + fig, ax = plt.subplots(figsize=(10, 6), dpi=150) + + sorted_audits = sorted(all_audits.items(), key=lambda x: x[1]["count"], reverse=True)[:top_n] + + def wrap_title(t, max_len=35): + if len(t) <= max_len: + return t + words = t.split() + lines, current = [], "" + for w in words: + if len(current + " " + w) <= max_len: + current = (current + " " + w).strip() + else: + if current: + lines.append(current) + current = w + if current: + lines.append(current) + return "\n".join(lines[:2]) + + names = [wrap_title(a[1]["title"]) for a in sorted_audits] + counts = [a[1]["count"] for a in sorted_audits] + percentages = [(c / total_pages) * 100 for c in counts] + + y_pos = np.arange(len(names)) + + # Background bars + ax.barh(y_pos, [100] * len(names), color=UI["border"], height=0.6, zorder=1) + + # Issue bars with severity colors + colors_list = [SCORE_COLORS["poor"] if p > 80 else SCORE_COLORS["needs_work"] if p > 50 + else BRAND["primary"] for p in percentages] + bars = ax.barh(y_pos, percentages, color=colors_list, height=0.6, zorder=2) + + ax.set_yticks(y_pos) + ax.set_yticklabels(names, fontsize=9, color=UI["text"]) + ax.set_xlim(0, 100) + ax.set_xlabel("Andel av sidor med problem (%)", fontsize=10, color=UI["muted"]) + + # Add labels - bars and data are in same order + for i, (pct, cnt) in enumerate(zip(percentages, counts)): + ax.text(pct + 2, i, f"{pct:.0f}% ({cnt})", va="center", fontsize=9, color=UI["text"]) + + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color(UI["border"]) + ax.spines["bottom"].set_color(UI["border"]) + + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format="png", bbox_inches="tight", facecolor="white", dpi=150) + plt.close() + buf.seek(0) + return buf + + +def create_savings_chart(all_audits: dict) -> io.BytesIO: + """Create chart showing potential savings in MB.""" + audits_with_savings = [(k, v) for k, v in all_audits.items() if v["total_wasted_bytes"] > 10000] + audits_with_savings.sort(key=lambda x: x[1]["total_wasted_bytes"], reverse=True) + audits_with_savings = audits_with_savings[:8] + + if not audits_with_savings: + return None + + fig, ax = plt.subplots(figsize=(9, 4), dpi=150) + + def wrap_title(t, max_len=30): + return t[:max_len] + "..." if len(t) > max_len else t + + names = [wrap_title(a[1]["title"]) for a in audits_with_savings] + savings_mb = [a[1]["total_wasted_bytes"] / (1024 * 1024) for a in audits_with_savings] + + y_pos = np.arange(len(names)) + bars = ax.barh(y_pos, savings_mb, color=SCORE_COLORS["needs_work"], height=0.6) + + ax.set_yticks(y_pos) + ax.set_yticklabels(names, fontsize=9, color=UI["text"]) + ax.set_xlabel("Potentiell besparing (MB)", fontsize=10, color=UI["muted"]) + + # Place labels at end of each bar + max_val = max(savings_mb) if savings_mb else 1 + for i, s in enumerate(savings_mb): + ax.text(s + max_val * 0.02, i, f"{s:.1f} MB", va="center", fontsize=9, color=UI["text"]) + + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color(UI["border"]) + ax.spines["bottom"].set_color(UI["border"]) + + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format="png", bbox_inches="tight", facecolor="white", dpi=150) + plt.close() + buf.seek(0) + return buf + + +def create_score_histogram(routes: list, category_names: dict = None) -> io.BytesIO: + """Create 4 histograms showing score distribution (0-100 scale).""" + if category_names is None: + category_names = CATEGORY_NAMES_SV + + fig, axes = plt.subplots(2, 2, figsize=(10, 7), dpi=150) + + categories = ["performance", "accessibility", "best-practices", "seo"] + titles = [category_names.get(c, c) for c in categories] + + for ax, cat, title in zip(axes.flatten(), categories, titles): + scores = [r.get("categories", {}).get(cat, {}).get("score", 0) * 100 for r in routes] + + bins = np.arange(0, 105, 10) + n, bins_out, patches = ax.hist(scores, bins=bins, edgecolor="white", alpha=0.9) + + for patch in patches: + bin_center = patch.get_x() + patch.get_width() / 2 + patch.set_facecolor(get_score_color(bin_center)) + + avg = np.mean(scores) + ax.axvline(x=avg, color=BRAND["primary_dark"], linestyle="--", linewidth=2) + + ax.set_xlim(0, 100) + ax.set_xlabel("Poäng", fontsize=9, color=UI["muted"]) + ax.set_ylabel("Antal sidor", fontsize=9, color=UI["muted"]) + ax.set_title(f"{title} (medel: {avg:.0f})", fontsize=11, fontweight="bold", color=UI["text"]) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color(UI["border"]) + ax.spines["bottom"].set_color(UI["border"]) + + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format="png", bbox_inches="tight", facecolor="white", dpi=150) + plt.close() + buf.seek(0) + return buf + + +def create_core_web_vitals_chart(metrics: dict) -> io.BytesIO: + """Create Core Web Vitals visualization.""" + fig, axes = plt.subplots(1, 3, figsize=(10, 3), dpi=150) + + vitals = [ + ("LCP", metrics.get("largest-contentful-paint", {}).get("averageNumericValue", 0) / 1000, + "s", 2.5, 4.0), + ("CLS", metrics.get("cumulative-layout-shift", {}).get("averageNumericValue", 0), + "", 0.1, 0.25), + ("FCP", metrics.get("first-contentful-paint", {}).get("averageNumericValue", 0) / 1000, + "s", 1.8, 3.0), + ] + + for ax, (abbr, value, unit, good, bad) in zip(axes, vitals): + if value <= good: + color = SCORE_COLORS["excellent"] + status = "BRA" + elif value <= bad: + color = SCORE_COLORS["needs_work"] + status = "BEHÖVER ARBETE" + else: + color = SCORE_COLORS["poor"] + status = "DÅLIGT" + + ax.text(0.5, 0.65, f"{value:.2f}{unit}", fontsize=28, fontweight="bold", + ha="center", va="center", transform=ax.transAxes, color=color) + ax.text(0.5, 0.35, abbr, fontsize=14, ha="center", va="center", + transform=ax.transAxes, color=UI["muted"]) + ax.text(0.5, 0.15, status, fontsize=10, ha="center", va="center", + transform=ax.transAxes, color=color, fontweight="bold") + ax.text(0.5, 0.02, f"(bra: ≤{good}{unit})", fontsize=8, ha="center", va="center", + transform=ax.transAxes, color=UI["muted"]) + + ax.axis("off") + + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format="png", bbox_inches="tight", facecolor="white", dpi=150) + plt.close() + buf.seek(0) + return buf + diff --git a/lib/pdf_report.py b/lib/pdf_report.py new file mode 100644 index 0000000..1b4c6cc --- /dev/null +++ b/lib/pdf_report.py @@ -0,0 +1,582 @@ +""" +PDF report generation module with brand styling. +""" + +from datetime import datetime +from pathlib import Path +from xml.sax.saxutils import escape as xml_escape +from collections import defaultdict + +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import cm +from reportlab.platypus import ( + SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, + PageBreak, Image +) +from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_RIGHT + +from .charts import ( + BRAND, SCORE_COLORS, UI, CATEGORY_NAMES_SV, + create_gauge_chart, create_overview_chart, create_issues_chart, + create_savings_chart, create_score_histogram, create_core_web_vitals_chart +) +from .analyzer import format_bytes + +# Audit groups for categorization +AUDIT_GROUPS = { + "images": { + "title": "Bildoptimering", + "audits": ["modern-image-formats", "uses-responsive-images", "unsized-images", + "offscreen-images", "uses-optimized-images", "lcp-lazy-loaded", + "image-size-responsive"], + "onetime": True, + "description": "Bildrelaterade problem – åtgärdas med optimeringsverktyg och temaändringar." + }, + "css_js": { + "title": "CSS & JavaScript", + "audits": ["unused-css-rules", "unused-javascript", "unminified-css", + "unminified-javascript", "render-blocking-resources", "legacy-javascript", + "duplicated-javascript", "bootup-time", "mainthread-work-breakdown"], + "onetime": True, + "description": "Oanvänd och blockerande kod – optimeras på temanivå med cachingplugins." + }, + "caching": { + "title": "Caching & Leverans", + "audits": ["uses-long-cache-ttl", "uses-text-compression", "uses-http2", + "server-response-time", "total-byte-weight"], + "onetime": True, + "description": "Server- och cachingkonfiguration – kräver serverinställningar och CDN." + }, + "accessibility": { + "title": "Tillgänglighetsproblem", + "audits": ["color-contrast", "link-name", "button-name", "image-alt", + "meta-viewport", "bypass", "html-has-lang", "document-title", + "heading-order", "label", "aria-"], + "onetime": False, + "description": "Tillgänglighetsproblem som hindrar användare med funktionsnedsättning." + }, + "seo": { + "title": "SEO-problem", + "audits": ["meta-description", "link-text", "crawlable-anchors", + "robots-txt", "canonical", "hreflang", "structured-data"], + "onetime": False, + "description": "Sökmotoroptimeringsproblem som påverkar ranking och synlighet." + }, + "errors": { + "title": "Fel & Varningar", + "audits": ["errors-in-console", "deprecations", "inspector-issues"], + "onetime": True, + "description": "JavaScript-fel och föråldrade API:er som indikerar teknisk skuld." + }, + "layout": { + "title": "Layout & CLS", + "audits": ["cumulative-layout-shift", "dom-size", "largest-contentful-paint-element", + "layout-shift-elements", "long-tasks"], + "onetime": False, + "description": "Layout-stabilitet och renderingsprestanda – kritiskt för UX." + } +} + + +def get_styles(): + """Get PDF styles with brand colors.""" + styles = getSampleStyleSheet() + + brand_primary = colors.HexColor(BRAND["primary"]) + brand_dark = colors.HexColor(BRAND["primary_dark"]) + text_color = colors.HexColor(UI["text"]) + muted_color = colors.HexColor(UI["muted"]) + + return { + "title": ParagraphStyle( + "Title", parent=styles["Heading1"], fontSize=28, spaceAfter=15, + alignment=TA_CENTER, textColor=brand_dark + ), + "h1": ParagraphStyle( + "H1", parent=styles["Heading1"], fontSize=16, spaceBefore=12, + spaceAfter=8, textColor=brand_dark + ), + "h2": ParagraphStyle( + "H2", parent=styles["Heading2"], fontSize=12, spaceBefore=10, + spaceAfter=5, textColor=text_color + ), + "h3": ParagraphStyle( + "H3", parent=styles["Heading3"], fontSize=10, spaceBefore=6, + spaceAfter=3, textColor=text_color + ), + "body": ParagraphStyle( + "Body", parent=styles["Normal"], fontSize=9, spaceAfter=5, + alignment=TA_JUSTIFY, leading=12, textColor=text_color + ), + "body_bold": ParagraphStyle( + "BodyBold", parent=styles["Normal"], fontSize=9, spaceAfter=5, + alignment=TA_JUSTIFY, leading=12, fontName="Helvetica-Bold", + textColor=text_color + ), + "small": ParagraphStyle( + "Small", parent=styles["Normal"], fontSize=8, + textColor=muted_color, leading=10, spaceAfter=3 + ), + "alert": ParagraphStyle( + "Alert", parent=styles["Normal"], fontSize=10, + textColor=colors.HexColor(SCORE_COLORS["poor"]), fontName="Helvetica-Bold" + ), + "warning": ParagraphStyle( + "Warning", parent=styles["Normal"], fontSize=10, + textColor=colors.HexColor(SCORE_COLORS["needs_work"]), fontName="Helvetica-Bold" + ), + "footer": ParagraphStyle( + "Footer", parent=styles["Normal"], fontSize=8, + textColor=muted_color, alignment=TA_RIGHT + ), + } + + +def generate_pdf( + output_path: Path, + data: dict, + all_audits: dict, + resource_issues: dict, + element_issues: dict, + page_count: int, + customer_name: str = "", + site_url: str = "", + logo_path: Path = None, + consultant_name: str = "", + category_names: dict = None +): + """Generate comprehensive PDF report with branding.""" + if category_names is None: + category_names = CATEGORY_NAMES_SV + + site_display = site_url.replace("https://", "").replace("http://", "").rstrip("/") + + doc = SimpleDocTemplate( + str(output_path), pagesize=A4, + rightMargin=1.8*cm, leftMargin=1.8*cm, topMargin=1.8*cm, bottomMargin=2*cm, + ) + + s = get_styles() + story = [] + + brand_primary = colors.HexColor(BRAND["primary"]) + brand_dark = colors.HexColor(BRAND["primary_dark"]) + border_color = colors.HexColor(UI["border"]) + light_bg = colors.HexColor(UI["light_bg"]) + + # ===== TITLE PAGE ===== + story.append(Spacer(1, 1*cm)) + + # Logo + if logo_path and logo_path.exists(): + try: + story.append(Image(str(logo_path), width=3*cm, height=3*cm)) + story.append(Spacer(1, 0.5*cm)) + except Exception: + pass + + story.append(Paragraph("Webbplatsanalys", s["title"])) + story.append(Paragraph(site_display, ParagraphStyle( + "Sub", parent=s["title"], fontSize=20, textColor=colors.HexColor(UI["muted"]) + ))) + + if customer_name: + story.append(Spacer(1, 0.3*cm)) + story.append(Paragraph(f"Kund: {customer_name}", ParagraphStyle( + "Customer", parent=s["body"], fontSize=11, alignment=TA_CENTER + ))) + + story.append(Spacer(1, 1*cm)) + + # Overall score gauge + overall = data["summary"]["score"] * 100 + gauge = create_gauge_chart(overall, "Övergripande betyg") + story.append(Image(gauge, width=10*cm, height=8*cm)) + + story.append(Spacer(1, 0.5*cm)) + + # Status assessment + if overall < 50: + story.append(Paragraph( + "Webbplatsen har betydande prestandaproblem som kräver omedelbar åtgärd.", + s["alert"])) + elif overall < 75: + story.append(Paragraph( + "Webbplatsen har flera förbättringsområden som påverkar användarupplevelse och SEO.", + s["warning"])) + + story.append(Spacer(1, 0.5*cm)) + + # Key stats table + stats_data = [ + ["Analyserade sidor", str(page_count)], + ["Identifierade problemtyper", str(len(all_audits))], + ["Resursproblem", str(len(resource_issues))], + ["Elementproblem", str(len(element_issues))], + ["Rapport genererad", datetime.now().strftime("%Y-%m-%d")], + ] + stats_table = Table(stats_data, colWidths=[8*cm, 5*cm]) + stats_table.setStyle(TableStyle([ + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("ALIGN", (1, 0), (1, -1), "RIGHT"), + ("TEXTCOLOR", (0, 0), (0, -1), colors.HexColor(UI["muted"])), + ("TEXTCOLOR", (1, 0), (1, -1), brand_dark), + ("FONTNAME", (1, 0), (1, -1), "Helvetica-Bold"), + ("LINEBELOW", (0, -1), (-1, -1), 1, brand_primary), + ])) + story.append(stats_table) + + if consultant_name: + story.append(Spacer(1, 0.5*cm)) + story.append(Paragraph(f"Utförd av: {consultant_name}", s["footer"])) + + story.append(PageBreak()) + + # ===== EXECUTIVE SUMMARY ===== + story.append(Paragraph("Sammanfattning", s["h1"])) + + summary_text = f""" + Denna rapport presenterar resultatet av en teknisk analys av webbplatsen {site_display}. + Totalt har {page_count} sidor granskats med avseende på prestanda, tillgänglighet, + bästa praxis och sökmotoroptimering. + """ + story.append(Paragraph(summary_text, s["body"])) + + # Performance summary + perf_score = data["summary"]["categories"]["performance"]["averageScore"] * 100 + a11y_score = data["summary"]["categories"]["accessibility"]["averageScore"] * 100 + bp_score = data["summary"]["categories"]["best-practices"]["averageScore"] * 100 + seo_score = data["summary"]["categories"]["seo"]["averageScore"] * 100 + + findings = [] + if perf_score < 70: + findings.append(f"Prestanda ({perf_score:.0f}%): Problem med laddningstider och rendering.") + if a11y_score < 90: + findings.append(f"Tillgänglighet ({a11y_score:.0f}%): Brister som påverkar användare.") + if bp_score < 80: + findings.append(f"Bästa praxis ({bp_score:.0f}%): Tekniska brister.") + if seo_score < 90: + findings.append(f"SEO ({seo_score:.0f}%): Optimeringsmöjligheter.") + + if findings: + story.append(Paragraph("Huvudsakliga fynd:", s["body"])) + for f in findings: + story.append(Paragraph(f"• {f}", s["body"])) + + story.append(Spacer(1, 0.3*cm)) + + # Category chart + cat_chart = create_overview_chart(data["summary"]["categories"], category_names) + story.append(Image(cat_chart, width=16*cm, height=6.5*cm)) + + story.append(Spacer(1, 0.3*cm)) + + # Category table + cat_table_data = [["Kategori", "Medel", "Lägsta", "Högsta", "Bedömning"]] + for cat_id, cat_info in data["summary"]["categories"].items(): + avg = cat_info["averageScore"] * 100 + scores = cat_info["scores"] + min_s, max_s = min(scores) * 100, max(scores) * 100 + + if avg >= 90: + status = "✓ Godkänt" + elif avg >= 50: + status = "⚠ Kräver åtgärd" + else: + status = "✗ Kritiskt" + + cat_table_data.append([ + category_names.get(cat_id, cat_id), + f"{avg:.0f}%", f"{min_s:.0f}%", f"{max_s:.0f}%", status + ]) + + t = Table(cat_table_data, colWidths=[5*cm, 2*cm, 2*cm, 2*cm, 3*cm]) + t.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), brand_dark), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("ALIGN", (1, 0), (-1, -1), "CENTER"), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, border_color), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, light_bg]) + ])) + story.append(t) + + story.append(PageBreak()) + + # ===== CORE WEB VITALS ===== + story.append(Paragraph("Core Web Vitals", s["h1"])) + story.append(Paragraph( + "Core Web Vitals är Googles officiella mätvärden för användarupplevelse. " + "Dessa påverkar direkt webbplatsens ranking i sökresultat.", s["body"])) + + cwv_chart = create_core_web_vitals_chart(data["summary"]["metrics"]) + story.append(Image(cwv_chart, width=16*cm, height=5*cm)) + + # Metrics table + metrics_data = [["Mätvärde", "Värde", "Bra", "Dåligt", "Status"]] + metric_info = [ + ("LCP (Largest Contentful Paint)", "largest-contentful-paint", 1000, "s", 2.5, 4.0), + ("FCP (First Contentful Paint)", "first-contentful-paint", 1000, "s", 1.8, 3.0), + ("CLS (Cumulative Layout Shift)", "cumulative-layout-shift", 1, "", 0.1, 0.25), + ("TBT (Total Blocking Time)", "total-blocking-time", 1, "ms", 200, 600), + ("TTI (Time to Interactive)", "interactive", 1000, "s", 3.8, 7.3), + ] + + for name, key, divisor, unit, good, bad in metric_info: + val = data["summary"]["metrics"].get(key, {}).get("averageNumericValue", 0) / divisor + status = "✓" if val <= good else "⚠" if val <= bad else "✗" + metrics_data.append([name, f"{val:.2f}{unit}", f"≤{good}{unit}", f">{bad}{unit}", status]) + + mt = Table(metrics_data, colWidths=[6.5*cm, 2.5*cm, 2*cm, 2*cm, 1.5*cm]) + mt.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), brand_dark), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("ALIGN", (1, 0), (-1, -1), "CENTER"), + ("FONTSIZE", (0, 0), (-1, -1), 8), + ("GRID", (0, 0), (-1, -1), 0.5, border_color), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, light_bg]) + ])) + story.append(mt) + + story.append(PageBreak()) + + # ===== SCORE DISTRIBUTION ===== + story.append(Paragraph("Poängfördelning per kategori", s["h1"])) + story.append(Paragraph( + f"Följande diagram visar hur de {page_count} sidorna presterar. " + "Majoriteten bör ligga i det gröna området (≥90).", s["body"])) + + hist_chart = create_score_histogram(data.get("routes", []), category_names) + story.append(Image(hist_chart, width=16*cm, height=11*cm)) + + story.append(PageBreak()) + + # ===== MOST COMMON ISSUES ===== + story.append(Paragraph("Vanligaste problemen", s["h1"])) + story.append(Paragraph( + "Problem som påverkar >80% av sidorna indikerar systemiska brister.", s["body"])) + + issues_chart = create_issues_chart(all_audits, page_count, 12) + story.append(Image(issues_chart, width=16*cm, height=10*cm)) + + story.append(PageBreak()) + + # ===== POTENTIAL SAVINGS ===== + story.append(Paragraph("Potentiella besparingar", s["h1"])) + + total_savings = sum(a["total_wasted_bytes"] for a in all_audits.values()) + story.append(Paragraph( + f"Total potentiell besparing: {format_bytes(total_savings)}", s["body_bold"])) + + savings_chart = create_savings_chart(all_audits) + if savings_chart: + story.append(Image(savings_chart, width=15*cm, height=6*cm)) + + story.append(PageBreak()) + + # ===== DETAILED ISSUES ===== + story.append(Paragraph("Detaljerade problem", s["h1"])) + + for group_id, group_info in AUDIT_GROUPS.items(): + group_audits = [] + for audit_id, audit_data in all_audits.items(): + for pattern in group_info["audits"]: + if pattern.endswith("-"): + if audit_id.startswith(pattern): + group_audits.append((audit_id, audit_data)) + break + elif audit_id == pattern: + group_audits.append((audit_id, audit_data)) + break + + if not group_audits: + continue + + group_audits.sort(key=lambda x: x[1]["count"], reverse=True) + total_in_group = sum(a[1]["count"] for a in group_audits) + if total_in_group == 0: + continue + + label = "ENGÅNGSÅTGÄRD" if group_info["onetime"] else "SIDSPECIFIK" + story.append(Paragraph( + f"{group_info['title']} [{label}]", s["h2"])) + story.append(Paragraph(group_info["description"], s["small"])) + + for audit_id, audit_data in group_audits[:8]: + if audit_data["count"] == 0: + continue + + title = xml_escape(audit_data["title"][:65]) + pct = (audit_data["count"] / page_count) * 100 + + story.append(Paragraph(f"• {title}", s["body"])) + + details = [f"{audit_data['count']}/{page_count} sidor ({pct:.0f}%)"] + if audit_data["total_wasted_bytes"] > 1024: + details.append(f"Besparing: {format_bytes(audit_data['total_wasted_bytes'])}") + + story.append(Paragraph(" " + " | ".join(details), s["small"])) + + story.append(Spacer(1, 0.2*cm)) + + story.append(PageBreak()) + + # ===== RESOURCE ISSUES ===== + story.append(Paragraph("Resurser som kräver åtgärd", s["h1"])) + + resource_by_audit = defaultdict(list) + for (audit_id, url), rdata in resource_issues.items(): + if rdata["count"] >= 10 and rdata["wasted_bytes"] > 5000: + resource_by_audit[audit_id].append((url, rdata)) + + for audit_id in ["unused-css-rules", "unused-javascript", "render-blocking-resources", + "modern-image-formats", "uses-long-cache-ttl"]: + resources = resource_by_audit.get(audit_id, []) + if not resources: + continue + + audit_title = all_audits.get(audit_id, {}).get("title", audit_id) + story.append(Paragraph(f"{xml_escape(audit_title[:50])}", s["h3"])) + + resources.sort(key=lambda x: x[1]["wasted_bytes"], reverse=True) + + resource_table = [["Resurs", "Sidor", "Bortkastade data"]] + for url, rdata in resources[:8]: + url_display = "..." + url[-42:] if len(url) > 45 else url + resource_table.append([ + url_display, str(rdata["count"]), format_bytes(rdata["wasted_bytes"]) + ]) + + if len(resource_table) > 1: + rt = Table(resource_table, colWidths=[9*cm, 2*cm, 3*cm]) + rt.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), brand_dark), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTSIZE", (0, 0), (-1, -1), 7), + ("FONTNAME", (0, 1), (0, -1), "Courier"), + ("ALIGN", (1, 0), (-1, -1), "CENTER"), + ("GRID", (0, 0), (-1, -1), 0.5, border_color), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, light_bg]) + ])) + story.append(rt) + story.append(Spacer(1, 0.2*cm)) + + story.append(PageBreak()) + + # ===== WORST PAGES ===== + story.append(Paragraph("Sidor som kräver mest arbete", s["h1"])) + + routes = data.get("routes", []) + for cat_id, cat_name in [("performance", "Prestanda"), ("accessibility", "Tillgänglighet")]: + story.append(Paragraph(f"{cat_name} – sämsta 10", s["h2"])) + + sorted_routes = sorted( + routes, key=lambda r: r.get("categories", {}).get(cat_id, {}).get("score", 1) + )[:10] + + worst_table = [["Sida", "Poäng"]] + for route in sorted_routes: + score = route.get("categories", {}).get(cat_id, {}).get("score", 1) * 100 + path = route.get("path", "/") + if len(path) > 50: + path = path[:47] + "..." + worst_table.append([path, f"{score:.0f}%"]) + + wt = Table(worst_table, colWidths=[12*cm, 2.5*cm]) + wt.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), brand_dark), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTSIZE", (0, 0), (-1, -1), 8), + ("ALIGN", (1, 0), (1, -1), "CENTER"), + ("GRID", (0, 0), (-1, -1), 0.5, border_color), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, light_bg]) + ])) + story.append(wt) + story.append(Spacer(1, 0.3*cm)) + + story.append(PageBreak()) + + # ===== RECOMMENDATIONS ===== + story.append(Paragraph("Rekommenderade åtgärder", s["h1"])) + + story.append(Paragraph("Fas 1: Engångsåtgärder", s["h2"])) + story.append(Paragraph("Åtgärdas en gång, förbättrar alla sidor automatiskt.", s["small"])) + + phase1 = [ + ("Bildoptimering", + f"WebP-konvertering och komprimering. Besparing: {format_bytes(all_audits.get('modern-image-formats', {}).get('total_wasted_bytes', 0))}.", + "2-4 tim"), + ("Caching och minifiering", + "Aktivera minifiering av CSS/JS, lazy loading och browser caching.", + "4-6 tim"), + ("Render-blocking resurser", + "Kritisk CSS-inlining och uppskjuten JavaScript-laddning.", + "2-4 tim"), + ("Console-fel", + f"Åtgärda JavaScript-fel på {all_audits.get('errors-in-console', {}).get('count', 0)} sidor.", + "2-4 tim"), + ] + + for title, desc, time in phase1: + story.append(Paragraph(f"{title} [{time}]", s["body"])) + story.append(Paragraph(desc, s["small"])) + + story.append(Spacer(1, 0.3*cm)) + story.append(Paragraph("Fas 2: Sidspecifika förbättringar", s["h2"])) + + phase2 = [ + ("Färgkontrast", + f"{all_audits.get('color-contrast', {}).get('count', 0)} sidor har otillräcklig kontrast.", + f"{all_audits.get('color-contrast', {}).get('count', 0) * 15} min"), + ("Länktexter", + f"{all_audits.get('link-name', {}).get('count', 0)} sidor har länkar utan beskrivande text.", + f"{all_audits.get('link-name', {}).get('count', 0) * 10} min"), + ("Layout Shift (CLS)", + "Definiera dimensioner på bilder, undvik dynamiskt innehåll.", + "4-8 tim"), + ] + + for title, desc, time in phase2: + story.append(Paragraph(f"{title} [{time}]", s["body"])) + story.append(Paragraph(desc, s["small"])) + + story.append(Spacer(1, 0.5*cm)) + + # Time estimate + story.append(Paragraph("Sammanfattning arbetsinsats", s["h2"])) + + total_time_low = int(15 + page_count * 0.25) + total_time_high = int(28 + page_count * 0.3) + + time_data = [ + ["Fas", "Åtgärd", "Tid", "Påverkan"], + ["1", "Bildoptimering + CDN", "4-8 tim", f"{page_count} sidor"], + ["1", "Caching & minifiering", "4-6 tim", f"{page_count} sidor"], + ["1", "Grundläggande a11y", "1-2 tim", f"{page_count} sidor"], + ["1", "Felsökning", "2-4 tim", f"{page_count} sidor"], + ["2", "Färgkontrast & länkar", f"{int(page_count * 0.2)}-{int(page_count * 0.3)} tim", "Specifika sidor"], + ["2", "CLS-optimering", "4-8 tim", "Berörda mallar"], + ["", "TOTALT ESTIMAT", f"{total_time_low}-{total_time_high} tim", ""], + ] + + tt = Table(time_data, colWidths=[1.2*cm, 7*cm, 3*cm, 3*cm]) + tt.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), brand_dark), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("ALIGN", (0, 0), (0, -1), "CENTER"), + ("ALIGN", (2, 0), (3, -1), "CENTER"), + ("GRID", (0, 0), (-1, -1), 0.5, border_color), + ("ROWBACKGROUNDS", (0, 1), (-1, -2), [colors.white, light_bg]), + ("BACKGROUND", (0, -1), (-1, -1), brand_primary), + ("TEXTCOLOR", (0, -1), (-1, -1), colors.white), + ("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"), + ("FONTSIZE", (0, -1), (-1, -1), 10), + ])) + story.append(tt) + + # Build PDF + doc.build(story) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..900e245 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# Webbplatsanalys Report Generator +# Install: pip install -r requirements.txt + +reportlab>=4.0 +matplotlib>=3.7 +numpy>=1.24 +pillow>=9.0 + diff --git a/s-logo.png b/s-logo.png new file mode 100644 index 0000000..1ebfafb Binary files /dev/null and b/s-logo.png differ diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..1c13d0c --- /dev/null +++ b/shell.nix @@ -0,0 +1,17 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + buildInputs = with pkgs; [ + python313 + python313Packages.reportlab + python313Packages.matplotlib + python313Packages.numpy + python313Packages.pillow # For logo handling + python313Packages.cairosvg # For SVG logo conversion + ]; + + shellHook = '' + echo "Webbplatsanalys redo. Kör: python webbanalys.py" + ''; +} + diff --git a/unlighthouse.config.ts b/unlighthouse.config.ts new file mode 100644 index 0000000..86270f0 --- /dev/null +++ b/unlighthouse.config.ts @@ -0,0 +1,6 @@ +export default defineUnlighthouseConfig({ + server: { + open: false, + }, +}) + diff --git a/webbanalys.py b/webbanalys.py new file mode 100644 index 0000000..d05d7ec --- /dev/null +++ b/webbanalys.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +""" +╔═══════════════════════════════════════════════════════════════════════╗ +║ WEBBPLATSANALYS RAPPORTGENERATOR ║ +║ ║ +║ Analysera webbplatser och generera professionella PDF-rapporter ║ +╚═══════════════════════════════════════════════════════════════════════╝ + +Usage: + python webbanalys.py scan # Run analysis + python webbanalys.py generate # Generate PDF report + python webbanalys.py run # Scan + generate in one step + +Requirements (NixOS): + nix-shell shell.nix + +Requirements (pip): + pip install -r requirements.txt +""" + +import argparse +import json +import subprocess +import shutil +import sys +from pathlib import Path +from datetime import datetime + +# Ensure lib is importable +sys.path.insert(0, str(Path(__file__).parent)) + +from lib.analyzer import load_ci_result, load_all_audits +from lib.pdf_report import generate_pdf + + +BANNER = """ +╔═══════════════════════════════════════════════════════════════════════╗ +║ WEBBPLATSANALYS RAPPORTGENERATOR ║ +╚═══════════════════════════════════════════════════════════════════════╝ +""" + + +def load_config(config_path: Path) -> dict: + """Load project configuration from JSON file.""" + if config_path.exists(): + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) + return {} + + +def save_config(config_path: Path, config: dict): + """Save project configuration to JSON file.""" + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + + +def find_data_files(base_path: Path) -> tuple[Path, Path]: + """Find ci-result.json and reports directory.""" + search_paths = [ + base_path / "rapport", + base_path / ".unlighthouse", + base_path, + ] + + ci_result = None + reports_dir = None + + for path in search_paths: + if not path.exists(): + continue + + candidate = path / "ci-result.json" + if candidate.exists(): + ci_result = candidate + + candidate = path / "reports" + if candidate.exists() and candidate.is_dir(): + reports_dir = candidate + + if ci_result and reports_dir: + break + + return ci_result, reports_dir + + +def find_logo(base_path: Path) -> Path: + """Find logo file in project.""" + # Prefer PNG/JPG, then SVG + for pattern in ["*logo*.png", "*logo*.jpg", "*.png", "*.jpg"]: + for f in base_path.glob(pattern): + if f.is_file() and f.suffix.lower() in [".png", ".jpg", ".jpeg"]: + return f + # Check for SVG (will be converted) + for pattern in ["*logo*.svg", "*.svg"]: + for f in base_path.glob(pattern): + if f.is_file(): + return f + return None + + +def convert_svg_to_png(svg_path: Path) -> Path: + """Convert SVG to PNG for PDF embedding.""" + try: + import cairosvg + png_path = svg_path.with_suffix(".png") + if not png_path.exists() or png_path.stat().st_mtime < svg_path.stat().st_mtime: + cairosvg.svg2png(url=str(svg_path), write_to=str(png_path), output_width=200) + return png_path + except ImportError: + return None + except Exception: + return None + + +def cmd_generate(args, base_path: Path): + """Generate PDF report.""" + config_path = base_path / "webbanalys.json" + config = load_config(config_path) + + # Find data files + print("\n🔍 Söker efter analysdata...") + + ci_result_path, reports_dir = find_data_files(base_path) + + if not ci_result_path: + print("❌ Kunde inte hitta ci-result.json") + print(" Kör först: unlighthouse-ci --site ") + print(f" Sökte i: {base_path}") + sys.exit(1) + + if not reports_dir: + print("❌ Kunde inte hitta reports/ katalog") + sys.exit(1) + + print(f" ✓ Hittade data i: {ci_result_path.parent}") + + # Determine parameters (CLI > config > default) + customer = args.customer or config.get("customer", "") + site_url = args.url or config.get("site_url", "") + consultant = args.consultant or config.get("consultant", "") + + # Find logo + logo_path = None + if args.logo: + logo_path = Path(args.logo) + elif config.get("logo"): + logo_path = base_path / config["logo"] + else: + logo_path = find_logo(base_path) + + # Convert SVG to PNG if needed + if logo_path and logo_path.exists(): + if logo_path.suffix.lower() == ".svg": + png_path = convert_svg_to_png(logo_path) + if png_path: + logo_path = png_path + print(f" ✓ Logotyp: {logo_path.name} (konverterad från SVG)") + else: + print(f" ⚠ Kunde inte konvertera SVG-logotyp") + logo_path = None + else: + print(f" ✓ Logotyp: {logo_path.name}") + + # Load data + print("\n📊 Laddar analysdata...") + data = load_ci_result(ci_result_path) + + # Extract site URL from data if not provided + if not site_url: + routes = data.get("routes", []) + if routes: + first_path = routes[0].get("path", "") + # Try to construct from common patterns + for route in routes[:5]: + path = route.get("path", "") + if path.startswith("http"): + site_url = "/".join(path.split("/")[:3]) + break + + print(f" Webbplats: {site_url or '(okänd)'}") + if customer: + print(f" Kund: {customer}") + + # Analyze + print("\n🔬 Analyserar sidor...") + all_audits, resource_issues, element_issues, page_count = load_all_audits( + reports_dir, site_url + ) + + print(f" Sidor analyserade: {page_count}") + print(f" Problemtyper: {len(all_audits)}") + print(f" Resursproblem: {len(resource_issues)}") + print(f" Elementproblem: {len(element_issues)}") + + # Overall score + overall = data["summary"]["score"] * 100 + if overall >= 90: + score_emoji = "🟢" + elif overall >= 50: + score_emoji = "🟡" + else: + score_emoji = "🔴" + print(f"\n Övergripande betyg: {score_emoji} {overall:.0f}/100") + + # Determine output path + if args.output: + output_path = Path(args.output) + else: + date_str = datetime.now().strftime("%Y%m%d") + site_slug = site_url.replace("https://", "").replace("http://", "").replace("/", "_").replace(".", "_") + output_path = base_path / f"rapport_{site_slug}_{date_str}.pdf" + + # Generate report + print(f"\n📝 Genererar rapport...") + generate_pdf( + output_path=output_path, + data=data, + all_audits=all_audits, + resource_issues=resource_issues, + element_issues=element_issues, + page_count=page_count, + customer_name=customer, + site_url=site_url, + logo_path=logo_path if logo_path and logo_path.exists() else None, + consultant_name=consultant + ) + + file_size = output_path.stat().st_size / 1024 + print(f"\n✅ Rapport genererad!") + print(f" 📄 {output_path}") + print(f" 📦 {file_size:.0f} KB") + + # Offer to save config + if not config and (customer or site_url): + print("\n💾 Spara inställningar för framtida rapporter? (y/n): ", end="") + try: + if input().lower() == "y": + new_config = {} + if customer: + new_config["customer"] = customer + if site_url: + new_config["site_url"] = site_url + if consultant: + new_config["consultant"] = consultant + if logo_path: + new_config["logo"] = str(logo_path.relative_to(base_path)) + save_config(config_path, new_config) + print(f" ✓ Sparad till {config_path.name}") + except (EOFError, KeyboardInterrupt): + pass + + +def cmd_init(args, base_path: Path): + """Initialize project configuration.""" + config_path = base_path / "webbanalys.json" + + print("\n🔧 Konfigurera projekt") + print(" (tryck Enter för att hoppa över)\n") + + config = load_config(config_path) + + try: + customer = input(f" Kundnamn [{config.get('customer', '')}]: ").strip() + if customer: + config["customer"] = customer + + site_url = input(f" Webbplats URL [{config.get('site_url', '')}]: ").strip() + if site_url: + config["site_url"] = site_url + + consultant = input(f" Konsultnamn [{config.get('consultant', '')}]: ").strip() + if consultant: + config["consultant"] = consultant + + save_config(config_path, config) + print(f"\n✅ Konfiguration sparad till {config_path.name}") + + except (EOFError, KeyboardInterrupt): + print("\n Avbrutet.") + + +def cmd_info(args, base_path: Path): + """Show project information.""" + config_path = base_path / "webbanalys.json" + config = load_config(config_path) + + print("\n📋 Projektinformation") + print(f" Mapp: {base_path}") + + if config: + print(f"\n Konfiguration ({config_path.name}):") + for key, value in config.items(): + print(f" {key}: {value}") + else: + print(f"\n ⚠ Ingen konfiguration hittad") + print(f" Kör: python webbanalys.py init") + + # Check for data + ci_result, reports_dir = find_data_files(base_path) + print(f"\n Analysdata:") + if ci_result: + print(f" ✓ ci-result.json: {ci_result}") + else: + print(f" ✗ ci-result.json saknas") + + if reports_dir: + report_count = len(list(reports_dir.glob("*/lighthouse.json"))) + print(f" ✓ reports/: {report_count} sidor") + else: + print(f" ✗ reports/ saknas") + + # Check for logo + logo = find_logo(base_path) + if logo: + print(f" ✓ Logotyp: {logo.name}") + + +def cmd_scan(args, base_path: Path): + """Run Unlighthouse scan.""" + config_path = base_path / "webbanalys.json" + config = load_config(config_path) + + # Determine URL + site_url = args.url or config.get("site_url", "") + if not site_url: + print("❌ Ingen URL angiven.") + print(" Använd: python webbanalys.py scan https://example.com") + print(" Eller kör 'python webbanalys.py init' först") + sys.exit(1) + + # Ensure URL has protocol + if not site_url.startswith("http"): + site_url = "https://" + site_url + + samples = args.samples or config.get("samples", 3) + output_path = base_path / "rapport" + + print(f"\n🔍 Startar webbanalys...") + print(f" URL: {site_url}") + print(f" Samples: {samples}") + print(f" Output: {output_path}") + + # Check for pnpm + pnpm_path = shutil.which("pnpm") + npx_path = shutil.which("npx") + + if pnpm_path: + cmd = [ + "pnpm", "dlx", "-p", "@unlighthouse/cli", + "unlighthouse-ci", + "--site", site_url, + "--samples", str(samples), + "--output-path", str(output_path), + "--reporter", "jsonExpanded" + ] + elif npx_path: + cmd = [ + "npx", "-y", "@unlighthouse/cli", + "unlighthouse-ci", + "--site", site_url, + "--samples", str(samples), + "--output-path", str(output_path), + "--reporter", "jsonExpanded" + ] + else: + print("❌ Varken pnpm eller npx hittades.") + print(" Installera Node.js och pnpm/npm först.") + sys.exit(1) + + print(f"\n Kör: {' '.join(cmd)}\n") + print("─" * 70) + + try: + result = subprocess.run(cmd, cwd=base_path) + print("─" * 70) + + if result.returncode == 0: + print("\n✅ Analys klar!") + + # Update config with URL + if site_url and site_url != config.get("site_url"): + config["site_url"] = site_url + config["samples"] = samples + save_config(config_path, config) + + return True + else: + print(f"\n❌ Analys misslyckades (kod {result.returncode})") + return False + + except KeyboardInterrupt: + print("\n\n⚠ Avbruten av användaren") + return False + except Exception as e: + print(f"\n❌ Fel: {e}") + return False + + +def cmd_run(args, base_path: Path): + """Run scan + generate in one step.""" + # First scan + if cmd_scan(args, base_path): + print("\n" + "═" * 70 + "\n") + # Then generate + cmd_generate(args, base_path) + + +def main(): + parser = argparse.ArgumentParser( + description="Analysera webbplatser och generera PDF-rapporter", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Kommandon: + scan Kör Unlighthouse-analys + generate Generera PDF-rapport från befintlig data + run Kör analys + generera rapport (allt-i-ett) + init Konfigurera projekt + info Visa projektinformation + +Exempel: + python webbanalys.py scan https://example.com + python webbanalys.py scan -u https://example.com -s 5 + python webbanalys.py generate -c "Företag AB" + python webbanalys.py run https://example.com -c "Företag AB" + """ + ) + + subparsers = parser.add_subparsers(dest="command", help="Kommando") + + # Scan command + scan_parser = subparsers.add_parser("scan", help="Kör Unlighthouse-analys") + scan_parser.add_argument("url", nargs="?", help="Webbplats-URL") + scan_parser.add_argument("--samples", "-s", type=int, default=3, help="Antal samples per sida (default: 3)") + + # Generate command + gen_parser = subparsers.add_parser("generate", help="Generera PDF-rapport") + gen_parser.add_argument("--customer", "-c", help="Kundnamn") + gen_parser.add_argument("--url", "-u", help="Webbplats-URL") + gen_parser.add_argument("--consultant", help="Konsultnamn") + gen_parser.add_argument("--logo", "-l", help="Sökväg till logotyp (PNG/SVG)") + gen_parser.add_argument("--output", "-o", help="Utdatafil") + + # Run command (scan + generate) + run_parser = subparsers.add_parser("run", help="Analysera + generera rapport") + run_parser.add_argument("url", nargs="?", help="Webbplats-URL") + run_parser.add_argument("--samples", "-s", type=int, default=3, help="Antal samples per sida (default: 3)") + run_parser.add_argument("--customer", "-c", help="Kundnamn") + run_parser.add_argument("--consultant", help="Konsultnamn") + run_parser.add_argument("--logo", "-l", help="Sökväg till logotyp (PNG/SVG)") + run_parser.add_argument("--output", "-o", help="Utdatafil") + + # Init command + subparsers.add_parser("init", help="Konfigurera projekt") + + # Info command + subparsers.add_parser("info", help="Visa projektinformation") + + args = parser.parse_args() + base_path = Path.cwd() + + print(BANNER) + + if args.command == "scan": + cmd_scan(args, base_path) + elif args.command == "generate": + cmd_generate(args, base_path) + elif args.command == "run": + cmd_run(args, base_path) + elif args.command == "init": + cmd_init(args, base_path) + elif args.command == "info": + cmd_info(args, base_path) + else: + # Default: show help + parser.print_help() + print("\n💡 Snabbstart:") + print(" python webbanalys.py run https://example.com -c 'Företag AB'") + + +if __name__ == "__main__": + main() +