inital commit

This commit is contained in:
2026-05-25 22:27:18 +02:00
commit 965f9d6ad4
91 changed files with 28963 additions and 0 deletions

674
LICENSE Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.

142
README.md Normal file
View File

@@ -0,0 +1,142 @@
# go-fa-api
Go 1.25+ SDK for [FurAffinity](https://www.furaffinity.net). FA has no JSON
API, so this library scrapes the beta theme via [Colly](https://github.com/gocolly/colly)
internally and exposes a strongly typed surface.
## Features
| Area | What you get |
|------|--------------|
| **Read** | `GetSubmission` · `GetUser` · `GetJournal` · `Download` |
| **Iterators** (`iter.Seq2`) | `Gallery` · `Scraps` · `Favorites` · `UserJournals` · `SubmissionComments` · `JournalComments` |
| **Discovery** | `Browse(BrowseOptions)` · `Search(query, SearchOptions)` full filter struct (rating/type/order/range/etc.) |
| **Inbox** | `SubmissionInbox` (watched-users feed) · `Notes` · `GetNote` · `Notifications` (journals/watches/comments/favs/shouts) |
| **Write** | `Fav`/`Unfav` · `Watch`/`Unwatch` · `PostSubmissionComment` · `PostJournalComment` · `SendNote` |
| **Auth** | `WithCookies(a, b)` · `WithCloudflare(cf_clearance)` · `WithUserAgent` · `WithSFW(SFWOn/Off/Auto)` |
| **Networking** | Built-in 1 req/s rate limiter · Cloudflare challenge detection · 429 `Retry-After` · 5xx exponential backoff · `context.Context` end-to-end |
| **Experimental** | `WithExperimentalJSONListings(true)` listing pages merge from FA's embedded `js-submissionData` JSON first, HTML fallback if it's absent. More resilient to markup drift; off by default. |
Pre-1.0: expect breaking changes. Beta theme only; classic theme is not parsed.
## Install
```bash
go get git.anthrove.art/public/go-fa-api
```
## Quickstart
```go
package main
import (
"context"
"fmt"
"log"
"os"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
client := fa.New(
fa.WithCookies(fa.Cookies{A: os.Getenv("FA_A"), B: os.Getenv("FA_B")}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(os.Getenv("FA_UA")),
)
sub, err := client.GetSubmission(context.Background(), 12345678)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s by %s\n", sub.Title, sub.Author.DisplayName)
for sub, err := range client.Gallery(context.Background(), "someuser", fa.ListOptions{MaxPages: 3}) {
if err != nil {
log.Fatal(err)
}
fmt.Println(sub.ID, sub.Title)
}
}
```
Runnable examples in `examples/`: `basic`, `gallery_dump`, `download`, `browse`, `search`, `inbox`, `notes`, `notifications`.
## Auth
FA's auth is cookie-based; Cloudflare gates submissions behind a clearance
cookie bound to your browser's User-Agent. The SDK doesn't log in for you copy the values once:
1. Log in at https://www.furaffinity.net in your browser.
2. DevTools → **Application****Cookies** → copy `a`, `b`, and `cf_clearance`.
3. DevTools → **Network** → any FA request → copy the **User-Agent** header
*exactly*. CF binds `cf_clearance` to that UA; mismatch = challenge.
4. Pass them in via `fa.WithCookies` / `fa.WithCloudflare` / `fa.WithUserAgent`.
If FA challenges you later, your `cf_clearance` expired refresh it and the
SDK will return `fa.ErrCloudflareChallenge` so you can detect it.
> Account preference must be set to the **beta** theme (Site Preferences → Style → Beta).
## Rate limiting
Every request (page fetches *and* downloads) passes through one token bucket.
Default is **1 req/s**, lowest safe for FA. Lives inside the
`http.RoundTripper` so callers can't bypass it.
```go
fa.New(fa.WithRateLimit(2*time.Second, 1)) // explicit
fa.New(fa.WithRequestsPerSecond(0.5)) // shorthand
```
## Iterators
Paginated endpoints return `iter.Seq2[*T, error]`:
```go
for sub, err := range client.Gallery(ctx, "someuser", fa.ListOptions{}) {
if err != nil { return err }
fmt.Println(sub.ID, sub.Title)
}
```
- **Lazy** pages fetched on demand; `break` stops further fetches.
- **Terminal errors** first error yields `(nil, err)` and stops the iterator.
- **One option-shape per iterator class**: simple paginated iterators
(Gallery / Scraps / Favorites / UserJournals / SubmissionInbox / Notes)
take `fa.ListOptions{StartPage, MaxPages}`; filtered iterators
(`Search`, `Browse`) take their own `SearchOptions` / `BrowseOptions`
struct that folds the same pagination fields in.
## Errors
Test with `errors.Is`:
| Sentinel | When | What to do |
|----------|------|-----------|
| `ErrNotFound` | FA "not found" / deleted submission | surface to user |
| `ErrUnauthorized` | endpoint needs login or `Login Required` page | refresh `a`/`b` |
| `ErrCloudflareChallenge` | CF interposed a challenge | refresh `cf_clearance` |
| `ErrRateLimited` | 429 after retry budget exhausted | slow down |
| `ErrSystemMessage` | uncategorised FA system message | inspect `*SystemMessageError` |
| `ErrParse` | HTML didn't match selectors | likely FA shifted markup open an issue |
## Test fixtures
Parsers are selector-driven; FA's HTML drifts. `TestRefreshFixtures` (build
tag `fixtures`) hits live FA with your cookies and snapshots each curated
page into `testdata/html/`. Parser `*_RealFixture` tests `t.Skip` cleanly
when their file is absent, so the basic suite works without any cookies.
```bash
# Configure once (or edit scripts/test.sh):
export FA_A=... FA_B=... CF_CLEARANCE=... FA_UA='Mozilla/5.0 ...'
export FA_TEST_USER=yourlogin FA_TEST_SUB_ID=... FA_TEST_JOURNAL_ID=...
# Optional refinements: FA_TEST_SUB_STORY_ID, FA_TEST_USER_WITH_SHOUTS,
# FA_TEST_GALLERY_USER, FA_TEST_GALLERY_LAST_PAGE, FA_TEST_NOTE_ID,
# FA_TEST_SEARCH_QUERY each gates a separate fixture; unset = skip.
go test -tags=fixtures -run TestRefreshFixtures -v ./... # or ./scripts/test.sh
go test ./... # now *_RealFixture tests activate
```

268
SDK_ISSUES.md Normal file
View File

@@ -0,0 +1,268 @@
# SDK issues FA Droid
Bugs whose root cause is in the **FA SDK** (`go-fa-api`, at
`/var/home/soxx/git/go-fa-api`, `replace`d in `go.mod`) not in this app.
The SDK is a **separate module, maintained and fixed by a separate AI**
that does not see this app's code or conversation context. This file is the
handoff: every entry is a self-contained bug report meant to be copy-pasted
to that AI as-is.
App-side bugs (frontend / go-service) live in `ISSUES.md`; fixed issues in
`SOLVED.md`. Features with UI but no backend are in `STUBS.md`; Wails/Android
stack traps in `PITFALLS.md`.
**Status legend:** `[ ]` open · `[~]` diagnosed handoff brief drafted ·
`[x]` fixed in the SDK (then moved to `SOLVED.md`)
**Numbering:** issue numbers are shared across `ISSUES.md`, `SOLVED.md` and
this file draw the next free number from the same sequence.
---
## SDK handoff brief required fields
Every entry MUST carry a handoff brief detailed enough to be copy-pasted to
the SDK AI as a standalone bug report. Do not write "sdk issue?" and stop —
that is not actionable for someone without this repo open.
A handoff brief must answer all seven of:
1. **SDK entry point** the exact exported function involved
(e.g. `Client.Fav(ctx, SubmissionID)` in `actions.go`). Name the file.
2. **How the app calls it** which `internal/services/*.go` wrapper
invokes it, and with what arguments.
3. **Observed behaviour** what the SDK call returns: an error (quote it
verbatim), a wrong value, or a silent no-op.
4. **Expected behaviour** what FurAffinity should do server-side and
what the SDK should return.
5. **Reproduction** concrete inputs (submission ID, username) and the
steps that trigger it.
6. **Suspected layer** HTML parsing (FA changed its markup?), request
shape (wrong form fields / missing CSRF / wrong URL), auth/cookies, or
rate-limiting. Point at the likely file: parsers are `*_parser.go`,
form posts are in `actions.go` / `*_post.go` / `*_send.go`.
7. **What is NOT the SDK** explicitly rule out the app layer so the SDK
AI does not chase a frontend bug. State what you verified.
If you cannot yet fill all seven fields, the issue stays `[~]` and the brief
lists exactly which diagnostics are still needed never hand over a
half-brief as if it were complete.
---
## Issues
### #4 [~] Audit the FA SDK for bugs
- **Where:** `go-fa-api` (sibling module, `replace`d in `go.mod`).
- **Task:** Standing umbrella review the SDK surface used by
`internal/services/` for incorrect parsing, missing endpoints, or wrong
request shapes.
- **Cause tag:** `sdk`
- **Progress:** The first audit pass produced #1, #5, #9, #10 and #14 **all
now fixed and verified** (#1/#5/#9/#10 in app v0.0.7, #14 in v0.0.10; see
`SOLVED.md`). #15 was then fixed too (SDK gave `WithResolvedAvatars` a
count limit; app v0.0.14). #17 (priority rate limiting) was implemented too
— verified in app v0.0.16. **No open SDK issues currently.** Keep this
entry as the umbrella for future audit passes; file each concrete finding
as its own numbered issue.
_No open SDK issues. Fixed ones are recorded in `SOLVED.md`._
---
## Add new SDK issues below
<!-- #N title (N = next free number, shared with ISSUES.md)
- Where:
- Symptom:
- Expected:
- Cause tag: sdk
- SDK handoff brief:
1. Entry point:
2. How the app calls it:
3. Observed behaviour:
4. Expected behaviour:
5. Reproduction:
6. Suspected layer:
7. What is NOT the SDK:
-->
### #18 [x] Rate limiter: upgrade from 2-level to N-level priority
**Implemented in the SDK update** `options.go` now exposes `WithPriority`
and a multi-level `Priority` type; `WithPrioritizedRateLimiting` honours it.
FA Droid follow-up (assign tiers to reads/writes/preload/crawl) is tracked as
app-side work, not an SDK issue.
- **Component:** `go-fa-api` `ratelimit.go`, `options.go`.
- **Today:** the limiter has exactly two priorities. `WithBackgroundPriority(ctx)`
marks a request background; everything else is foreground. Background
requests wait until no foreground request is queued. Enabled via
`WithPrioritizedRateLimiting(true)`.
- **Need:** a consumer (FA Droid) wants *three+* tiers, not two:
1. user-interactive (the page on screen, user write actions),
2. speculative neighbor preload (likely-next submissions),
3. bulk background crawl (inbox/watchlist warming).
With only two levels, neighbor preload must either compete with the live
page (tier 1) or interleave with the bulk crawl (tier 2) neither is right.
- **Proposed API:** keep `WithBackgroundPriority` working (maps to the lowest
level) and add `WithPriority(ctx, Priority)` where `Priority` is an ordered
enum, e.g. `PriorityInteractive`, `PriorityNormal` (default),
`PriorityLow`, `PriorityBackground`. The limiter serves waiting goroutines
highest-priority-first; the token emission rate is unchanged.
- **Compatibility:** default (no marker) must remain `PriorityNormal`;
`WithBackgroundPriority` must remain equivalent to `PriorityBackground`.
- **Why it matters to the app:** FA Droid will then assign tier 1 to
current-page reads + the write-action queue worker, tier `PriorityLow` to
neighbor preload, and `PriorityBackground` to the inbox crawler.
### #21 [x] GetSubmission doesn't expose the viewer's favorite state
- **Where:** `go-fa-api` `submission.go` (`Submission` struct + `parseSubmission`).
- **Symptom:** A submission the logged-in user has favorited shows the
favorite heart as *empty* on the app's submission detail page. There is no
way to know a submission's favorite state from a `GetSubmission` result.
- **Expected:** `GetSubmission` should report whether the authenticated viewer
has favorited the submission, so the UI can render the correct heart state.
- **Cause tag:** `sdk`
- **SDK handoff brief:**
1. **Entry point:** `Client.GetSubmission(ctx, SubmissionID)` in
`submission.go`, which builds the result via `parseSubmission(id, doc)`.
The returned `Submission` struct (`submission.go:17-38`) has fields
ID, Title, Author, PostedAt, Rating, Category, Type, Species, Gender,
Description, DescriptionText, Tags, FileURL, ThumbURL, Width, Height,
Stats, Folders, Prev, Next and **nothing indicating favorite state**.
2. **How the app calls it:** `SubmissionService.getCached` in
`internal/services/submission.go` calls `GetSubmission`, then
`dto.FromSubmission` (`internal/dto/types.go`) copies the struct to the
wire DTO. The frontend (`SubmissionView.svelte`) does
`favorited = !!sub.favorited`.
3. **Observed behaviour:** `GetSubmission` returns a `*Submission` with no
favorite-state field. It is not a wrong value or an error the datum is
simply absent from the SDK's public type.
4. **Expected behaviour:** When `/view/{id}/` is fetched with valid `a`/`b`
cookies, FA renders either a `+Fav` (`/fav/{id}/...`) or a `Fav`
(`/unfav/{id}/...`) anchor exactly one, matching the viewer's current
state. The SDK should surface this on `Submission`, e.g. a new
`Favorited bool` field (final name your call), set true when the page
shows the `/unfav/` link. On an anonymous (no-cookie) fetch neither link
is present → `Favorited` false, which is correct.
5. **Reproduction:** With cookies set, favorite submission X on FA, then
call `GetSubmission(ctx, X)` the result cannot express that X is
favorited.
6. **Suspected layer:** HTML parsing `parseSubmission`. The SDK *already*
has the exact parser: `findFavLinks(doc, subID) (favURL, unfavURL string)`
in `actions.go` (used by `toggleFavorite` for its idempotency check).
`unfavURL != ""` means "currently favorited." `parseSubmission` just needs
to run that check and set the new field. No new scraping logic required.
7. **What is NOT the SDK:** The app side is verified ready and correct.
`SubmissionView.svelte` already reads `sub.favorited` and types it
(`favorited?: boolean`); `getCached` correctly busts and re-fetches the
submission cache after a fav write (via `WriteService` / the action
queue). The value never reaches the app only because the SDK `Submission`
struct has no field to carry it `dto.FromSubmission` has nothing to map.
- **Related (please also check):** the same gap likely exists for *watch*
state `findWatchLinks` exists in `actions.go`, but the `User` struct may
not expose whether the viewer watches that user. Worth fixing in the same
pass.
- **When it lands (FA Droid follow-up):** add `Favorited bool` to
`dto.Submission` (`json:"favorited"`), map it in `dto.FromSubmission`, and
regenerate bindings. The frontend already consumes `sub.favorited`, so no UI
change is needed.
- **Status:** done SDK update added `Submission.Favorited`; app wired it in
v0.0.22.
### #23 [ ] SubmissionInbox yields only the first page (~72 items)
- **Where:** `go-fa-api` `inbox.go` (`SubmissionInbox` + `parseSubmissionInboxPage`).
- **Symptom:** The new-submission inbox shows only ~72 items even when the
account has thousands pending. A user with ~4718 inbox submissions sees one
page.
- **Expected:** `SubmissionInbox` should walk every cursor page until FA stops
rendering a "Next 72" link.
- **Cause tag:** `sdk`
- **SDK handoff brief:**
1. **Entry point:** `Client.SubmissionInbox(ctx, ListOptions)` in `inbox.go`,
whose iterator follows `parseSubmissionInboxPage`'s returned `nextURL`.
2. **How the app calls it:** `InboxService.StreamSubmissions`
(`internal/services/inbox.go`) runs one goroutine that does
`for sub, err := range client.SubmissionInbox(ctx, fa.ListOptions{})`
`ListOptions{}` means `MaxPages: 0` (unbounded), so the app does not
cap the crawl.
3. **Observed behaviour:** the `range` completes after ~72 items the
iterator yields one page then ends. Verified on-device: the app's crawl
channel (fed one item per `yield`) closes after exactly one ~72-item
chunk, so `StreamSubmissions` reports `HasMore: false` immediately after
page 1.
4. **Expected behaviour:** FA's `/msg/submissions/` paginates via a
"Next 72" link encoding a from-id cursor; the iterator should follow it
across all ~66 pages for a 4718-item inbox.
5. **Reproduction:** logged-in client with a large submission inbox;
`count := 0; for range client.SubmissionInbox(ctx, ListOptions{}) { count++ }`
yields ~72, not the true total.
6. **Suspected layer:** HTML parsing `parseSubmissionInboxPage`. Its next-
cursor selector is `div.messagecenter-navigation a.button.more`; if FA
changed that markup the parser returns `nextURL == ""` and the iterator
stops after page 1. Check the selector against current `/msg/submissions/`
HTML (and the cursor-URL construction).
7. **What is NOT the SDK:** app side verified. `StreamSubmissions` ranges the
iterator fully on a single goroutine and streams everything it yields; it
passes `ListOptions{}` (no `MaxPages` cap). On-device logging shows the
crawl ends after one chunk the iterator simply stops yielding.
### #24 [x] Request logger drops `context.Context` (breaks trace propagation)
**Fixed in the SDK** `transport.go`'s `logRequest` now emits its record via
`logger.InfoContext(req.Context(), "fa.request", …)` instead of `logger.Info`,
so a context-aware `slog.Handler` recovers the caller's active span and the
HTTP span nests under the RPC span. A regression test,
`TestTransport_LogRequest_PropagatesRequestContext` in `transport_test.go`,
installs a context-capturing handler and asserts a sentinel value threaded
through `req.Context()` reaches the slog record guarding against a silent
revert to `Info`.
- **Where:** `go-fa-api` the HTTP transport's request logging (`transport.go`,
the `logRequest` helper / wherever `slog` records an outgoing request).
- **Type:** Enhancement, not a bug the SDK works correctly; this is a
one-line change the app needs for distributed tracing.
- **Symptom:** The app (FA Droid, WI-10) added OpenTelemetry spans. An app RPC
opens an `rpc` span and threads its `context.Context` into the SDK call. The
SDK's per-request `slog` line is currently emitted with `logger.Info(...)`,
which carries **no context** so the app's `slog` handler cannot recover the
active span, and each HTTP span becomes an unparented root instead of a child
of the RPC span.
- **Expected:** the request log record should carry the request's context so a
context-aware `slog.Handler` can read the active span from it.
- **The fix (one line):** change the request-logging call from
`logger.Info("fa.request", …)` to
`logger.InfoContext(req.Context(), "fa.request", …)` (use the `*http.Request`'s
own `Context()`). `slog` already supports `InfoContext`; no API change, no new
dependency.
- **Cause tag:** `sdk`
- **SDK handoff brief:**
1. **SDK entry point:** the HTTP transport `RoundTrip` / request path in
`transport.go` specifically the `slog` call that logs each outgoing FA
request (`logRequest`, or inline). All SDK client methods route through it.
2. **How the app calls it:** every `internal/services/*.go` wrapper calls a
`Client.*` method with a `context.Context` that now carries an OTel span;
that ctx reaches the `*http.Request` (`req.Context()`).
3. **Observed behaviour:** the request `slog` record is created without a
context, so `Handler.Handle` receives `context.Background()`.
4. **Expected behaviour:** the record carries `req.Context()`, so a
context-aware handler can extract the active span.
5. **Reproduction:** with WI-10's `diag` slog handler installed, every HTTP
span has `parentSpanId == ""` even when the call was made inside an RPC
span. After the `InfoContext` change, the HTTP span nests under the RPC
span on the same trace id.
6. **Suspected layer:** request shape / logging purely the logging call,
no parsing or auth involved.
7. **What is NOT the SDK behaviour-wise:** no functional SDK behaviour
changes request execution, parsing, retries, rate-limiting are all
untouched. This only changes which `slog` method is used so context flows.
- **Note:** WI-10 applied this change to the *local* `replace`d working copy of
`go-fa-api` so FA Droid v0.0.33 has working rpc→http span linkage. It is
**not committed in the SDK repo**. Until it is upstreamed, a clean SDK
checkout will degrade gracefully HTTP spans become valid but unparented
roots (no crash, no data loss, only the rpc↔http link is lost).

216
actions.go Normal file
View File

@@ -0,0 +1,216 @@
package fa
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// Fav adds a submission to the logged-in user's favorites. Idempotent: if
// the submission is already favorited, the call is a no-op.
//
// Implementation note: FA gates this with a one-shot CSRF key that lives
// on the "+Fav" anchor on the submission page. We fetch the submission to
// scrape the key, then follow the link. The whole exchange happens
// through the rate-limited transport.
func (c *Client) Fav(ctx context.Context, id SubmissionID) error {
return c.toggleFavorite(ctx, id, true)
}
// Unfav removes a submission from the logged-in user's favorites.
// Idempotent: if not favorited, no-op.
func (c *Client) Unfav(ctx context.Context, id SubmissionID) error {
return c.toggleFavorite(ctx, id, false)
}
// toggleFavorite implements both Fav and Unfav: scrape the per-state link
// off the submission page and follow it. wantFav=true means "fav if not
// already faved"; wantFav=false means "unfav if currently faved".
func (c *Client) toggleFavorite(ctx context.Context, id SubmissionID, wantFav bool) error {
if id <= 0 {
return fmt.Errorf("fa: toggleFavorite: id must be > 0")
}
var actionURL string
var alreadyInDesiredState bool
err := c.fetch(ctx, urls.Submission(int64(id)), func(doc *goquery.Document) error {
fav, unfav := findFavLinks(doc, int64(id))
switch {
case wantFav && fav != "":
actionURL = fav
case wantFav && fav == "" && unfav != "":
alreadyInDesiredState = true
case !wantFav && unfav != "":
actionURL = unfav
case !wantFav && unfav == "" && fav != "":
alreadyInDesiredState = true
default:
return fmt.Errorf("%w: submission %d: no fav/unfav link on page (not logged in?)", ErrUnauthorized, id)
}
return nil
})
if err != nil {
return err
}
if alreadyInDesiredState {
return nil
}
return c.followAction(ctx, actionURL)
}
// Watch starts watching a user. Idempotent: if already watching, no-op.
// The key + endpoint live on a button on the user's profile (or any page
// that user is the "owner" of, like a journal). We scrape the profile.
func (c *Client) Watch(ctx context.Context, name string) error {
return c.toggleWatch(ctx, name, true)
}
// Unwatch stops watching a user. Idempotent.
func (c *Client) Unwatch(ctx context.Context, name string) error {
return c.toggleWatch(ctx, name, false)
}
// toggleWatch fetches the user page, picks watch or unwatch link by state.
func (c *Client) toggleWatch(ctx context.Context, name string, wantWatch bool) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("fa: toggleWatch: empty name")
}
var actionURL string
var alreadyInDesiredState bool
err := c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error {
watch, unwatch := findWatchLinks(doc, name)
switch {
case wantWatch && watch != "":
actionURL = watch
case wantWatch && watch == "" && unwatch != "":
alreadyInDesiredState = true
case !wantWatch && unwatch != "":
actionURL = unwatch
case !wantWatch && unwatch == "" && watch != "":
alreadyInDesiredState = true
default:
return fmt.Errorf("%w: user %q: no watch/unwatch link on page", ErrUnauthorized, name)
}
return nil
})
if err != nil {
return err
}
if alreadyInDesiredState {
return nil
}
return c.followAction(ctx, actionURL)
}
// findFavLinks scans a submission view page for the "+Fav" and "Fav"
// anchors that FA renders side by side (only one is real at a time —
// whichever matches your current favorite state). Returns absolute URLs;
// either may be "" if the page is anonymous-mode or doesn't show controls.
func findFavLinks(doc *goquery.Document, subID int64) (favURL, unfavURL string) {
subStr := fmt.Sprintf("/fav/%d/", subID)
unfavStr := fmt.Sprintf("/unfav/%d/", subID)
doc.Find("a[href*='/fav/'], a[href*='/unfav/']").Each(func(_ int, a *goquery.Selection) {
href, _ := a.Attr("href")
switch {
case strings.HasPrefix(href, subStr):
favURL = urls.AbsoluteCDN(href)
case strings.HasPrefix(href, unfavStr):
unfavURL = urls.AbsoluteCDN(href)
}
})
return favURL, unfavURL
}
// findWatchLinks scans a user page for the Watch / Unwatch button.
// FA renders id="watch-button" on the active anchor with href
// "/watch/{name}/?key=..." or "/unwatch/{name}/?key=...".
func findWatchLinks(doc *goquery.Document, name string) (watchURL, unwatchURL string) {
wPrefix := "/watch/" + strings.ToLower(name) + "/"
uwPrefix := "/unwatch/" + strings.ToLower(name) + "/"
doc.Find("a[href*='/watch/'], a[href*='/unwatch/']").Each(func(_ int, a *goquery.Selection) {
href, _ := a.Attr("href")
switch {
case strings.HasPrefix(href, wPrefix):
watchURL = urls.AbsoluteCDN(href)
case strings.HasPrefix(href, uwPrefix):
unwatchURL = urls.AbsoluteCDN(href)
}
})
return watchURL, unwatchURL
}
// followAction performs a one-shot GET to a fav/watch/etc. action URL and
// classifies the response. FA typically redirects to the originating page
// on success (handled transparently by the stdlib client), so a 2xx final
// status means the action took effect.
//
// 4xx is surfaced as ErrUnauthorized / ErrNotFound / ErrSystemMessage via
// the transport+classifier; 5xx becomes HTTPError. We don't parse the
// response body FA's success states are too varied to verify reliably
// from HTML alone.
func (c *Client) followAction(ctx context.Context, actionURL string) error {
if actionURL == "" {
return fmt.Errorf("fa: followAction: empty URL")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, actionURL, nil)
if err != nil {
return err
}
// Rate limit + retries are handled by the transport on c.http.
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Drain and classify. If the response is FA's System Error template we
// surface that; otherwise a 2xx response is taken as success.
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusFound {
// Parse out a possible system-message wrapper.
if doc, derr := goquery.NewDocumentFromReader(strings.NewReader(string(body))); derr == nil {
if smErr := classifySystemMessage(doc); smErr != nil {
return smErr
}
}
return nil
}
return &HTTPError{StatusCode: resp.StatusCode, URL: actionURL}
}
// postForm is the shared form-POST helper used by PostComment, SendNote,
// and any other M3 write action. It honours the rate limiter, sets the
// proper Content-Type, and surfaces system-message errors from the
// response body.
//
// Returns the response body for callers that need to parse a confirmation
// (e.g., to extract a newly-posted comment's ID).
func (c *Client) postForm(ctx context.Context, rawURL string, form url.Values) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode >= 400 {
return body, &HTTPError{StatusCode: resp.StatusCode, URL: rawURL}
}
if doc, derr := goquery.NewDocumentFromReader(strings.NewReader(string(body))); derr == nil {
if smErr := classifySystemMessage(doc); smErr != nil {
return body, smErr
}
}
return body, nil
}

401
actions_test.go Normal file
View File

@@ -0,0 +1,401 @@
package fa
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync/atomic"
"testing"
"github.com/PuerkitoBio/goquery"
)
// fakeSubmissionPage builds a minimal /view/{id}/ response that has either
// a Fav or Unfav anchor (controlled by faved). Sufficient to satisfy the
// classifySystemMessage check (title is non-System-Error) and the
// findFavLinks selector.
func fakeSubmissionPage(id int, faved bool) string {
prefix := "/fav/"
label := "+Fav"
if faved {
prefix = "/unfav/"
label = "-Fav"
}
href := prefix + strconv.Itoa(id) + "/?key=k1"
return `<html><head><title>Test submission</title></head><body>
<div class="submission-description-artist">
<a href="/user/x/"><img class="avatar" src="/a.png"/></a>
<div>
<div class="submission-title"><h2>T</h2></div>
<div><span class="c-usernameBlockSimple__displayName" title="x">x</span></div>
</div>
</div>
<a class="button" href="` + href + `">` + label + `</a>
</body></html>`
}
func fakeUserPage(name string, watching bool) string {
href := "/watch/" + name + "/?key=k2"
text := "Watch"
if watching {
href = "/unwatch/" + name + "/?key=k2"
text = "Unwatch"
}
return `<html><head><title>Userpage</title></head><body>
<div class="username"><h2><span>` + name + `</span></h2></div>
<a id="watch-button" class="button" href="` + href + `">` + text + `</a>
</body></html>`
}
func TestFav_FetchesAndFollowsFavLink(t *testing.T) {
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/view/123/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte(fakeSubmissionPage(123, false)))
})
mux.HandleFunc("/fav/123/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if r.URL.Query().Get("key") != "k1" {
t.Errorf("missing key on /fav/: %q", r.URL.RawQuery)
}
w.WriteHeader(http.StatusOK)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
if err := client.Fav(context.Background(), 123); err != nil {
t.Fatalf("Fav: %v", err)
}
if hits.Load() != 2 {
t.Errorf("hits = %d; want 2 (fetch + follow)", hits.Load())
}
}
func TestFav_AlreadyFavedIsNoOp(t *testing.T) {
var follows atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/view/77/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(fakeSubmissionPage(77, true)))
})
mux.HandleFunc("/fav/77/", func(w http.ResponseWriter, r *http.Request) {
follows.Add(1)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
if err := client.Fav(context.Background(), 77); err != nil {
t.Fatalf("Fav: %v", err)
}
if follows.Load() != 0 {
t.Errorf("/fav/ was hit %d times; want 0 (already faved)", follows.Load())
}
}
func TestUnfav_FollowsUnfavLink(t *testing.T) {
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/view/55/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(fakeSubmissionPage(55, true)))
})
mux.HandleFunc("/unfav/55/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if r.URL.Query().Get("key") != "k1" {
t.Errorf("missing key on /unfav/")
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
if err := client.Unfav(context.Background(), 55); err != nil {
t.Fatalf("Unfav: %v", err)
}
if hits.Load() != 1 {
t.Errorf("/unfav/ hits = %d; want 1", hits.Load())
}
}
func TestWatch_FollowsWatchLink(t *testing.T) {
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/user/alice/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(fakeUserPage("alice", false)))
})
mux.HandleFunc("/watch/alice/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if r.URL.Query().Get("key") != "k2" {
t.Errorf("missing key on /watch/")
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
if err := client.Watch(context.Background(), "alice"); err != nil {
t.Fatalf("Watch: %v", err)
}
if hits.Load() != 1 {
t.Errorf("/watch/ hits = %d", hits.Load())
}
}
func TestUnwatch_AlreadyNotWatching_NoOp(t *testing.T) {
var follows atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/user/bob/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(fakeUserPage("bob", false)))
})
mux.HandleFunc("/unwatch/bob/", func(w http.ResponseWriter, r *http.Request) {
follows.Add(1)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
if err := client.Unwatch(context.Background(), "bob"); err != nil {
t.Fatalf("Unwatch: %v", err)
}
if follows.Load() != 0 {
t.Errorf("/unwatch/ was hit; want no-op when not watching")
}
}
func TestPostSubmissionComment_POSTsCorrectForm(t *testing.T) {
var got url.Values
mux := http.NewServeMux()
mux.HandleFunc("/view/200/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "want POST", http.StatusBadRequest)
return
}
body, _ := io.ReadAll(r.Body)
got, _ = url.ParseQuery(string(body))
_, _ = w.Write([]byte("<html><body><div class='comment-container'>posted</div></body></html>"))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
err := client.PostSubmissionComment(context.Background(), 200, "Nice work!", PostCommentOptions{})
if err != nil {
t.Fatalf("PostSubmissionComment: %v", err)
}
if got.Get("action") != "reply" {
t.Errorf("action = %q", got.Get("action"))
}
if got.Get("reply") != "Nice work!" {
t.Errorf("reply = %q", got.Get("reply"))
}
if got.Get("replyto") != "" {
t.Errorf("replyto should be empty for top-level; got %q", got.Get("replyto"))
}
}
func TestPostSubmissionComment_ReplytoSetForThreadedReply(t *testing.T) {
var got url.Values
mux := http.NewServeMux()
mux.HandleFunc("/view/200/", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
got, _ = url.ParseQuery(string(body))
_, _ = w.Write([]byte("<html><body>ok</body></html>"))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
err := client.PostSubmissionComment(context.Background(), 200, "reply text", PostCommentOptions{ParentID: 999})
if err != nil {
t.Fatalf("PostSubmissionComment: %v", err)
}
if got.Get("replyto") != "999" {
t.Errorf("replyto = %q; want 999", got.Get("replyto"))
}
}
func TestPostJournalComment_TargetsJournalURL(t *testing.T) {
var hit string
mux := http.NewServeMux()
mux.HandleFunc("/journal/400/", func(w http.ResponseWriter, r *http.Request) {
hit = r.URL.Path
_, _ = w.Write([]byte("<html><body>ok</body></html>"))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
err := client.PostJournalComment(context.Background(), 400, "hi", PostCommentOptions{})
if err != nil {
t.Fatalf("PostJournalComment: %v", err)
}
if hit != "/journal/400/" {
t.Errorf("hit = %q", hit)
}
}
func TestSendNote_ScrapesKeyAndPosts(t *testing.T) {
inboxHTML := `<html><head><title>Inbox</title></head><body>
<form action="/msg/send/"><input type="hidden" name="key" value="SCRAPED_KEY"/></form>
</body></html>`
var sendBody url.Values
mux := http.NewServeMux()
mux.HandleFunc("/msg/pms/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(inboxHTML))
})
mux.HandleFunc("/msg/send/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("want POST, got %s", r.Method)
}
body, _ := io.ReadAll(r.Body)
sendBody, _ = url.ParseQuery(string(body))
_, _ = w.Write([]byte("<html><body>sent</body></html>"))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
err := client.SendNote(context.Background(), "vampexx", "Re: hi", "body text")
if err != nil {
t.Fatalf("SendNote: %v", err)
}
if sendBody.Get("key") != "SCRAPED_KEY" {
t.Errorf("key = %q; want SCRAPED_KEY", sendBody.Get("key"))
}
if sendBody.Get("to") != "vampexx" {
t.Errorf("to = %q", sendBody.Get("to"))
}
if sendBody.Get("subject") != "Re: hi" {
t.Errorf("subject = %q", sendBody.Get("subject"))
}
if sendBody.Get("message") != "body text" {
t.Errorf("message = %q", sendBody.Get("message"))
}
}
func TestSendNote_NoKeyOnInboxPage_Unauthorized(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/msg/pms/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`<html><head><title>Inbox</title></head><body>no form here</body></html>`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
err := client.SendNote(context.Background(), "x", "s", "b")
if !errors.Is(err, ErrUnauthorized) {
t.Fatalf("got %v; want ErrUnauthorized", err)
}
}
func TestBuildBrowseURL_NoFiltersUsesSimpleForm(t *testing.T) {
u := buildBrowseURL(1, BrowseOptions{})
if u != "https://www.furaffinity.net/browse/" {
t.Errorf("page=1 default = %q", u)
}
u = buildBrowseURL(2, BrowseOptions{})
if u != "https://www.furaffinity.net/browse/?page=2" {
t.Errorf("page=2 default = %q", u)
}
}
func TestBuildBrowseURL_RatingsAndCategoryFilters(t *testing.T) {
got := buildBrowseURL(1, BrowseOptions{
Ratings: []Rating{RatingGeneral, RatingMature},
Category: 2,
PerPage: 48,
})
q := mustQuery(t, got)
if q.Get("rating_general") != "1" || q.Get("rating_mature") != "1" {
t.Errorf("ratings not encoded: %v", q)
}
if q.Get("rating_adult") != "" {
t.Errorf("rating_adult should not be set when not requested")
}
if q.Get("cat") != "2" {
t.Errorf("cat = %q", q.Get("cat"))
}
if q.Get("perpage") != "48" {
t.Errorf("perpage = %q", q.Get("perpage"))
}
}
func TestE2E_Browse_RatingsFilterFlowsThroughIterator(t *testing.T) {
var seenRatings []string
mux := http.NewServeMux()
mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) {
seenRatings = []string{
r.URL.Query().Get("rating_general"),
r.URL.Query().Get("rating_mature"),
r.URL.Query().Get("rating_adult"),
}
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<html><body></body></html>`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
for _, err := range client.Browse(context.Background(), BrowseOptions{
Ratings: []Rating{RatingGeneral},
}) {
if err != nil {
t.Fatalf("iter: %v", err)
}
}
if seenRatings[0] != "1" || seenRatings[1] != "" || seenRatings[2] != "" {
t.Errorf("seenRatings = %v; want [1, '', '']", seenRatings)
}
}
// TestFindFavLinks_RealFixture confirms findFavLinks scrapes the +Fav anchor
// out of a real captured /view/ page. Guards against FA changing the fav-link
// markup (issue #5: favourite action doing nothing).
func TestFindFavLinks_RealFixture(t *testing.T) {
raw := loadFixture(t, "submission.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
// submission.html was captured for submission 65052636 (a "+Fav" page).
favURL, unfavURL := findFavLinks(doc, 65052636)
if favURL == "" {
t.Error("findFavLinks: favURL empty +Fav anchor not found in real markup")
}
if !strings.Contains(favURL, "/fav/65052636/") || !strings.Contains(favURL, "key=") {
t.Errorf("findFavLinks: favURL = %q; want a /fav/65052636/?key=... URL", favURL)
}
if unfavURL != "" {
t.Errorf("findFavLinks: unfavURL = %q; want empty on a not-yet-faved page", unfavURL)
}
}
// TestFindWatchLinks_RealFixture confirms findWatchLinks scrapes the
// watch-button anchor out of a real captured userpage-style page. Guards
// against FA changing the watch-button markup (issue #10).
func TestFindWatchLinks_RealFixture(t *testing.T) {
raw := loadFixture(t, "gallery_page1.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
// gallery_page1.html was captured for kazucreations, whom the capturing
// account watches so the header shows an Unwatch button.
watchURL, unwatchURL := findWatchLinks(doc, "kazucreations")
if unwatchURL == "" {
t.Error("findWatchLinks: unwatchURL empty watch-button anchor not found in real markup")
}
if unwatchURL != "" && (!strings.Contains(unwatchURL, "/unwatch/kazucreations/") || !strings.Contains(unwatchURL, "key=")) {
t.Errorf("findWatchLinks: unwatchURL = %q; want a /unwatch/kazucreations/?key=... URL", unwatchURL)
}
if watchURL != "" {
t.Errorf("findWatchLinks: watchURL = %q; want empty when already watching", watchURL)
}
}

148
browse.go Normal file
View File

@@ -0,0 +1,148 @@
package fa
import (
"context"
"iter"
"net/url"
"strconv"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// browsePerPage is FA's default page size for /browse/. We use it as the
// "this page is full → there's likely another one" threshold because the
// browse feed paginates via POST forms in the UI; detectNextPage's anchor-
// based heuristic doesn't fire on browse responses.
const browsePerPage = 72
// BrowseOptions configures /browse/ filters. Zero-value defaults match
// FA's web UI default: all ratings on, all categories, all art types,
// any species, 72 per page.
//
// The filter fields use FA's internal numeric IDs (1 = "All" / "Any" for
// category, atype, species). The exact list of valid values is huge and
// changes over time; for now we expose them as raw ints rather than
// pretending to enumerate them callers wanting a typed Category enum
// should grab the value off the browse form's <option> elements.
type BrowseOptions struct {
// Ratings restricts results to these rating levels. nil/empty = all
// three (general + mature + adult).
Ratings []Rating
// Category, ArtType, Species are FA's internal numeric IDs. Zero or 1
// means "All / Any" (the default).
Category int
ArtType int
Species int
// PerPage is the page size. Valid values match the web UI: 24/36/48/60/72.
// Zero defaults to 72.
PerPage int
// StartPage is the 1-based page to begin iteration on.
StartPage int
// MaxPages bounds the iterator. Zero = unbounded (be careful the feed
// is effectively infinite).
MaxPages int
}
// Browse iterates FA's global front-page feed (/browse/), newest first.
// Returns the same partially-populated *Submission shape as [Client.Gallery]:
// ID, Title, Author, ThumbURL, Rating. Call [Client.GetSubmission] with
// the ID to load the full record.
//
// The feed is effectively unbounded (millions of submissions across the
// site). Always set [BrowseOptions.MaxPages] in production code; without
// it the iterator will keep fetching until FA returns a partial page.
//
// Note: FA paginates browse via POST forms in the UI; this iterator
// instead uses GET with ?page=N, which is honoured for the rendered HTML.
// "Next page exists?" is inferred from item count: a full page
// (browsePerPage items) means we keep going; anything less is the tail.
func (c *Client) Browse(ctx context.Context, opts BrowseOptions) iter.Seq2[*Submission, error] {
return func(yield func(*Submission, error) bool) {
page := opts.StartPage
if page < 1 {
page = 1
}
pagesFetched := 0
for {
if opts.MaxPages > 0 && pagesFetched >= opts.MaxPages {
return
}
pageURL := buildBrowseURL(page, opts)
var items []*Submission
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
items, _ = parseGalleryPage(doc, c.cfg.jsonListings)
return nil
})
if err != nil {
yield(nil, err)
return
}
pagesFetched++
if len(items) == 0 {
return
}
for _, s := range items {
if !yield(s, nil) {
return
}
}
if len(items) < browsePerPage {
return
}
page++
}
}
}
// buildBrowseURL constructs a /browse/ URL with the given page and any
// non-default filter fields. The form fields match what FA's UI POSTs on
// the front-page form; we send them as GET params instead.
func buildBrowseURL(page int, opts BrowseOptions) string {
// Without any filters set, the simple path-form URL works.
hasFilters := len(opts.Ratings) != 0 ||
opts.Category > 1 || opts.ArtType > 1 || opts.Species > 1 ||
opts.PerPage > 0
if !hasFilters {
return urls.Browse(page)
}
v := url.Values{}
if page > 1 {
v.Set("page", strconv.Itoa(page))
}
if opts.PerPage > 0 {
v.Set("perpage", strconv.Itoa(opts.PerPage))
}
if opts.Category > 0 {
v.Set("cat", strconv.Itoa(opts.Category))
}
if opts.ArtType > 0 {
v.Set("atype", strconv.Itoa(opts.ArtType))
}
if opts.Species > 0 {
v.Set("species", strconv.Itoa(opts.Species))
}
ratings := opts.Ratings
if len(ratings) == 0 {
ratings = []Rating{RatingGeneral, RatingMature, RatingAdult}
}
for _, r := range ratings {
switch r {
case RatingGeneral:
v.Set("rating_general", "1")
case RatingMature:
v.Set("rating_mature", "1")
case RatingAdult:
v.Set("rating_adult", "1")
}
}
return urls.Host + "/browse/?" + v.Encode()
}

185
client.go Normal file
View File

@@ -0,0 +1,185 @@
package fa
import (
"bytes"
"context"
"fmt"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/gocolly/colly/v2"
farouting "git.anthrove.art/public/go-fa-api/internal/urls"
)
// Client is the entry point of the SDK. It is safe for concurrent use; the
// internal rate limiter serializes outbound requests regardless of the
// number of calling goroutines.
//
// One Client corresponds to one FA session. Construct anonymous and
// authenticated clients separately rather than mutating one in-flight.
type Client struct {
cfg config
limiter *rateLimiter
logger *slog.Logger
collector *colly.Collector
http *http.Client
jar http.CookieJar
}
// New returns a configured Client. Pass options to override defaults.
//
// client := fa.New(
// fa.WithCookies(fa.Cookies{A: aCookie, B: bCookie}),
// fa.WithUserAgent("myapp/1.0"),
// )
func New(opts ...Option) *Client {
cfg := config{
userAgent: defaultUserAgent,
// One request per second steady-state, but allow a small burst so
// that e.g. avatar enrichment (one fetch per distinct author) can
// fire a few requests back-to-back before the 1/s pacing kicks in.
rateInterval: time.Second,
rateBurst: 3,
logger: slog.Default(),
maxRetries: defaultMaxRetries,
}
for _, o := range opts {
o(&cfg)
}
limiter := newRateLimiter(cfg.rateInterval, cfg.rateBurst, cfg.priorityRL)
// Build the base RoundTripper. If caller supplied an http.Client, reuse
// its transport as the "base" so that any TLS customisation (uTLS,
// chromedp, etc.) still applies. Otherwise wrap the stdlib default.
var baseRT http.RoundTripper = http.DefaultTransport
if cfg.httpClient != nil && cfg.httpClient.Transport != nil {
baseRT = cfg.httpClient.Transport
}
rt := &transport{
base: baseRT,
limiter: limiter,
userAgent: cfg.userAgent,
maxRetries: cfg.maxRetries,
logger: cfg.logger,
}
jar, _ := cookiejar.New(nil)
seedJar(jar, cfg.cookies, cfg.cf, cfg.sfw)
httpClient := &http.Client{
Transport: rt,
Jar: jar,
}
if cfg.httpClient != nil {
httpClient.Timeout = cfg.httpClient.Timeout
httpClient.CheckRedirect = cfg.httpClient.CheckRedirect
}
base := colly.NewCollector(
colly.UserAgent(cfg.userAgent),
colly.AllowURLRevisit(),
)
base.SetClient(httpClient)
base.SetCookieJar(jar)
// Colly's own LimitRule would compose with our transport limiter and
// double-throttle requests; instead, leave Colly unthrottled and let the
// transport be the single source of pacing truth.
return &Client{
cfg: cfg,
limiter: limiter,
logger: cfg.logger,
collector: base,
http: httpClient,
jar: jar,
}
}
// seedJar installs the FA session and Cloudflare clearance cookies onto the
// cookie jar so every outbound request to the host picks them up. The
// stdlib jar requires a URL to scope cookies; we use the FA host root.
//
// When sfw is [SFWOn] or [SFWOff] the `sfw` cookie is set to "1" or "0"
// respectively, matching what FA's navbar slider writes client-side.
// [SFWAuto] leaves the cookie unset so the account default applies.
func seedJar(jar http.CookieJar, fa Cookies, cf CFCookies, sfw SFWMode) {
hostURL, err := url.Parse(farouting.Host)
if err != nil {
return
}
var cookies []*http.Cookie
if fa.A != "" {
cookies = append(cookies, &http.Cookie{Name: "a", Value: fa.A, Path: "/"})
}
if fa.B != "" {
cookies = append(cookies, &http.Cookie{Name: "b", Value: fa.B, Path: "/"})
}
if cf.Clearance != "" {
cookies = append(cookies, &http.Cookie{Name: "cf_clearance", Value: cf.Clearance, Path: "/"})
}
switch sfw {
case SFWOn:
cookies = append(cookies, &http.Cookie{Name: "sfw", Value: "1", Path: "/"})
case SFWOff:
cookies = append(cookies, &http.Cookie{Name: "sfw", Value: "0", Path: "/"})
}
if len(cookies) > 0 {
jar.SetCookies(hostURL, cookies)
}
}
// fetch executes a single GET via the internal Colly collector and hands the
// parsed goquery document to parse. The collector clone scopes the OnHTML/
// OnResponse callbacks to this single call, so concurrent calls do not see
// each other's responses.
//
// Context cancellation propagates through the http.Request and the rate
// limiter a cancelled ctx surfaces from Wait or from the underlying
// transport, depending on which phase the request is in.
func (c *Client) fetch(ctx context.Context, rawURL string, parse func(doc *goquery.Document) error) error {
clone := c.collector.Clone()
clone.SetClient(c.http)
clone.SetCookieJar(c.jar)
clone.Context = ctx
var (
parseErr error
respErr error
)
clone.OnResponse(func(r *colly.Response) {
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(r.Body))
if err != nil {
parseErr = fmt.Errorf("%w: build document: %v", ErrParse, err)
return
}
if smErr := classifySystemMessage(doc); smErr != nil {
parseErr = smErr
return
}
if err := parse(doc); err != nil {
parseErr = err
}
})
clone.OnError(func(r *colly.Response, err error) {
respErr = err
})
if err := clone.Visit(rawURL); err != nil {
if respErr != nil {
return respErr
}
return err
}
if respErr != nil {
return respErr
}
return parseErr
}

60
comment.go Normal file
View File

@@ -0,0 +1,60 @@
package fa
import (
"context"
"iter"
"time"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// Comment is one entry from a submission's or journal's comment thread.
// FA's beta theme renders the thread as a flat indented list; the parser
// fills Depth and Parent so callers can rebuild the tree if needed.
type Comment struct {
ID CommentID
Author UserRef
PostedAt time.Time
BodyHTML string
BodyText string
Depth int // 0 = top level
Parent CommentID // 0 when Depth is 0
Deleted bool
}
// SubmissionComments yields every comment on a submission's view page.
// Comments aren't paginated, so this iterator performs one fetch and then
// yields each comment in document order; early termination still avoids
// processing the rest of the slice.
func (c *Client) SubmissionComments(ctx context.Context, id SubmissionID) iter.Seq2[*Comment, error] {
return c.yieldComments(ctx, urls.Submission(int64(id)))
}
// JournalComments yields every comment on a journal page. Same iteration
// shape as [Client.SubmissionComments].
func (c *Client) JournalComments(ctx context.Context, id JournalID) iter.Seq2[*Comment, error] {
return c.yieldComments(ctx, urls.Journal(int64(id)))
}
// yieldComments performs the single fetch shared by submission and journal
// comment iterators, then yields parsed comments to the caller.
func (c *Client) yieldComments(ctx context.Context, pageURL string) iter.Seq2[*Comment, error] {
return func(yield func(*Comment, error) bool) {
var comments []*Comment
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
comments = parseComments(doc)
return nil
})
if err != nil {
yield(nil, err)
return
}
for _, cm := range comments {
if !yield(cm, nil) {
return
}
}
}
}

131
comment_parser.go Normal file
View File

@@ -0,0 +1,131 @@
package fa
import (
"strconv"
"strings"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// parseComments walks a submission or journal page's comment section and
// returns the comments in document order. Depth and Parent are inferred from
// data-attributes when present, otherwise from the legacy "width: NN%" style
// FA still emits on threaded replies.
func parseComments(doc *goquery.Document) []*Comment {
var out []*Comment
doc.Find("div.comment-container, div[id^='cid:'], div[id^='comment-']").Each(func(_ int, sel *goquery.Selection) {
idAttr, _ := sel.Attr("id")
idStr := strings.TrimPrefix(idAttr, "cid:")
idStr = strings.TrimPrefix(idStr, "comment-")
id, _ := parseID[CommentID](idStr)
c := &Comment{ID: id}
// Deleted comments: class on the container.
if class, _ := sel.Attr("class"); strings.Contains(class, "comment-deleted") ||
strings.Contains(class, "deleted-comment") {
c.Deleted = true
}
// Depth: prefer data-depth, then class "c-1/c-2/...", then style width %.
if d, ok := sel.Attr("data-depth"); ok {
if n, err := strconv.Atoi(strings.TrimSpace(d)); err == nil {
c.Depth = n
}
} else if class, _ := sel.Attr("class"); class != "" {
for _, tok := range strings.Fields(class) {
if strings.HasPrefix(tok, "c-") {
if n, err := strconv.Atoi(strings.TrimPrefix(tok, "c-")); err == nil {
c.Depth = n
break
}
}
}
}
if c.Depth == 0 {
if style, ok := sel.Attr("style"); ok {
c.Depth = depthFromWidthStyle(style)
}
}
// Parent: from data-parent or replyto-* class.
if p, ok := sel.Attr("data-parent"); ok {
if n, err := parseID[CommentID](strings.TrimSpace(p)); err == nil {
c.Parent = n
}
} else if class, _ := sel.Attr("class"); class != "" {
for _, tok := range strings.Fields(class) {
if strings.HasPrefix(tok, "replyto-") {
if n, err := parseID[CommentID](strings.TrimPrefix(tok, "replyto-")); err == nil {
c.Parent = n
break
}
}
}
}
// Author.
authorSel := sel.Find("a.iconusername, .comment-username a, .c-usernameBlock a").First()
if authorSel.Length() > 0 {
href, _ := authorSel.Attr("href")
c.Author = UserRef{
DisplayName: trimText(authorSel),
AvatarURL: urls.AbsoluteCDN(trimAttr(authorSel.Find("img").First(), "src")),
}
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
c.Author.Name = parts[1]
}
}
// Date.
dateSel := sel.Find("span.popup_date").First()
if t, err := ParseFADate(firstNonEmpty(trimAttr(dateSel, "title"), trimText(dateSel))); err == nil {
c.PostedAt = t
}
// Body.
body := sel.Find("div.comment-user-text, div.user-text, .no_overflow").First()
c.BodyHTML = htmlOf(body)
c.BodyText = strings.TrimSpace(body.Text())
if c.ID != 0 || c.Author.DisplayName != "" || c.BodyText != "" {
out = append(out, c)
}
})
return out
}
// depthFromWidthStyle reads a legacy FA inline style like
// "width: 96%; padding: ..." and maps it to a depth level. FA used to shrink
// each reply by 3% per level, which is how earlier scrapers detected depth.
// Returns 0 if no usable width found.
func depthFromWidthStyle(style string) int {
low := strings.ToLower(style)
i := strings.Index(low, "width:")
if i == -1 {
return 0
}
rest := low[i+len("width:"):]
end := strings.Index(rest, "%")
if end == -1 {
return 0
}
numStr := strings.TrimSpace(rest[:end])
num, err := strconv.Atoi(numStr)
if err != nil {
return 0
}
// 100% or 99% -> depth 0; each 3% step is one level deeper.
switch {
case num >= 99:
return 0
case num <= 0:
return 0
default:
return (99 - num) / 3
}
}

83
comment_parser_test.go Normal file
View File

@@ -0,0 +1,83 @@
package fa
import (
"bytes"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
const syntheticCommentsHTML = `<html><body>
<div id="cid:111" class="comment-container c-0" data-depth="0">
<a class="iconusername" href="/user/alice/"><img src="//d.example/a.png"/>Alice</a>
<span class="popup_date" title="Apr 5, 2026 01:00 PM">today</span>
<div class="comment-user-text"><p>First!</p></div>
</div>
<div id="cid:112" class="comment-container c-1 replyto-111" data-depth="1" data-parent="111">
<a class="iconusername" href="/user/bob/"><img src="//d.example/b.png"/>Bob</a>
<span class="popup_date" title="Apr 5, 2026 01:05 PM">today</span>
<div class="comment-user-text"><p>Reply to first</p></div>
</div>
<div id="cid:113" class="comment-container comment-deleted c-0">
<span class="popup_date" title="Apr 5, 2026 01:10 PM">today</span>
<div class="comment-user-text">[deleted]</div>
</div>
</body></html>`
func TestParseComments_Synthetic(t *testing.T) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticCommentsHTML))
if err != nil {
t.Fatalf("setup: %v", err)
}
cs := parseComments(doc)
if len(cs) != 3 {
t.Fatalf("comments = %d; want 3", len(cs))
}
if cs[0].ID != 111 || cs[0].Author.Name != "alice" || cs[0].Depth != 0 || cs[0].Parent != 0 {
t.Errorf("comment[0] = %+v", cs[0])
}
if cs[1].ID != 112 || cs[1].Author.Name != "bob" || cs[1].Depth != 1 || cs[1].Parent != 111 {
t.Errorf("comment[1] = %+v", cs[1])
}
if !cs[2].Deleted {
t.Errorf("comment[2].Deleted = false; want true")
}
if !strings.Contains(cs[0].BodyText, "First!") {
t.Errorf("comment[0].BodyText = %q", cs[0].BodyText)
}
}
func TestDepthFromWidthStyle(t *testing.T) {
cases := map[string]int{
"width: 99%; padding: 0": 0,
"width:96%": 1,
"width: 93%": 2,
"width: 90%": 3,
"width: 50%": 16,
"width: 100%": 0,
"no width here": 0,
"width: abc%": 0,
}
for in, want := range cases {
if got := depthFromWidthStyle(in); got != want {
t.Errorf("depthFromWidthStyle(%q) = %d; want %d", in, got, want)
}
}
}
func TestParseComments_RealFixture(t *testing.T) {
raw := loadFixture(t, "comments_submission.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
cs := parseComments(doc)
// Comments may be empty on a submission with no comments; just ensure
// the parser doesn't crash and yields sane values when populated.
for i, c := range cs {
if c.Depth < 0 {
t.Errorf("comment %d: negative depth %d", i, c.Depth)
}
}
}

62
comments_post.go Normal file
View File

@@ -0,0 +1,62 @@
package fa
import (
"context"
"fmt"
"net/url"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// PostCommentOptions configures a comment post. ParentID is the comment
// being replied to (0 for a top-level comment on the submission/journal).
type PostCommentOptions struct {
ParentID CommentID
}
// PostSubmissionComment posts a comment on a submission. Body is BBCode-
// formatted text (FA's comment markup). Returns nil on success; an
// [ErrUnauthorized] when called without auth cookies; an
// [*SystemMessageError] if FA refused the post (rate limiter, blocked
// user, etc).
//
// FA's comment form (`#add_comment_form`) posts to the submission's own
// URL with fields `action=reply`, `replyto=<parent>`, `reply=<body>`.
// There is no separate per-form CSRF key auth cookies + Cloudflare
// clearance are the only gates.
func (c *Client) PostSubmissionComment(ctx context.Context, id SubmissionID, body string, opts PostCommentOptions) error {
if id <= 0 {
return fmt.Errorf("fa: PostSubmissionComment: id must be > 0")
}
return c.postCommentForm(ctx, urls.Submission(int64(id)), body, opts)
}
// PostJournalComment posts a comment on a journal. Same form shape as
// submissions; the form action just points at /journal/{id}/.
func (c *Client) PostJournalComment(ctx context.Context, id JournalID, body string, opts PostCommentOptions) error {
if id <= 0 {
return fmt.Errorf("fa: PostJournalComment: id must be > 0")
}
return c.postCommentForm(ctx, urls.Journal(int64(id)), body, opts)
}
// postCommentForm builds the field set #add_comment_form sends. Shared
// between submission and journal comment posting because FA renders an
// identical form on both pages.
func (c *Client) postCommentForm(ctx context.Context, pageURL, body string, opts PostCommentOptions) error {
if body == "" {
return fmt.Errorf("fa: PostComment: empty body")
}
v := url.Values{}
v.Set("f", "0")
v.Set("action", "reply")
if opts.ParentID > 0 {
v.Set("replyto", opts.ParentID.String())
} else {
v.Set("replyto", "")
}
v.Set("reply", body)
v.Set("submit", "Post Comment")
_, err := c.postForm(ctx, pageURL, v)
return err
}

42
doc.go Normal file
View File

@@ -0,0 +1,42 @@
// Package fa is a Go SDK for FurAffinity (https://www.furaffinity.net).
//
// FurAffinity exposes no JSON API, so this package scrapes the rendered
// HTML of the site's beta theme using Colly internally, and presents a
// strongly typed surface to callers.
//
// # Quickstart
//
// client := fa.New(
// fa.WithCookies(fa.Cookies{A: os.Getenv("FA_A"), B: os.Getenv("FA_B")}),
// fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
// fa.WithUserAgent(os.Getenv("FA_UA")),
// )
//
// sub, err := client.GetSubmission(ctx, 12345678)
// if err != nil {
// return err
// }
// fmt.Println(sub.Title, "by", sub.Author.DisplayName)
//
// for sub, err := range client.Gallery(ctx, "someuser", fa.ListOptions{MaxPages: 3}) {
// if err != nil {
// return err
// }
// fmt.Println(sub.ID, sub.Title)
// }
//
// # Rate limiting
//
// All HTTP requests, including file downloads from the CDN, pass through a
// single token-bucket rate limiter. The default is one request per second,
// which is the lowest safe value for unauthenticated browsing of the site.
// Callers cannot bypass it; it lives inside the [http.RoundTripper].
//
// # Cloudflare
//
// FurAffinity sits behind Cloudflare. To pass challenge pages, callers must
// supply a fresh cf_clearance cookie obtained from a real browser, along
// with the exact User-Agent string that produced it. If the SDK receives a
// challenge response it returns [ErrCloudflareChallenge]; the caller should
// refresh the clearance cookie and retry.
package fa

556
e2e_test.go Normal file
View File

@@ -0,0 +1,556 @@
package fa
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
// rewritingRT routes every request to a fixed test server while preserving
// the original path and query. Lets us exercise the SDK end-to-end against
// canned responses without monkey-patching the urls package.
type rewritingRT struct {
target *url.URL
base http.RoundTripper
}
func (r *rewritingRT) RoundTrip(req *http.Request) (*http.Response, error) {
req2 := req.Clone(req.Context())
req2.URL = &url.URL{
Scheme: r.target.Scheme,
Host: r.target.Host,
Path: req.URL.Path,
RawQuery: req.URL.RawQuery,
}
req2.Host = r.target.Host
return r.base.RoundTrip(req2)
}
func newE2EClient(t *testing.T, srv *httptest.Server) *Client {
t.Helper()
target, err := url.Parse(srv.URL)
if err != nil {
t.Fatalf("parse server url: %v", err)
}
hc := &http.Client{
Transport: &rewritingRT{target: target, base: http.DefaultTransport},
Timeout: 5 * time.Second,
}
// Tight rate limit so tests don't sleep for seconds between pages.
return New(
WithHTTPClient(hc),
WithRateLimit(time.Microsecond, 16),
WithMaxRetries(0),
)
}
func TestE2E_GetSubmission(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/view/1234/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(syntheticSubmissionHTML))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
sub, err := client.GetSubmission(context.Background(), 1234)
if err != nil {
t.Fatalf("GetSubmission: %v", err)
}
if sub.Title != "My Test Submission" {
t.Errorf("Title = %q", sub.Title)
}
if sub.Author.Name != "somefurry" {
t.Errorf("Author.Name = %q", sub.Author.Name)
}
if sub.Rating != RatingGeneral {
t.Errorf("Rating = %q", sub.Rating)
}
}
func TestE2E_GalleryIterator_WalksPages(t *testing.T) {
page1 := `<html><body>
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a></figure>
<figure id="sid-2"><a href="/view/2/" title="Two"><img src="/t2.png"/></a></figure>
<a class="button standard" href="/gallery/me/2/">Next</a>
</body></html>`
page2 := `<html><body>
<figure id="sid-3"><a href="/view/3/" title="Three"><img src="/t3.png"/></a></figure>
</body></html>`
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/gallery/me/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page1))
})
mux.HandleFunc("/gallery/me/2/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(page2))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
var ids []SubmissionID
for sub, err := range client.Gallery(context.Background(), "me", ListOptions{}) {
if err != nil {
t.Fatalf("iter: %v", err)
}
ids = append(ids, sub.ID)
}
wantIDs := []SubmissionID{1, 2, 3}
if len(ids) != len(wantIDs) {
t.Fatalf("got %d ids; want %d", len(ids), len(wantIDs))
}
for i, id := range wantIDs {
if ids[i] != id {
t.Errorf("ids[%d] = %d; want %d", i, ids[i], id)
}
}
if hits.Load() != 2 {
t.Errorf("page fetches = %d; want 2", hits.Load())
}
}
func TestE2E_GalleryIterator_EarlyBreakStopsFetching(t *testing.T) {
page1 := `<html><body>
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a></figure>
<figure id="sid-2"><a href="/view/2/" title="Two"><img src="/t2.png"/></a></figure>
<a class="button standard" href="/gallery/me/2/">Next</a>
</body></html>`
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/gallery/me/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte(page1))
})
mux.HandleFunc("/gallery/me/2/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
t.Error("page 2 should not have been fetched after early break")
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
for _, err := range client.Gallery(context.Background(), "me", ListOptions{}) {
if err != nil {
t.Fatalf("iter: %v", err)
}
break // stop after first
}
if hits.Load() != 1 {
t.Errorf("hits = %d; want 1", hits.Load())
}
}
func TestE2E_SystemMessage_NotFound(t *testing.T) {
notFoundHTML := `<html>
<head><title>System Error</title></head>
<body>
<section>
<div class="section-header"><h2>System Error</h2></div>
<div class="section-body">The submission you are trying to find is not in our database.</div>
</section>
</body></html>`
mux := http.NewServeMux()
mux.HandleFunc("/view/999/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(notFoundHTML))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
_, err := client.GetSubmission(context.Background(), 999)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("got %v; want ErrNotFound", err)
}
}
func TestE2E_BrowseIterator_WalksPagesByFullness(t *testing.T) {
// Build a full page (browsePerPage items) so the iterator keeps going,
// then a half page (< browsePerPage) so it stops naturally.
fullPage := buildFigurePage(browsePerPage, 1)
tailPage := buildFigurePage(browsePerPage/2, 1+browsePerPage)
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
page := r.URL.Query().Get("page")
w.Header().Set("Content-Type", "text/html")
switch page {
case "", "1":
_, _ = w.Write([]byte(fullPage))
case "2":
_, _ = w.Write([]byte(tailPage))
default:
t.Errorf("unexpected page=%q requested", page)
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
count := 0
for sub, err := range client.Browse(context.Background(), BrowseOptions{}) {
if err != nil {
t.Fatalf("iter: %v", err)
}
if sub.ID == 0 {
t.Errorf("yielded submission with ID 0")
}
count++
}
wantCount := browsePerPage + browsePerPage/2
if count != wantCount {
t.Errorf("yielded %d items; want %d", count, wantCount)
}
if hits.Load() != 2 {
t.Errorf("hit count = %d; want 2 (page=1 + page=2)", hits.Load())
}
}
func TestE2E_BrowseIterator_EarlyBreakStopsFetching(t *testing.T) {
fullPage := buildFigurePage(browsePerPage, 1)
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte(fullPage))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
for _, err := range client.Browse(context.Background(), BrowseOptions{}) {
if err != nil {
t.Fatalf("iter: %v", err)
}
break // stop after the very first item
}
if hits.Load() != 1 {
t.Errorf("hit count = %d; want 1 (early break before page 2)", hits.Load())
}
}
func TestE2E_BrowseIterator_MaxPagesCap(t *testing.T) {
full := buildFigurePage(browsePerPage, 1)
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte(full))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
count := 0
for _, err := range client.Browse(context.Background(), BrowseOptions{MaxPages: 2}) {
if err != nil {
t.Fatalf("iter: %v", err)
}
count++
}
if hits.Load() != 2 {
t.Errorf("hits = %d; want 2 (BrowseOptions.MaxPages=2 should cap fetches)", hits.Load())
}
if count != 2*browsePerPage {
t.Errorf("count = %d; want %d", count, 2*browsePerPage)
}
}
// buildFigurePage constructs a synthetic browse-style HTML page with n
// figure[id^=sid-] items, starting from idStart. Each figure mirrors the
// real FA structure closely enough to satisfy parseGalleryPage.
func buildFigurePage(n int, idStart int) string {
var b strings.Builder
b.WriteString(`<html><body>`)
for i := 0; i < n; i++ {
id := idStart + i
fmt.Fprintf(&b, `<figure id="sid-%d" class="r-general t-image u-artist%d">
<a href="/view/%d/" title="Submission %d"><img src="/t%d.png"/></a>
<figcaption>
<p><a href="/view/%d/" title="Submission %d">Submission %d</a></p>
<p><i>by</i> <a href="/user/artist%d/" title="Artist%d">Artist%d</a></p>
</figcaption>
</figure>
`, id, i, id, id, id, id, id, id, i, i, i)
}
b.WriteString(`</body></html>`)
return b.String()
}
func TestE2E_Search_FollowsNextPagination(t *testing.T) {
page1 := `<html><body>
<section id="gallery-search-results" class="gallery">
<figure id="sid-10"><a href="/view/10/" title="Ten"><img src="/t10.png"/></a>
<figcaption><p><a href="/view/10/" title="Ten">Ten</a></p><p>by <a href="/user/alice/">Alice</a></p></figcaption>
</figure>
<figure id="sid-11"><a href="/view/11/" title="Eleven"><img src="/t11.png"/></a>
<figcaption><p><a href="/view/11/" title="Eleven">Eleven</a></p><p>by <a href="/user/bob/">Bob</a></p></figcaption>
</figure>
</section>
<div class="pagination">
<button class="button" disabled>Back</button>
<a class="button" href="/search/?q=x&amp;page=2">Next</a>
</div>
</body></html>`
page2 := `<html><body>
<section id="gallery-search-results" class="gallery">
<figure id="sid-12"><a href="/view/12/" title="Twelve"><img src="/t12.png"/></a>
<figcaption><p><a href="/view/12/" title="Twelve">Twelve</a></p><p>by <a href="/user/carol/">Carol</a></p></figcaption>
</figure>
</section>
<div class="pagination">
<a class="button" href="/search/?q=x&amp;page=1">Back</a>
</div>
</body></html>`
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/search/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
page := r.URL.Query().Get("page")
if page == "2" {
_, _ = w.Write([]byte(page2))
} else {
_, _ = w.Write([]byte(page1))
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
var ids []SubmissionID
for sub, err := range client.Search(context.Background(), "x", SearchOptions{}) {
if err != nil {
t.Fatalf("iter: %v", err)
}
ids = append(ids, sub.ID)
}
wantIDs := []SubmissionID{10, 11, 12}
if len(ids) != len(wantIDs) {
t.Fatalf("got %d ids; want %d (%v)", len(ids), len(wantIDs), ids)
}
for i, id := range wantIDs {
if ids[i] != id {
t.Errorf("ids[%d] = %d; want %d", i, ids[i], id)
}
}
if hits.Load() != 2 {
t.Errorf("hits = %d; want 2", hits.Load())
}
}
func TestE2E_Search_RespectsMaxPages(t *testing.T) {
full := `<html><body>
<section id="gallery-search-results">
<figure id="sid-1"><a href="/view/1/" title="A"><img src="/t.png"/></a>
<figcaption><p><a href="/view/1/" title="A">A</a></p><p>by <a href="/user/x/">X</a></p></figcaption>
</figure>
</section>
<div class="pagination"><a class="button" href="/search/?page=99">Next</a></div>
</body></html>`
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/search/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte(full))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
for _, err := range client.Search(context.Background(), "x", SearchOptions{MaxPages: 3}) {
if err != nil {
t.Fatalf("iter: %v", err)
}
}
if hits.Load() != 3 {
t.Errorf("hits = %d; want 3 (MaxPages cap)", hits.Load())
}
}
func TestE2E_SubmissionInbox_FollowsCursorPagination(t *testing.T) {
// Build a fake inbox page with the date-divider wrapper and a cursor
// link that points at a second page; the second page omits the cursor
// so the iterator stops naturally.
page1 := `<html><body>
<div id="messagecenter-submissions">
<div class="notifications-by-date" data-date="1779177165">
<h4>Today</h4>
<section class="gallery">
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a>
<figcaption><p><a href="/view/1/" title="One">One</a></p><p>by <a href="/user/alice/">Alice</a></p></figcaption>
</figure>
<figure id="sid-2"><a href="/view/2/" title="Two"><img src="/t2.png"/></a>
<figcaption><p><a href="/view/2/" title="Two">Two</a></p><p>by <a href="/user/bob/">Bob</a></p></figcaption>
</figure>
</section>
</div>
</div>
<div class="messagecenter-navigation">
<a class="button standard more" href="/msg/submissions/new~2@72/">Next 72</a>
</div>
</body></html>`
page2 := `<html><body>
<div id="messagecenter-submissions">
<div class="notifications-by-date" data-date="1779000000">
<h4>Yesterday</h4>
<section class="gallery">
<figure id="sid-3"><a href="/view/3/" title="Three"><img src="/t3.png"/></a>
<figcaption><p><a href="/view/3/" title="Three">Three</a></p><p>by <a href="/user/carol/">Carol</a></p></figcaption>
</figure>
</section>
</div>
</div>
</body></html>`
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/msg/submissions/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte(page1))
})
mux.HandleFunc("/msg/submissions/new~2@72/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte(page2))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
var ids []SubmissionID
var posted []time.Time
for sub, err := range client.SubmissionInbox(context.Background(), ListOptions{}) {
if err != nil {
t.Fatalf("iter: %v", err)
}
ids = append(ids, sub.ID)
posted = append(posted, sub.PostedAt)
}
wantIDs := []SubmissionID{1, 2, 3}
if len(ids) != len(wantIDs) {
t.Fatalf("got %d ids; want %d", len(ids), len(wantIDs))
}
for i, id := range wantIDs {
if ids[i] != id {
t.Errorf("ids[%d] = %d; want %d", i, ids[i], id)
}
}
if posted[0].IsZero() || posted[2].IsZero() {
t.Errorf("PostedAt not populated from data-date: %v / %v", posted[0], posted[2])
}
if hits.Load() != 2 {
t.Errorf("hits = %d; want 2", hits.Load())
}
}
// inboxPageHTML builds a /msg/submissions/ page wrapping figures for the
// given submission IDs (newest first) in FA's date-divider markup.
func inboxPageHTML(ids []int) string {
var figs strings.Builder
for _, id := range ids {
fmt.Fprintf(&figs,
`<figure id="sid-%d"><a href="/view/%d/" title="S%d"><img src="/t.png"/></a></figure>`,
id, id, id)
}
return `<html><body><div id="messagecenter-submissions">` +
`<div class="notifications-by-date" data-date="1779177165">` +
`<section class="gallery">` + figs.String() + `</section></div></div></body></html>`
}
// When FA serves a full inbox page (72 items) but omits the "Next 72"
// cursor link, the iterator must keep crawling by synthesizing the cursor
// from the oldest submission on the page otherwise a multi-thousand-item
// inbox is truncated to its first page (SDK issue #23).
func TestE2E_SubmissionInbox_SynthesizesCursorWhenLinkMissing(t *testing.T) {
// Page 1: a full page (72 items, ids 200..129) with NO "Next 72" link.
page1IDs := make([]int, 0, 72)
for id := 200; id >= 129; id-- {
page1IDs = append(page1IDs, id)
}
// Page 2: the synthesized cursor lands here; a short final page.
page2IDs := make([]int, 0, 30)
for id := 128; id >= 99; id-- {
page2IDs = append(page2IDs, id)
}
mux := http.NewServeMux()
mux.HandleFunc("/msg/submissions/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(inboxPageHTML(page1IDs)))
})
mux.HandleFunc("/msg/submissions/new~129@72/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(inboxPageHTML(page2IDs)))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
var got []int
for sub, err := range client.SubmissionInbox(context.Background(), ListOptions{}) {
if err != nil {
t.Fatalf("iter: %v", err)
}
got = append(got, int(sub.ID))
}
if len(got) != 102 {
t.Fatalf("got %d items; want 102 (72 + 30)", len(got))
}
if got[0] != 200 || got[101] != 99 {
t.Errorf("boundary ids = %d..%d; want 200..99", got[0], got[101])
}
}
func TestE2E_ContextCancelInterruptsIterator(t *testing.T) {
page := `<html><body>
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a></figure>
<a class="button standard" href="/gallery/me/2/">Next</a>
</body></html>`
mux := http.NewServeMux()
mux.HandleFunc("/gallery/me/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(page))
})
mux.HandleFunc("/gallery/me/2/", func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
ctx, cancel := context.WithCancel(context.Background())
gotErr := error(nil)
count := 0
go func() {
time.Sleep(30 * time.Millisecond)
cancel()
}()
for _, err := range client.Gallery(ctx, "me", ListOptions{}) {
if err != nil {
gotErr = err
break
}
count++
}
if gotErr == nil {
t.Fatal("expected cancellation error; iterator completed normally")
}
}

46
enums.go Normal file
View File

@@ -0,0 +1,46 @@
package fa
import "strings"
// Rating reflects the submission's content rating as displayed on the page.
// FA uses three buckets. Unknown values from future site changes are preserved
// verbatim rather than mapped to an "Unknown" constant, so parser drift
// surfaces in field values, not in panics.
type Rating string
const (
RatingGeneral Rating = "General"
RatingMature Rating = "Mature"
RatingAdult Rating = "Adult"
)
// ParseRating normalises a rating string (case-insensitive) into one of the
// known constants, or returns it lower-cased-and-titled verbatim if unknown.
func ParseRating(s string) Rating {
switch strings.ToLower(strings.TrimSpace(s)) {
case "general", "g":
return RatingGeneral
case "mature", "m":
return RatingMature
case "adult", "a", "explicit", "e":
return RatingAdult
default:
return Rating(strings.TrimSpace(s))
}
}
// Category is FA's "Category" metadata field (e.g. "Artwork (Digital)",
// "Story", "Music"). Stored as the raw string so unknown categories survive.
type Category string
// Type is FA's "Type" metadata field. Often parallels Category for visual
// art ("Digital", "Traditional", "Photography", ...) and differs for other
// media. Stored raw.
type Type string
// Species is FA's "Species" metadata field (e.g. "Wolf", "Dragon", "Fox").
// Free-form on the site.
type Species string
// Gender is FA's "Gender" metadata field (e.g. "Male", "Female", "Any").
type Gender string

24
enums_test.go Normal file
View File

@@ -0,0 +1,24 @@
package fa
import "testing"
func TestParseRating(t *testing.T) {
cases := map[string]Rating{
"General": RatingGeneral,
"general": RatingGeneral,
"g": RatingGeneral,
" Mature": RatingMature,
"M": RatingMature,
"Adult": RatingAdult,
"a": RatingAdult,
"Explicit": RatingAdult,
"E": RatingAdult,
// Unknown values survive verbatim (trimmed).
"WhoKnows": Rating("WhoKnows"),
}
for in, want := range cases {
if got := ParseRating(in); got != want {
t.Errorf("ParseRating(%q) = %q; want %q", in, got, want)
}
}
}

65
errors.go Normal file
View File

@@ -0,0 +1,65 @@
package fa
import (
"errors"
"fmt"
)
// Sentinel errors. Callers should use errors.Is to test for these.
var (
// ErrNotFound is returned when FA renders a "not found" system message
// or an HTTP 404. Covers deleted submissions, unknown users, etc.
ErrNotFound = errors.New("fa: not found")
// ErrUnauthorized is returned when an endpoint requires authentication
// (the SDK was called without cookies, or the cookies were rejected by FA).
ErrUnauthorized = errors.New("fa: unauthorized cookies missing or invalid")
// ErrCloudflareChallenge is returned when Cloudflare interposes a JS
// challenge or managed challenge. Callers must refresh their cf_clearance
// cookie and User-Agent in a real browser, then retry.
ErrCloudflareChallenge = errors.New("fa: cloudflare challenge refresh cf_clearance")
// ErrRateLimited is returned when FA or Cloudflare responds with HTTP 429
// and the transport's retry budget has been exhausted.
ErrRateLimited = errors.New("fa: rate limited by server")
// ErrSystemMessage is the umbrella error for any FA "system-message" page
// that doesn't match a more specific sentinel. Wrap in a *SystemMessageError
// for the title/body.
ErrSystemMessage = errors.New("fa: system message")
// ErrParse is returned when the HTML parser cannot extract a required field.
// Often indicates FA changed its markup.
ErrParse = errors.New("fa: parse error")
)
// SystemMessageError carries the title and body of a FA system-message page
// that doesn't classify into one of the more specific sentinels.
type SystemMessageError struct {
Title string
Body string
}
func (e *SystemMessageError) Error() string {
if e.Title == "" {
return "fa: system message: " + e.Body
}
return fmt.Sprintf("fa: system message: %s: %s", e.Title, e.Body)
}
// Is allows errors.Is(err, ErrSystemMessage) to match.
func (e *SystemMessageError) Is(target error) bool {
return target == ErrSystemMessage
}
// HTTPError wraps a non-success HTTP response that the transport surfaces
// to the caller after exhausting retries.
type HTTPError struct {
StatusCode int
URL string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("fa: http %d for %s", e.StatusCode, e.URL)
}

34
errors_test.go Normal file
View File

@@ -0,0 +1,34 @@
package fa
import (
"errors"
"testing"
)
func TestSystemMessageError_IsErrSystemMessage(t *testing.T) {
err := &SystemMessageError{Title: "Something", Body: "broke"}
if !errors.Is(err, ErrSystemMessage) {
t.Fatalf("errors.Is(SystemMessageError, ErrSystemMessage) = false; want true")
}
}
func TestHTTPError_Format(t *testing.T) {
e := &HTTPError{StatusCode: 503, URL: "https://example/"}
if got := e.Error(); got != "fa: http 503 for https://example/" {
t.Fatalf("HTTPError.Error() = %q", got)
}
}
func TestSentinelsAreDistinct(t *testing.T) {
sentinels := []error{
ErrNotFound, ErrUnauthorized, ErrCloudflareChallenge,
ErrRateLimited, ErrSystemMessage, ErrParse,
}
for i, a := range sentinels {
for j, b := range sentinels {
if i != j && errors.Is(a, b) {
t.Errorf("sentinel %v wrongly matches %v", a, b)
}
}
}
}

55
examples/basic/main.go Normal file
View File

@@ -0,0 +1,55 @@
// basic demonstrates use of the SDK: fetching a single submission and
// printing a few fields.
//
// The example runs anonymously by default. If the FA_A / FA_B (and ideally
// CF_CLEARANCE + FA_UA) environment variables are set, it authenticates
// with them required for any submission FA gates behind login, mature
// content guard, or Cloudflare challenges.
//
// go run ./examples/basic 12345678
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
if len(os.Args) < 2 {
log.Fatalf("usage: %s <submission-id>", os.Args[0])
}
id, err := strconv.ParseInt(os.Args[1], 10, 64)
if err != nil {
log.Fatalf("invalid submission id: %v", err)
}
opts := []fa.Option{fa.WithUserAgent("go-fa-api-example/0.1")}
if a, b := os.Getenv("FA_A"), os.Getenv("FA_B"); a != "" && b != "" {
log.Printf("using FA_A/FA_B cookies for authenticated request")
opts = []fa.Option{
fa.WithCookies(fa.Cookies{A: a, B: b}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
}
}
client := fa.New(opts...)
sub, err := client.GetSubmission(context.Background(), fa.SubmissionID(id))
if err != nil {
log.Fatalf("GetSubmission: %v", err)
}
fmt.Printf("%s\nby %s\nrating: %s\ntags: %v\nfile: %s\n",
sub.Title, sub.Author.DisplayName, sub.Rating, sub.Tags, sub.FileURL)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

54
examples/browse/main.go Normal file
View File

@@ -0,0 +1,54 @@
// browse prints the global front-page feed at /browse/ FA's "what's new
// across the site" stream. Uses anon access by default; honours FA_A /
// FA_B / CF_CLEARANCE / FA_UA from env when set (recommended, since FA
// gates parts of the feed behind login + Cloudflare).
//
// go run ./examples/browse # 1 page, ~72 items
// go run ./examples/browse 3 # 3 pages
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
maxPages := 1
if len(os.Args) >= 2 {
if n, err := strconv.Atoi(os.Args[1]); err == nil && n > 0 {
maxPages = n
}
}
opts := []fa.Option{fa.WithUserAgent("go-fa-api-example/0.1")}
if a, b := os.Getenv("FA_A"), os.Getenv("FA_B"); a != "" && b != "" {
opts = []fa.Option{
fa.WithCookies(fa.Cookies{A: a, B: b}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
}
}
client := fa.New(opts...)
count := 0
for sub, err := range client.Browse(context.Background(), fa.BrowseOptions{MaxPages: maxPages}) {
if err != nil {
log.Fatalf("Browse: %v", err)
}
count++
fmt.Printf("[%d] %s %s by %s\n", sub.ID, sub.Title, sub.Rating, sub.Author.DisplayName)
}
fmt.Printf("\n%d submissions across %d page(s)\n", count, maxPages)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

49
examples/download/main.go Normal file
View File

@@ -0,0 +1,49 @@
// download fetches a single submission and streams its main file to disk.
// Demonstrates that downloads share the SDK's rate limiter and cookie jar
// with metadata fetches.
//
// go run ./examples/download 12345678 out.jpg
package main
import (
"context"
"log"
"os"
"strconv"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
if len(os.Args) < 3 {
log.Fatalf("usage: %s <submission-id> <out-path>", os.Args[0])
}
id, err := strconv.ParseInt(os.Args[1], 10, 64)
if err != nil {
log.Fatalf("invalid id: %v", err)
}
client := fa.New(
fa.WithCookies(fa.Cookies{A: os.Getenv("FA_A"), B: os.Getenv("FA_B")}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(os.Getenv("FA_UA")),
)
ctx := context.Background()
sub, err := client.GetSubmission(ctx, fa.SubmissionID(id))
if err != nil {
log.Fatalf("GetSubmission: %v", err)
}
out, err := os.Create(os.Args[2])
if err != nil {
log.Fatalf("create: %v", err)
}
defer out.Close()
n, err := client.Download(ctx, sub, out)
if err != nil {
log.Fatalf("download: %v", err)
}
log.Printf("wrote %d bytes to %s", n, os.Args[2])
}

View File

@@ -0,0 +1,54 @@
// gallery_dump iterates a user's gallery (authenticated) and prints the
// title and ID of every submission encountered, honouring the SDK's default
// 1 req/sec rate limit.
//
// Required environment variables:
//
// FA_A — the `a` session cookie
// FA_B — the `b` session cookie
// CF_CLEARANCE — the cf_clearance cookie from the same browser session
// FA_UA — the User-Agent string that produced CF_CLEARANCE
//
// Usage:
//
// go run ./examples/gallery_dump <username> [maxPages]
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
if len(os.Args) < 2 {
log.Fatalf("usage: %s <username> [maxPages]", os.Args[0])
}
user := os.Args[1]
maxPages := 0
if len(os.Args) >= 3 {
if n, err := strconv.Atoi(os.Args[2]); err == nil {
maxPages = n
}
}
client := fa.New(
fa.WithCookies(fa.Cookies{A: os.Getenv("FA_A"), B: os.Getenv("FA_B")}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(os.Getenv("FA_UA")),
)
count := 0
for sub, err := range client.Gallery(context.Background(), user, fa.ListOptions{MaxPages: maxPages}) {
if err != nil {
log.Fatalf("iter: %v", err)
}
count++
fmt.Printf("[%d] %s %s\n", sub.ID, sub.Title, sub.Rating)
}
fmt.Printf("\n%d submissions\n", count)
}

76
examples/inbox/main.go Normal file
View File

@@ -0,0 +1,76 @@
// inbox prints the logged-in user's new submissions the "what's new
// from people you watch" feed at https://www.furaffinity.net/msg/submissions/.
//
// Requires FA_A and FA_B (session cookies). CF_CLEARANCE + FA_UA are
// strongly recommended without them FurAffinity's Cloudflare layer will
// usually serve a challenge page instead of the inbox.
//
// go run ./examples/inbox # first page (~72 items)
// go run ./examples/inbox 3 # up to 3 cursor pages (~216 items)
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"strconv"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
maxPages := 1
if len(os.Args) >= 2 {
if n, err := strconv.Atoi(os.Args[1]); err == nil && n > 0 {
maxPages = n
}
}
a, b := os.Getenv("FA_A"), os.Getenv("FA_B")
if a == "" || b == "" {
log.Fatal("FA_A and FA_B must be set the submission inbox requires login")
}
//if os.Getenv("CF_CLEARANCE") == "" || os.Getenv("FA_UA") == "" {
// log.Println("warning: CF_CLEARANCE / FA_UA not set expect a Cloudflare challenge")
//}
client := fa.New(
fa.WithCookies(fa.Cookies{A: a, B: b}),
//fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
// JSON-first listing parse: more resilient to FA markup tweaks,
// and populates each item's author avatar URL.
fa.WithExperimentalJSONListings(false),
)
count := 0
for sub, err := range client.SubmissionInbox(context.Background(), fa.ListOptions{MaxPages: maxPages}) {
if err != nil {
switch {
case errors.Is(err, fa.ErrCloudflareChallenge):
log.Fatal("Cloudflare challenge refresh CF_CLEARANCE + FA_UA from your browser and retry")
case errors.Is(err, fa.ErrUnauthorized):
log.Fatal("unauthorized FA_A / FA_B are missing or expired")
default:
log.Fatalf("SubmissionInbox: %v", err)
}
}
count++
when := "—"
if !sub.PostedAt.IsZero() {
when = sub.PostedAt.Format("2006-01-02 15:04")
}
fmt.Printf("[%d] %-50.50s %-8s by %s (%s)\n",
sub.ID, sub.Title, sub.Rating, sub.Author.DisplayName, when)
}
fmt.Printf("\n%d new submission(s) across up to %d page(s)\n", count, maxPages)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

73
examples/notes/main.go Normal file
View File

@@ -0,0 +1,73 @@
// notes dumps the /msg/pms/ inbox listing and, if an argument is given,
// prints the full body of that note id. Requires FA_A / FA_B in env.
//
// go run ./examples/notes # list inbox
// go run ./examples/notes 131012623 # read one note
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
a, b := os.Getenv("FA_A"), os.Getenv("FA_B")
if a == "" || b == "" {
log.Fatal("FA_A and FA_B must be set notes require login")
}
client := fa.New(
fa.WithCookies(fa.Cookies{A: a, B: b}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
)
ctx := context.Background()
if len(os.Args) >= 2 {
id, err := strconv.ParseInt(os.Args[1], 10, 64)
if err != nil {
log.Fatalf("invalid note id: %v", err)
}
n, err := client.GetNote(ctx, fa.NoteID(id))
if err != nil {
log.Fatalf("GetNote: %v", err)
}
fmt.Printf("Subject: %s\nFrom: %s (@%s)\nTo: %s (@%s)\nSent: %s\n\n%s\n",
n.Subject,
n.From.DisplayName, n.From.Name,
n.To.DisplayName, n.To.Name,
n.SentAt.Format("2006-01-02 15:04"),
n.BodyText)
return
}
count := 0
for np, err := range client.Notes(ctx, fa.ListOptions{MaxPages: 1}) {
if err != nil {
log.Fatalf("Notes: %v", err)
}
count++
unread := " "
if np.Unread {
unread = "*"
}
from := np.Sender.DisplayName
if from == "" {
from = np.Sender.Name
}
fmt.Printf("[%s] [%d] %s from %s (%s)\n",
unread, np.ID, np.Subject, from, np.SentAt.Format("2006-01-02 15:04"))
}
fmt.Printf("\n%d notes on first page\n", count)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@@ -0,0 +1,70 @@
// notifications dumps the full /msg/others/ page watch / journal /
// comment / fav / shout notifications, single fetch, all categories.
// Requires FA_A / FA_B in env (and ideally CF_CLEARANCE + FA_UA).
//
// go run ./examples/notifications
package main
import (
"context"
"fmt"
"log"
"os"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
a, b := os.Getenv("FA_A"), os.Getenv("FA_B")
if a == "" || b == "" {
log.Fatal("FA_A and FA_B must be set notifications require login")
}
client := fa.New(
fa.WithCookies(fa.Cookies{A: a, B: b}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
)
n, err := client.Notifications(context.Background())
if err != nil {
log.Fatalf("Notifications: %v", err)
}
section("Journals", len(n.Journals))
for _, j := range n.Journals {
fmt.Printf(" [%d] %s by %s (%s, %s)\n",
j.JournalID, j.Title, j.Author.DisplayName, j.Rating,
j.PostedAt.Format("2006-01-02 15:04"))
}
section("New watchers", len(n.Watches))
for _, w := range n.Watches {
fmt.Printf(" %s (%s)\n", w.User.DisplayName, w.WatchedAt.Format("2006-01-02 15:04"))
}
section("Submission comments", len(n.SubmissionComments))
for _, c := range n.SubmissionComments {
fmt.Printf(" on submission %d (%q) by %s\n", c.OnSubmission, c.OnTitle, c.Author.DisplayName)
}
section("Journal comments", len(n.JournalComments))
for _, c := range n.JournalComments {
fmt.Printf(" on journal %d (%q) by %s\n", c.OnJournal, c.OnTitle, c.Author.DisplayName)
}
section("Favorites", len(n.Favorites))
for _, f := range n.Favorites {
fmt.Printf(" %s favorited %d (%q)\n", f.Favoriter.DisplayName, f.SubmissionID, f.SubmissionTitle)
}
section("Shouts", len(n.Shouts))
for _, s := range n.Shouts {
fmt.Printf(" shout from %s (%s)\n", s.Author.DisplayName, s.PostedAt.Format("2006-01-02 15:04"))
}
}
func section(name string, count int) {
fmt.Printf("\n=== %s (%d) ===\n", name, count)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

117
examples/priority/main.go Normal file
View File

@@ -0,0 +1,117 @@
// priority demonstrates multi-level rate-limiter priority.
//
// A background gallery crawl runs continuously at fa.PriorityBackground
// while interactive submission fetches at fa.PriorityInteractive jump ahead
// of it even though the crawl started first and both share one global
// token bucket. (fa.PriorityNormal and fa.PriorityLow sit between the two;
// the same rule applies higher priority is always served first.)
//
// The example runs anonymously by default. If the FA_A / FA_B (and ideally
// CF_CLEARANCE + FA_UA) environment variables are set, it authenticates
// with them.
//
// Watch the timestamps in the output: once the interactive fetches are
// issued, they take the next tokens and the background crawl is pushed back.
//
// go run ./examples/priority <username> <submission-id> [<submission-id>...]
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
"sync"
"time"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
if len(os.Args) < 3 {
log.Fatalf("usage: %s <username> <submission-id> [<submission-id>...]", os.Args[0])
}
user := os.Args[1]
ids := make([]fa.SubmissionID, 0, len(os.Args)-2)
for _, arg := range os.Args[2:] {
n, err := strconv.ParseInt(arg, 10, 64)
if err != nil {
log.Fatalf("invalid submission id %q: %v", arg, err)
}
ids = append(ids, fa.SubmissionID(n))
}
// Priority scheduling must be enabled at construction time; without
// WithPrioritizedRateLimiting the WithPriority markers below are inert.
opts := []fa.Option{
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
fa.WithPrioritizedRateLimiting(true),
}
if a, b := os.Getenv("FA_A"), os.Getenv("FA_B"); a != "" && b != "" {
log.Printf("using FA_A/FA_B cookies for authenticated requests")
opts = append(opts,
fa.WithCookies(fa.Cookies{A: a, B: b}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
)
}
client := fa.New(opts...)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
start := time.Now()
since := func() string { return fmt.Sprintf("%5.1fs", time.Since(start).Seconds()) }
// Background crawler: walks the user's gallery at the lowest priority,
// keeping the shared token bucket busy so the priority effect is visible.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
bgCtx := fa.WithBackgroundPriority(ctx)
n := 0
for sub, err := range client.Gallery(bgCtx, user, fa.ListOptions{}) {
if err != nil {
if ctx.Err() == nil { // not just our own cancel
log.Printf("[%s] background crawl stopped: %v", since(), err)
}
return
}
n++
fmt.Printf("[%s] background · gallery item %3d (%d) %s\n",
since(), n, sub.ID, sub.Title)
}
fmt.Printf("[%s] background · gallery exhausted after %d items\n", since(), n)
}()
// Give the crawler a head start so it is mid-crawl, holding the queue,
// when the interactive fetches arrive.
time.Sleep(1500 * time.Millisecond)
// Interactive fetches: the user is waiting on these. Each takes the very
// next token, ahead of the background crawl's pending page fetch.
for _, id := range ids {
ictx := fa.WithPriority(ctx, fa.PriorityInteractive)
t0 := time.Now()
sub, err := client.GetSubmission(ictx, id)
if err != nil {
log.Printf("[%s] interactive · GetSubmission(%d): %v", since(), id, err)
continue
}
fmt.Printf("[%s] INTERACTIVE · got (%d) %q in %.1fs jumped the queue\n",
since(), id, sub.Title, time.Since(t0).Seconds())
}
// Foreground work is done stop the background crawler and wait for it.
cancel()
wg.Wait()
fmt.Printf("[%s] done\n", since())
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

68
examples/search/main.go Normal file
View File

@@ -0,0 +1,68 @@
// search runs a /search/?q=... query and prints the first N pages of
// matches. Works anonymously for general-rated results; mature/adult
// searches require login.
//
// go run ./examples/search dragon
// go run ./examples/search "fox knight" --max=2 --rating=general --order=date
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"strings"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
maxPages := flag.Int("max", 1, "max pages to fetch")
ratingFlag := flag.String("rating", "", "comma-separated: general, mature, adult (empty = all)")
orderFlag := flag.String("order", "", "relevancy | date | popularity (empty = default)")
flag.Parse()
if flag.NArg() == 0 {
log.Fatalf("usage: %s [flags] <query>", os.Args[0])
}
query := strings.Join(flag.Args(), " ")
opts := []fa.Option{fa.WithUserAgent("go-fa-api-example/0.1")}
if a, b := os.Getenv("FA_A"), os.Getenv("FA_B"); a != "" && b != "" {
opts = []fa.Option{
fa.WithCookies(fa.Cookies{A: a, B: b}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
}
}
client := fa.New(opts...)
so := fa.SearchOptions{MaxPages: *maxPages}
if *ratingFlag != "" {
for _, r := range strings.Split(*ratingFlag, ",") {
so.Ratings = append(so.Ratings, fa.ParseRating(strings.TrimSpace(r)))
}
}
if *orderFlag != "" {
so.OrderBy = fa.SearchOrder(*orderFlag)
}
count := 0
for sub, err := range client.Search(context.Background(), query, so) {
if err != nil {
log.Fatalf("Search: %v", err)
}
count++
fmt.Printf("[%d] %s %s by %s\n",
sub.ID, sub.Title, sub.Rating, sub.Author.DisplayName)
}
fmt.Printf("\n%d results for %q across up to %d page(s)\n", count, query, *maxPages)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

330
fixtures_refresh_test.go Normal file
View File

@@ -0,0 +1,330 @@
//go:build fixtures
// This file is compiled only when the `fixtures` build tag is set:
//
// go test -tags=fixtures -run TestRefreshFixtures ./...
//
// It hits live FurAffinity with the cookies in your environment and snapshots
// the response body of each curated page into testdata/html/. The regular
// parser tests read from those snapshots, so this is how we keep the parser
// in sync with the live site without baking sample data into the repo.
//
// Each fixture is its own subtest. A failure on one (network blip, dead
// target, fresh CF challenge) does not abort the rest.
//
// # Required environment variables
//
// FA_A — `a` session cookie
// FA_B — `b` session cookie
// CF_CLEARANCE — cf_clearance cookie from the same browser session
// FA_UA — User-Agent string that produced CF_CLEARANCE
//
// # Per-fixture targets
//
// All of these have defaults that fall back to FA_TEST_USER (your own login
// name) where possible. Set them explicitly to capture data from somewhere
// other than your own profile.
//
// FA_TEST_USER base username (yours)
// FA_TEST_SUB_ID image submission ID (default: 12345678)
// FA_TEST_SUB_STORY_ID non-image submission ID (story/music/PDF)
// FA_TEST_GALLERY_USER gallery owner (default: FA_TEST_USER)
// FA_TEST_GALLERY_LAST_PAGE page index near/at the end of that gallery
// FA_TEST_SCRAPS_USER scraps owner (default: FA_TEST_GALLERY_USER)
// FA_TEST_FAVORITES_USER favorites owner (default: FA_TEST_USER)
// FA_TEST_JOURNALS_USER journals listing owner (default: FA_TEST_USER)
// FA_TEST_JOURNAL_ID single journal ID
// FA_TEST_USER_WITH_SHOUTS profile that has visible shouts
// FA_TEST_USER_WITH_BANNER profile that has a custom site banner uploaded
// FA_TEST_NOTE_ID single note (PM) ID (M2 prep)
// FA_TEST_SEARCH_QUERY search keyword (M4 prep)
// FA_TEST_NONEXISTENT_SUB_ID ID guaranteed to 404 (default: 9999999999)
package fa
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
"github.com/gocolly/colly/v2"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// fixtureTarget defines one HTML file to capture. requires lists env-var
// names that must be set (after defaults are resolved) for this fixture to
// be attempted; targets with missing prerequisites are skipped, not failed.
type fixtureTarget struct {
name string
url string
requires []string // already-resolved values to check non-empty
notes string
}
func TestRefreshFixtures(t *testing.T) {
a := os.Getenv("FA_A")
b := os.Getenv("FA_B")
if a == "" || b == "" {
t.Skip("FA_A / FA_B not set; cannot refresh fixtures")
}
cf := os.Getenv("CF_CLEARANCE")
ua := os.Getenv("FA_UA")
if cf == "" || ua == "" {
t.Log("warning: CF_CLEARANCE or FA_UA not set; refresh likely to hit a Cloudflare challenge")
}
if err := os.MkdirAll(fixturesDir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", fixturesDir, err)
}
client := New(
WithCookies(Cookies{A: a, B: b}),
WithCloudflare(CFCookies{Clearance: cf}),
WithUserAgent(ua),
)
// Resolve targets every fixture is gated on the relevant env-derived
// values being non-empty so an incomplete env still gets you the
// fixtures you can capture.
user := os.Getenv("FA_TEST_USER")
galleryUser := envOr("FA_TEST_GALLERY_USER", user)
scrapsUser := envOr("FA_TEST_SCRAPS_USER", galleryUser)
favoritesUser := envOr("FA_TEST_FAVORITES_USER", user)
journalsUser := envOr("FA_TEST_JOURNALS_USER", user)
shoutsUser := os.Getenv("FA_TEST_USER_WITH_SHOUTS")
bannerUser := os.Getenv("FA_TEST_USER_WITH_BANNER")
searchQuery := os.Getenv("FA_TEST_SEARCH_QUERY")
subID := atoi64Default(os.Getenv("FA_TEST_SUB_ID"), 12345678)
storyID := atoi64Default(os.Getenv("FA_TEST_SUB_STORY_ID"), 0)
journalID := atoi64Default(os.Getenv("FA_TEST_JOURNAL_ID"), 0)
noteID := atoi64Default(os.Getenv("FA_TEST_NOTE_ID"), 0)
galleryLastPage := atoiDefault(os.Getenv("FA_TEST_GALLERY_LAST_PAGE"), 0)
missingSubID := atoi64Default(os.Getenv("FA_TEST_NONEXISTENT_SUB_ID"), 9999999999)
targets := []fixtureTarget{
// ---- M1: read API verifiable today --------------------------------
{
name: "submission.html",
url: urls.Submission(subID),
requires: []string{strconv.FormatInt(subID, 10)},
notes: "image submission used by parseSubmission tests + comments parser",
},
{
name: "submission_story.html",
url: urls.Submission(storyID),
requires: []string{strconv.FormatInt(storyID, 10)},
notes: "non-image submission (story/music/PDF) exercises FileURL fallback to Download button",
},
{
name: "user.html",
url: urls.User(user),
requires: []string{user},
notes: "user profile used by parseUser tests",
},
{
name: "user_with_shouts.html",
url: urls.User(shoutsUser),
requires: []string{shoutsUser},
notes: "profile that exposes shouts used to validate shouts parser",
},
{
name: "user_with_banner.html",
url: urls.User(bannerUser),
requires: []string{bannerUser},
notes: "profile that has a custom uploaded site banner used to validate SiteBanner.IsCustom",
},
{
name: "gallery_page1.html",
url: urls.Gallery(galleryUser, 1),
requires: []string{galleryUser},
notes: "first gallery page figure[id^=sid-] iteration",
},
{
name: "gallery_page_last.html",
url: urls.Gallery(galleryUser, galleryLastPage),
requires: []string{galleryUser, strconv.Itoa(galleryLastPage)},
notes: "last gallery page verifies detectNextPage returns false at the end",
},
{
name: "scraps_page1.html",
url: urls.Scraps(scrapsUser, 1),
requires: []string{scrapsUser},
notes: "scraps listing same parser as gallery; sanity-check shape",
},
{
name: "favorites_page1.html",
url: urls.Favorites(favoritesUser, 1),
requires: []string{favoritesUser},
notes: "favorites per-item Author should be the original artist",
},
{
name: "journals_listing_page1.html",
url: urls.UserJournals(journalsUser, 1),
requires: []string{journalsUser},
notes: "journals listing used by UserJournals iterator",
},
{
name: "journal.html",
url: urls.Journal(journalID),
requires: []string{strconv.FormatInt(journalID, 10)},
notes: "single journal entry parseJournal target",
},
{
name: "comments_submission.html",
url: urls.Submission(subID),
requires: []string{strconv.FormatInt(subID, 10)},
notes: "submission page captured a second time for comment-parser fixture (comments are inline)",
},
{
name: "comments_journal.html",
url: urls.Journal(journalID),
requires: []string{strconv.FormatInt(journalID, 10)},
notes: "journal page captured for journal comments parsing",
},
{
name: "system_message_not_found.html",
url: urls.Submission(missingSubID),
requires: []string{strconv.FormatInt(missingSubID, 10)},
notes: "captures FA's system-message page for ErrNotFound classifier validation",
},
// ---- M2: inbox/notes (parsers not yet written; captures for prep) -
{
name: "msg_submissions.html",
url: urls.MsgSubmissions(),
requires: []string{a},
notes: "M2 prep: new-submission inbox (auth required)",
},
{
name: "msg_others.html",
url: urls.MsgOthers(),
requires: []string{a},
notes: "M2 prep: watch/journal/comment/fav notifications",
},
{
name: "msg_pms.html",
url: urls.MsgPMs(),
requires: []string{a},
notes: "M2 prep: private-message inbox",
},
{
name: "note_view.html",
url: urls.ViewMessage(noteID),
requires: []string{strconv.FormatInt(noteID, 10)},
notes: "M2 prep: single note view (needs FA_TEST_NOTE_ID)",
},
// ---- M4: search/browse (parsers not yet written; captures for prep)
{
name: "search_results.html",
url: urls.Search(searchQuery, 1),
requires: []string{searchQuery},
notes: "M4 prep: search results page",
},
{
name: "browse.html",
url: urls.Browse(1),
requires: []string{a},
notes: "M4 prep: /browse/ page",
},
}
for _, tg := range targets {
t.Run(tg.name, func(t *testing.T) {
for _, r := range tg.requires {
if strings.TrimSpace(r) == "" || r == "0" {
t.Skipf("required input not set; skipping (%s)", tg.notes)
return
}
}
raw, err := fetchRaw(t.Context(), client, tg.url)
if err != nil {
t.Fatalf("fetch %s (%s): %v", tg.name, tg.url, err)
}
if doc, derr := goquery.NewDocumentFromReader(bytes.NewReader(raw)); derr == nil {
if title := strings.TrimSpace(doc.Find("title").First().Text()); title == "Just a moment..." {
t.Fatalf("got Cloudflare challenge page; refresh CF_CLEARANCE / FA_UA")
}
}
out := filepath.Join(fixturesDir, tg.name)
if err := os.WriteFile(out, raw, 0o644); err != nil {
t.Fatalf("write %s: %v", out, err)
}
t.Logf("wrote %s (%d bytes) %s", out, len(raw), tg.notes)
})
}
}
// fetchRaw fetches the URL through the same Colly+transport pipeline the SDK
// uses for parsed calls, but hands back the raw response body instead of
// running a parser. Lives in the test build so we don't expose a public
// raw-fetch API just for fixture refreshing.
func fetchRaw(ctx context.Context, c *Client, rawURL string) ([]byte, error) {
clone := c.collector.Clone()
clone.SetClient(c.http)
clone.SetCookieJar(c.jar)
clone.Context = ctx
var body []byte
var respErr error
clone.OnResponse(func(r *colly.Response) {
// Copy: r.Body is reused by Colly across responses.
body = append(body[:0], r.Body...)
})
clone.OnError(func(r *colly.Response, err error) {
respErr = err
})
if err := clone.Visit(rawURL); err != nil {
if respErr != nil {
return nil, respErr
}
return nil, err
}
if respErr != nil {
return nil, respErr
}
if len(body) == 0 {
return nil, errors.New("fetchRaw: empty body")
}
return body, nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// atoi64Default parses s as an int64; on any failure returns fallback.
func atoi64Default(s string, fallback int64) int64 {
if s == "" {
return fallback
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return fallback
}
return n
}
// atoiDefault parses s as an int; on any failure returns fallback.
func atoiDefault(s string, fallback int) int {
if s == "" {
return fallback
}
n, err := strconv.Atoi(s)
if err != nil {
return fallback
}
return n
}

28
fixtures_test.go Normal file
View File

@@ -0,0 +1,28 @@
package fa
import (
"os"
"path/filepath"
"testing"
)
// fixturesDir is where captured FA HTML responses live. The refresh tool
// (see fixtures_refresh_test.go, build tag `fixtures`) writes here.
const fixturesDir = "testdata/html"
// loadFixture reads a captured HTML fixture by name and skips the test if
// the file is missing. Pairs with the `fixtures` build-tagged refresh test:
// if you've never run the refresh, parser tests against real HTML are skipped
// cleanly instead of failing.
func loadFixture(t *testing.T, name string) []byte {
t.Helper()
path := filepath.Join(fixturesDir, name)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
t.Skipf("fixture %s not present run `go test -tags=fixtures` with FA_* env vars to refresh", path)
}
t.Fatalf("read fixture %s: %v", path, err)
}
return data
}

76
gallery.go Normal file
View File

@@ -0,0 +1,76 @@
package fa
import (
"context"
"iter"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// Gallery iterates the submissions in a user's main gallery, newest first.
//
// Each yielded *Submission carries only the fields visible on the listing
// page: ID, Title, Author (for favorites), ThumbURL, and Rating. Call
// [Client.GetSubmission] with the ID to load the full record.
func (c *Client) Gallery(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Submission, error] {
return c.listGallerySection(ctx, name, urls.Gallery, opts)
}
// Scraps iterates the user's scraps folder. Same yield shape as Gallery.
func (c *Client) Scraps(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Submission, error] {
return c.listGallerySection(ctx, name, urls.Scraps, opts)
}
// Favorites iterates the user's favorited submissions. The yielded
// *Submission's Author field reflects the original artist (not the user
// whose favorites we are walking).
func (c *Client) Favorites(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Submission, error] {
return c.listGallerySection(ctx, name, urls.Favorites, opts)
}
// listGallerySection is the shared engine for Gallery / Scraps / Favorites.
// urlFn picks the section-specific URL builder; the rest of the pagination
// machinery is identical across all three sections.
func (c *Client) listGallerySection(
ctx context.Context,
name string,
urlFn func(string, int) string,
opts ListOptions,
) iter.Seq2[*Submission, error] {
return func(yield func(*Submission, error) bool) {
page := opts.firstPage()
pagesFetched := 0
for {
if opts.reachedLimit(pagesFetched) {
return
}
var (
items []*Submission
hasNext bool
)
err := c.fetch(ctx, urlFn(name, page), func(doc *goquery.Document) error {
items, hasNext = parseGalleryPage(doc, c.cfg.jsonListings)
return nil
})
if err != nil {
yield(nil, err)
return
}
pagesFetched++
if len(items) == 0 {
return
}
for _, s := range items {
if !yield(s, nil) {
return
}
}
if !hasNext {
return
}
page++
}
}
}

107
gallery_parser.go Normal file
View File

@@ -0,0 +1,107 @@
package fa
import (
"strings"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// parseGalleryPage parses one page of /gallery/, /scraps/, /favorites/, or
// /browse/, returning each submission preview and whether a next page
// exists.
//
// useJSON controls the experimental JSON-first merge: when true, the
// parser reads the embedded js-submissionData blob first and uses it as
// the primary source for title/author/avatar; HTML scraping covers what
// the JSON doesn't carry (rating, thumb, ID). When false the parser is
// pure HTML the same behaviour as before [WithExperimentalJSONListings]
// existed.
func parseGalleryPage(doc *goquery.Document, useJSON bool) (items []*Submission, hasNext bool) {
var jsonData listingJSONMap
if useJSON {
jsonData = readListingJSON(doc)
}
doc.Find("figure[id^=sid-]").Each(func(_ int, sel *goquery.Selection) {
if s := parseGalleryFigure(sel, jsonData); s != nil {
items = append(items, s)
}
})
hasNext = detectNextPage(doc)
return items, hasNext
}
// parseGalleryFigure lifts a single submission preview from a
// <figure id="sid-…"> element. Shared between gallery, browse, favorites,
// search, and the submission inbox.
//
// When jsonData is non-nil and contains an entry for this submission's
// ID, the JSON values win for title/author display name/lower-cased name/
// avatar. Rating, ThumbURL, and ID always come from the HTML those
// aren't represented in the JSON blob.
func parseGalleryFigure(sel *goquery.Selection, jsonData listingJSONMap) *Submission {
idAttr, _ := sel.Attr("id")
idStr := strings.TrimPrefix(idAttr, "sid-")
id, err := parseID[SubmissionID](idStr)
if err != nil || id == 0 {
return nil
}
s := &Submission{ID: id}
viewLink := sel.Find("a[href^='/view/']").First()
if viewLink.Length() > 0 {
s.Title = firstNonEmpty(
trimAttr(viewLink, "title"),
trimText(sel.Find("figcaption p:first-child").First()),
trimText(viewLink),
)
img := viewLink.Find("img").First()
s.ThumbURL = urls.AbsoluteCDN(firstNonEmpty(
trimAttr(img, "data-src"),
trimAttr(img, "src"),
))
}
// Rating class on the figure: figure.t-image.r-general (et al.)
class, _ := sel.Attr("class")
switch {
case strings.Contains(class, "r-adult"):
s.Rating = RatingAdult
case strings.Contains(class, "r-mature"):
s.Rating = RatingMature
case strings.Contains(class, "r-general"):
s.Rating = RatingGeneral
}
// Author from figcaption (favorites/browse render an artist link there).
if author := sel.Find("figcaption a[href^='/user/']").First(); author.Length() > 0 {
href, _ := author.Attr("href")
s.Author = UserRef{
DisplayName: trimText(author),
}
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
s.Author.Name = strings.ToLower(parts[1])
}
}
// JSON enrichment preferred sources for the fields it carries.
if jsonData != nil {
if entry, ok := jsonData[id]; ok {
if entry.Title != "" {
s.Title = entry.Title
}
if entry.Username != "" {
s.Author.DisplayName = entry.Username
}
if entry.Lower != "" {
s.Author.Name = entry.Lower
}
if av := avatarURLFromMtime(entry.Lower, entry.AvatarMtime); av != "" {
s.Author.AvatarURL = av
}
}
}
return s
}

83
gallery_parser_test.go Normal file
View File

@@ -0,0 +1,83 @@
package fa
import (
"bytes"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
const syntheticGalleryHTML = `<html><body>
<figure id="sid-1001" class="t-image r-general">
<a href="/view/1001/" title="Submission One">
<img src="//d.example/thumb/1001.png" data-src="//d.example/thumb/1001.png"/>
</a>
<figcaption>
<p>Submission One</p>
<a href="/user/artistone/">ArtistOne</a>
</figcaption>
</figure>
<figure id="sid-1002" class="t-image r-adult">
<a href="/view/1002/" title="Submission Two">
<img src="//d.example/thumb/1002.png"/>
</a>
<figcaption>
<p>Submission Two</p>
<a href="/user/artisttwo/">ArtistTwo</a>
</figcaption>
</figure>
<a class="button standard" href="/gallery/me/2/">Next</a>
</body></html>`
func TestParseGalleryPage_Synthetic(t *testing.T) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticGalleryHTML))
if err != nil {
t.Fatalf("setup: %v", err)
}
items, hasNext := parseGalleryPage(doc, false)
if len(items) != 2 {
t.Fatalf("items = %d; want 2", len(items))
}
if items[0].ID != 1001 || items[1].ID != 1002 {
t.Errorf("ids = [%d, %d]", items[0].ID, items[1].ID)
}
if items[0].Title != "Submission One" {
t.Errorf("items[0].Title = %q", items[0].Title)
}
if items[0].Rating != RatingGeneral {
t.Errorf("items[0].Rating = %q; want General", items[0].Rating)
}
if items[1].Rating != RatingAdult {
t.Errorf("items[1].Rating = %q; want Adult", items[1].Rating)
}
if items[0].Author.Name != "artistone" {
t.Errorf("items[0].Author.Name = %q", items[0].Author.Name)
}
if !strings.HasPrefix(items[0].ThumbURL, "https://") {
t.Errorf("items[0].ThumbURL = %q; want absolute URL", items[0].ThumbURL)
}
if !hasNext {
t.Error("hasNext = false; want true")
}
}
func TestParseGalleryPage_RealFixture(t *testing.T) {
raw := loadFixture(t, "gallery_page1.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
items, _ := parseGalleryPage(doc, false)
if len(items) == 0 {
t.Fatal("real fixture: no items parsed")
}
for i, it := range items {
if it.ID == 0 {
t.Errorf("item %d: ID == 0", i)
}
if it.Title == "" {
t.Errorf("item %d: empty Title", i)
}
}
}

28
go.mod Normal file
View File

@@ -0,0 +1,28 @@
module git.anthrove.art/public/go-fa-api
go 1.26.0
require (
github.com/PuerkitoBio/goquery v1.12.0
github.com/gocolly/colly/v2 v2.3.0
golang.org/x/time v0.15.0
)
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/antchfx/htmlquery v1.3.5 // indirect
github.com/antchfx/xmlquery v1.5.0 // indirect
github.com/antchfx/xpath v1.3.5 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/kennygrant/sanitize v1.2.4 // indirect
github.com/nlnwa/whatwg-url v0.6.2 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/temoto/robotstxt v1.1.2 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

125
go.sum Normal file
View File

@@ -0,0 +1,125 @@
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c=
github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc=
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gocolly/colly/v2 v2.3.0 h1:HSFh0ckbgVd2CSGRE+Y/iA4goUhGROJwyQDCMXGFBWM=
github.com/gocolly/colly/v2 v2.3.0/go.mod h1:Qp54s/kQbwCQvFVx8KzKCSTXVJ1wWT4QeAKEu33x1q8=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

36
ids.go Normal file
View File

@@ -0,0 +1,36 @@
package fa
import (
"fmt"
"strconv"
)
// SubmissionID identifies a submission. FA exposes these as positive integers
// in URLs of the form /view/{id}/.
type SubmissionID int64
func (id SubmissionID) String() string { return strconv.FormatInt(int64(id), 10) }
// JournalID identifies a journal entry. URL form: /journal/{id}/.
type JournalID int64
func (id JournalID) String() string { return strconv.FormatInt(int64(id), 10) }
// CommentID identifies a comment within a submission or journal page.
type CommentID int64
func (id CommentID) String() string { return strconv.FormatInt(int64(id), 10) }
// parseID parses a positive integer from a string and returns it as T.
// Returns 0 and a wrapped error if s is empty or non-numeric. Used by parsers
// to extract IDs from hrefs.
func parseID[T ~int64](s string) (T, error) {
if s == "" {
return 0, fmt.Errorf("parse id: empty string")
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse id %q: %w", s, err)
}
return T(n), nil
}

168
inbox.go Normal file
View File

@@ -0,0 +1,168 @@
package fa
import (
"context"
"iter"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// SubmissionInbox iterates the new-submission inbox at /msg/submissions/ —
// the feed of submissions posted since you last cleared the inbox by users
// you watch. Requires a logged-in client; anonymous calls hit the login
// gate and surface as [ErrUnauthorized].
//
// Each yielded *Submission carries ID, Title, Author, ThumbURL, Rating,
// and PostedAt (derived from the date-divider's data-date timestamp).
// Items are yielded in document order newest first, grouped by day.
//
// Pagination follows FA's cursor scheme (the "Next 72" link encodes
// "submissions newer than ID X, 72 per page" in its href). When FA serves
// a full page but omits that link, the iterator synthesizes the next
// cursor from the oldest submission on the page so a large inbox is not
// truncated to its first page. Iteration stops once a page yields no new
// submissions, or returns fewer than a full page with no cursor link.
//
// Use [ListOptions.MaxPages] to bound the crawl; the inbox can hold
// hundreds of pending items if you watch many active artists.
//
// ListOptions.StartPage is ignored the inbox is cursor-paginated by
// FA (the "Next 72" link encodes a from-id), not page-numbered, so there
// is nothing meaningful to start from.
func (c *Client) SubmissionInbox(ctx context.Context, opts ListOptions) iter.Seq2[*Submission, error] {
return func(yield func(*Submission, error) bool) {
nextURL := urls.MsgSubmissions()
pagesFetched := 0
visited := make(map[string]bool)
seen := make(map[SubmissionID]bool)
for nextURL != "" {
if opts.reachedLimit(pagesFetched) {
return
}
// Loop guard: FA (or a synthesized cursor) can point back at a
// page already crawled; stop rather than spin forever.
if visited[nextURL] {
return
}
visited[nextURL] = true
var (
items []*Submission
next string
)
err := c.fetch(ctx, nextURL, func(doc *goquery.Document) error {
items, next = parseSubmissionInboxPage(doc, c.cfg.jsonListings)
return nil
})
if err != nil {
yield(nil, err)
return
}
pagesFetched++
newCount := 0
minID := SubmissionID(0)
for _, s := range items {
if minID == 0 || s.ID < minID {
minID = s.ID
}
if seen[s.ID] {
continue
}
seen[s.ID] = true
newCount++
if !yield(s, nil) {
return
}
}
// A page that adds nothing new is the natural end of the crawl.
if newCount == 0 {
return
}
// FA renders a "Next 72" cursor link on every page that has a
// successor but it can omit it even when the inbox holds more.
// When the page came back full, trust the item count over the
// missing link and synthesize the cursor from the oldest id.
if next == "" {
if len(items) >= urls.InboxPageSize && minID > 0 {
next = urls.MsgSubmissionsCursor(int64(minID))
} else {
return
}
}
nextURL = next
}
}
}
// parseSubmissionInboxPage walks /msg/submissions/ (or one of its cursor-
// paginated variants), returning each yielded submission and the absolute
// URL of the "Next 72" cursor page, or "" if there's no further page.
//
// Inbox items are grouped under <div class="notifications-by-date"
// data-date="UNIXTIME"> wrappers; the parser lifts the group timestamp
// onto each contained submission's PostedAt so callers don't have to
// re-derive it.
//
// useJSON controls the experimental JSON-first merge see parseGalleryPage.
func parseSubmissionInboxPage(doc *goquery.Document, useJSON bool) (items []*Submission, nextURL string) {
var jsonData listingJSONMap
if useJSON {
jsonData = readListingJSON(doc)
}
doc.Find("#messagecenter-submissions div.notifications-by-date").Each(func(_ int, group *goquery.Selection) {
groupTime := groupDate(group)
group.Find("figure[id^=sid-]").Each(func(_ int, sel *goquery.Selection) {
s := parseGalleryFigure(sel, jsonData)
if s == nil {
return
}
if s.PostedAt.IsZero() && !groupTime.IsZero() {
s.PostedAt = groupTime
}
items = append(items, s)
})
})
if len(items) == 0 {
doc.Find("#messagecenter-submissions figure[id^=sid-]").Each(func(_ int, sel *goquery.Selection) {
if s := parseGalleryFigure(sel, jsonData); s != nil {
items = append(items, s)
}
})
}
// Last resort: a cursor page may drop the #messagecenter-submissions
// wrapper entirely. /msg/submissions/ carries no figures other than the
// inbox gallery, so a document-wide sweep is safe here.
if len(items) == 0 {
doc.Find("figure[id^=sid-]").Each(func(_ int, sel *goquery.Selection) {
if s := parseGalleryFigure(sel, jsonData); s != nil {
items = append(items, s)
}
})
}
if next := doc.Find("div.messagecenter-navigation a.button.more").First(); next.Length() > 0 {
href, _ := next.Attr("href")
nextURL = urls.AbsoluteCDN(href)
}
return items, nextURL
}
// groupDate reads the unix timestamp from a notifications-by-date wrapper's
// data-date attribute. Returns zero time when missing/unparseable.
func groupDate(group *goquery.Selection) time.Time {
v := strings.TrimSpace(trimAttr(group, "data-date"))
if v == "" {
return time.Time{}
}
secs, err := strconv.ParseInt(v, 10, 64)
if err != nil || secs <= 0 {
return time.Time{}
}
return time.Unix(secs, 0).UTC()
}

32
inbox_test.go Normal file
View File

@@ -0,0 +1,32 @@
package fa
import (
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
// A cursor page (/msg/submissions/new~{id}@72/) may render its gallery
// without the #messagecenter-submissions wrapper the default view uses.
// parseSubmissionInboxPage must still recover the submissions instead of
// silently returning an empty page, which would truncate the crawl.
func TestParseSubmissionInboxPage_FindsFiguresOutsideMessageCenter(t *testing.T) {
html := `<html><body>
<section class="gallery">
<figure id="sid-501"><a href="/view/501/" title="A"><img src="/a.png"/></a></figure>
<figure id="sid-500"><a href="/view/500/" title="B"><img src="/b.png"/></a></figure>
</section>
</body></html>`
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
t.Fatal(err)
}
items, _ := parseSubmissionInboxPage(doc, false)
if len(items) != 2 {
t.Fatalf("got %d items; want 2", len(items))
}
if items[0].ID != 501 || items[1].ID != 500 {
t.Errorf("ids = %d, %d; want 501, 500", items[0].ID, items[1].ID)
}
}

147
internal/urls/routes.go Normal file
View File

@@ -0,0 +1,147 @@
// Package urls is the single source of truth for every FA URL the SDK
// constructs. Centralising route building here keeps fragile path
// concatenation out of the public API and makes the site's URL scheme
// trivial to swap (e.g., were FA to move endpoints).
package urls
import (
"fmt"
"net/url"
"strconv"
"strings"
)
// Host is the canonical FA host. Exported so callers can override for
// proxies or local mirrors, but the production value is what every
// builder below uses.
const Host = "https://www.furaffinity.net"
// Submission returns the canonical URL for viewing a submission.
func Submission(id int64) string {
return Host + "/view/" + strconv.FormatInt(id, 10) + "/"
}
// User returns the URL for a user's profile page.
func User(name string) string {
return Host + "/user/" + safeName(name) + "/"
}
// Gallery returns the URL for a user's main gallery page.
func Gallery(name string, page int) string {
return Host + "/gallery/" + safeName(name) + "/" + pageSegment(page)
}
// Scraps returns the URL for a user's scraps page.
func Scraps(name string, page int) string {
return Host + "/scraps/" + safeName(name) + "/" + pageSegment(page)
}
// Favorites returns the URL for a user's favorites page. FA uses a numeric
// page parameter; the first page is 1.
func Favorites(name string, page int) string {
return Host + "/favorites/" + safeName(name) + "/" + pageSegment(page)
}
// Journal returns the URL for a single journal entry.
func Journal(id int64) string {
return Host + "/journal/" + strconv.FormatInt(id, 10) + "/"
}
// UserJournals returns the URL for a user's journals listing.
func UserJournals(name string, page int) string {
return Host + "/journals/" + safeName(name) + "/" + pageSegment(page)
}
// MsgSubmissions returns the URL for the new-submission inbox. Requires auth.
func MsgSubmissions() string {
return Host + "/msg/submissions/"
}
// InboxPageSize is FA's fixed page size for the submission inbox its
// pagination links and "Next N" label are always built around 72 items.
const InboxPageSize = 72
// MsgSubmissionsCursor returns the URL for the new-submission inbox page
// that begins just below submission id FA's "new~{id}@72" cursor scheme.
// Used to keep crawling when FA omits the rendered "Next 72" link.
func MsgSubmissionsCursor(id int64) string {
return Host + "/msg/submissions/new~" +
strconv.FormatInt(id, 10) + "@" +
strconv.Itoa(InboxPageSize) + "/"
}
// MsgOthers returns the URL for the watch/journal/comment/fav notifications
// page. Requires auth.
func MsgOthers() string {
return Host + "/msg/others/"
}
// MsgPMs returns the URL for the private-message inbox. Requires auth.
func MsgPMs() string {
return Host + "/msg/pms/"
}
// ViewMessage returns the URL for a single private message (note) by ID.
// Requires auth.
func ViewMessage(id int64) string {
return Host + "/viewmessage/" + strconv.FormatInt(id, 10) + "/"
}
// Search returns the URL for a keyword search. FA accepts the query string
// directly; pagination is a query param rather than a path segment.
func Search(query string, page int) string {
u := Host + "/search/"
q := url.Values{}
q.Set("q", query)
if page > 1 {
q.Set("page", strconv.Itoa(page))
}
if e := q.Encode(); e != "" {
u += "?" + e
}
return u
}
// Browse returns the URL for /browse/ with optional page index. FA's
// browse UI navigates via POST forms, but a GET with ?page=N is honoured
// for the rendered results page, which is all this SDK needs.
func Browse(page int) string {
if page <= 1 {
return Host + "/browse/"
}
return Host + "/browse/?page=" + strconv.Itoa(page)
}
// safeName lower-cases and URL-escapes a username segment. FA folds names
// to lowercase for URL routing.
func safeName(name string) string {
return url.PathEscape(strings.ToLower(strings.TrimSpace(name)))
}
// pageSegment renders a 1-based page index as a trailing path segment.
// Returns the empty string for page <= 1 so the first page URL matches the
// canonical form FA emits in its own "next page" links.
func pageSegment(page int) string {
if page <= 1 {
return ""
}
return strconv.Itoa(page) + "/"
}
// AbsoluteCDN turns an //d.furaffinity.net/... or /art/... reference into a
// fully qualified https URL. Returns s unchanged if it already has a scheme.
func AbsoluteCDN(s string) string {
s = strings.TrimSpace(s)
switch {
case s == "":
return ""
case strings.HasPrefix(s, "http://"), strings.HasPrefix(s, "https://"):
return s
case strings.HasPrefix(s, "//"):
return "https:" + s
case strings.HasPrefix(s, "/"):
return Host + s
default:
return fmt.Sprintf("%s/%s", Host, s)
}
}

View File

@@ -0,0 +1,65 @@
package urls
import "testing"
func TestSubmission(t *testing.T) {
got := Submission(42)
want := "https://www.furaffinity.net/view/42/"
if got != want {
t.Errorf("Submission(42) = %q; want %q", got, want)
}
}
func TestUser_LowercasesAndEscapes(t *testing.T) {
tests := []struct {
name, in, want string
}{
{"plain", "SomeUser", "https://www.furaffinity.net/user/someuser/"},
{"trim", " Mixed ", "https://www.furaffinity.net/user/mixed/"},
{"unicode safe", "über", "https://www.furaffinity.net/user/%C3%BCber/"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := User(tc.in); got != tc.want {
t.Errorf("User(%q) = %q; want %q", tc.in, got, tc.want)
}
})
}
}
func TestGallery_PageSegments(t *testing.T) {
cases := map[int]string{
1: "https://www.furaffinity.net/gallery/me/",
2: "https://www.furaffinity.net/gallery/me/2/",
10: "https://www.furaffinity.net/gallery/me/10/",
}
for page, want := range cases {
if got := Gallery("me", page); got != want {
t.Errorf("Gallery(me, %d) = %q; want %q", page, got, want)
}
}
}
func TestMsgSubmissionsCursor(t *testing.T) {
got := MsgSubmissionsCursor(65032289)
want := "https://www.furaffinity.net/msg/submissions/new~65032289@72/"
if got != want {
t.Errorf("MsgSubmissionsCursor(65032289) = %q; want %q", got, want)
}
}
func TestAbsoluteCDN(t *testing.T) {
cases := map[string]string{
"": "",
"https://d.example/x.png": "https://d.example/x.png",
"http://d.example/x.png": "http://d.example/x.png",
"//d.furaffinity.net/art/x.png": "https://d.furaffinity.net/art/x.png",
"/view/1/": "https://www.furaffinity.net/view/1/",
"art/foo": "https://www.furaffinity.net/art/foo",
}
for in, want := range cases {
if got := AbsoluteCDN(in); got != want {
t.Errorf("AbsoluteCDN(%q) = %q; want %q", in, got, want)
}
}
}

80
journal.go Normal file
View File

@@ -0,0 +1,80 @@
package fa
import (
"context"
"fmt"
"iter"
"time"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// Journal is a single journal entry, as seen on /journal/{id}/.
type Journal struct {
ID JournalID
Title string
Author UserRef
PostedAt time.Time
BodyHTML string
BodyText string
}
// GetJournal fetches a journal entry by its numeric ID.
func (c *Client) GetJournal(ctx context.Context, id JournalID) (*Journal, error) {
if id <= 0 {
return nil, fmt.Errorf("fa: GetJournal: id must be > 0")
}
var out *Journal
err := c.fetch(ctx, urls.Journal(int64(id)), func(doc *goquery.Document) error {
j, err := parseJournal(id, doc)
if err != nil {
return err
}
out = j
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// UserJournals iterates a user's journals across pages. The iterator yields
// each [Journal] preview (full body included on FA's listing page).
//
// Use [ListOptions.MaxPages] to bound the crawl.
func (c *Client) UserJournals(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Journal, error] {
return func(yield func(*Journal, error) bool) {
page := opts.firstPage()
pagesFetched := 0
for {
if opts.reachedLimit(pagesFetched) {
return
}
var (
items []*Journal
hasNext bool
)
err := c.fetch(ctx, urls.UserJournals(name, page), func(doc *goquery.Document) error {
items, hasNext = parseUserJournalsPage(doc)
return nil
})
if err != nil {
yield(nil, err)
return
}
pagesFetched++
for _, j := range items {
if !yield(j, nil) {
return
}
}
if !hasNext {
return
}
page++
}
}
}

96
journal_parser.go Normal file
View File

@@ -0,0 +1,96 @@
package fa
import (
"fmt"
"strings"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// parseJournal lifts a [Journal] from a /journal/{id}/ document. FA renders
// the journal view inside the author's profile shell, so the author is
// derived from the userpage nav rather than from any byline in the journal
// body itself.
func parseJournal(id JournalID, doc *goquery.Document) (*Journal, error) {
j := &Journal{ID: id}
// Title.
j.Title = trimText(doc.Find("#c-journalTitleTop__subject h3").First())
if j.Title == "" {
j.Title = firstNonEmpty(
trimText(doc.Find("h2.journal-title").First()),
trimText(doc.Find("h3.journal-title").First()),
)
}
if j.Title == "" {
return nil, fmt.Errorf("%w: journal %d: missing title", ErrParse, id)
}
// Author from the userpage nav at the top of the rendered page.
authorLink := doc.Find("a.c-usernameBlock__displayName[href^='/user/']").First()
if authorLink.Length() > 0 {
href, _ := authorLink.Attr("href")
j.Author = UserRef{
DisplayName: trimText(authorLink.Find("span.js-displayName").First()),
AvatarURL: urls.AbsoluteCDN(trimAttr(doc.Find("img.user-nav-avatar").First(), "src")),
}
if j.Author.DisplayName == "" {
j.Author.DisplayName = trimText(authorLink)
}
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
j.Author.Name = strings.ToLower(parts[1])
}
}
// Date from the journal title block.
j.PostedAt = parsePopupDate(doc.Find("#c-journalTitleTop__date span.popup_date").First())
if j.PostedAt.IsZero() {
j.PostedAt = parsePopupDate(doc.Find("span.popup_date").First())
}
// Body.
body := firstNonEmptySel(doc,
"div.section-body.journal-body-theme div.journal-content",
"div.journal-content",
"div.journal-body",
"section.journal-body",
)
if body != nil {
j.BodyHTML = htmlOf(body)
j.BodyText = strings.TrimSpace(body.Text())
}
return j, nil
}
// parseUserJournalsPage parses a /journals/{user}/[page]/ listing page,
// returning the journal entries it contains and whether a next page exists.
//
// FA renders each entry inside the listing differently from the standalone
// journal page; selectors here target the listing's tile structure.
func parseUserJournalsPage(doc *goquery.Document) (entries []*Journal, hasNext bool) {
doc.Find("section.journal, section[id^=jid], div.journal[id^=jid]").Each(func(_ int, sel *goquery.Selection) {
j := &Journal{}
idAttr, _ := sel.Attr("id")
idAttr = strings.TrimPrefix(idAttr, "jid:")
idAttr = strings.TrimPrefix(idAttr, "journal-")
if n, err := parseID[JournalID](strings.TrimSpace(idAttr)); err == nil {
j.ID = n
}
j.Title = firstNonEmpty(
trimText(sel.Find("h2 a, h3 a").First()),
trimText(sel.Find("h2, h3").First()),
)
j.PostedAt = parsePopupDate(sel.Find("span.popup_date").First())
body := sel.Find("div.journal-body, div.journal-content").First()
j.BodyHTML = htmlOf(body)
j.BodyText = strings.TrimSpace(body.Text())
if j.ID != 0 || j.Title != "" {
entries = append(entries, j)
}
})
hasNext = detectNextPage(doc)
return entries, hasNext
}

103
journal_parser_test.go Normal file
View File

@@ -0,0 +1,103 @@
package fa
import (
"bytes"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
const syntheticJournalHTML = `<html><body>
<a class="c-usernameBlock__displayName js-displayName-block" href="/user/jjwriter/">
<span class="js-displayName">JJWriter</span>
</a>
<img class="user-nav-avatar" src="//d.example/avatars/jjwriter.png"/>
<div id="c-journalTitleTop">
<span id="c-journalTitleTop__subject"><h3>My Journal Entry</h3></span>
</div>
<div id="c-journalTitleBottom">
<span id="c-journalTitleTop__date"><span class="popup_date" data-time="1743851460" title="April 5, 2025 11:11:00 AM">Apr 5</span></span>
</div>
<div class="section-body journal-body-theme">
<div class="journal-content user-submitted-links"><p>Some <i>thoughts</i>.</p></div>
</div>
</body></html>`
func TestParseJournal_Synthetic(t *testing.T) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticJournalHTML))
if err != nil {
t.Fatalf("setup: %v", err)
}
j, err := parseJournal(42, doc)
if err != nil {
t.Fatalf("parseJournal: %v", err)
}
if j.ID != 42 {
t.Errorf("ID = %d; want 42", j.ID)
}
if j.Title != "My Journal Entry" {
t.Errorf("Title = %q", j.Title)
}
if j.Author.Name != "jjwriter" {
t.Errorf("Author.Name = %q", j.Author.Name)
}
if j.Author.DisplayName != "JJWriter" {
t.Errorf("Author.DisplayName = %q", j.Author.DisplayName)
}
if j.PostedAt.Year() != 2025 {
t.Errorf("PostedAt year = %d", j.PostedAt.Year())
}
if !strings.Contains(j.BodyText, "thoughts") {
t.Errorf("BodyText missing: %q", j.BodyText)
}
}
const syntheticUserJournalsHTML = `<html><body>
<section id="jid:111" class="journal">
<h2><a href="/journal/111/">First Entry</a></h2>
<span class="popup_date" title="Apr 1, 2026 10:00 AM">today</span>
<div class="journal-body">Hello.</div>
</section>
<section id="jid:222" class="journal">
<h2><a href="/journal/222/">Second Entry</a></h2>
<span class="popup_date" title="Mar 30, 2026 09:00 AM">yesterday</span>
<div class="journal-body">World.</div>
</section>
<a class="button standard" href="/journals/me/2/">Next</a>
</body></html>`
func TestParseUserJournalsPage_Synthetic(t *testing.T) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticUserJournalsHTML))
if err != nil {
t.Fatalf("setup: %v", err)
}
entries, hasNext := parseUserJournalsPage(doc)
if len(entries) != 2 {
t.Fatalf("entries = %d; want 2", len(entries))
}
if entries[0].ID != 111 || entries[1].ID != 222 {
t.Errorf("ids = [%d, %d]; want [111, 222]", entries[0].ID, entries[1].ID)
}
if entries[0].Title != "First Entry" {
t.Errorf("title[0] = %q", entries[0].Title)
}
if !hasNext {
t.Error("hasNext = false; want true")
}
}
func TestParseJournal_RealFixture(t *testing.T) {
raw := loadFixture(t, "journal.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
j, err := parseJournal(0, doc)
if err != nil {
t.Fatalf("parseJournal(real): %v", err)
}
if j.Title == "" {
t.Error("real fixture: Title is empty")
}
}

68
listing_json.go Normal file
View File

@@ -0,0 +1,68 @@
package fa
import (
"encoding/json"
"strings"
"github.com/PuerkitoBio/goquery"
)
// listingJSONEntry is one row of FA's embedded js-submissionData blob —
// a JSON dictionary every listing page emits with stable metadata for
// each visible submission. The blob is keyed by submission ID (string)
// and survives most HTML reshuffles because it's consumed by FA's own
// front-end JS.
//
// Not every field appears on every page; description is sometimes truncated
// to ~500 chars with a trailing "…". We use this purely as a hinting
// source the HTML figure scrape remains authoritative for fields the
// JSON doesn't carry (ID, rating, thumbnail).
type listingJSONEntry struct {
Title string `json:"title"`
Description string `json:"description"` // BBCode source (may be truncated)
Username string `json:"username"` // display name with original casing
Lower string `json:"lower"` // URL-safe lowercase login
AvatarMtime string `json:"avatar_mtime"`
}
// listingJSONMap is the parsed js-submissionData blob: SubmissionID → entry.
type listingJSONMap map[SubmissionID]listingJSONEntry
// readListingJSON pulls and parses the <script id="js-submissionData">
// blob from doc. Returns nil if the tag is absent or the JSON is
// malformed the caller then falls back to HTML-only scraping. The
// JSON is keyed by string in the source; we deserialize directly into a
// SubmissionID map by exploiting the typed int unmarshal path.
func readListingJSON(doc *goquery.Document) listingJSONMap {
raw := strings.TrimSpace(doc.Find("script#js-submissionData").First().Text())
if raw == "" {
return nil
}
// Decode into string-keyed map first because Go's json package only
// accepts string-convertible keys from JSON object keys.
var stringKeyed map[string]listingJSONEntry
if err := json.Unmarshal([]byte(raw), &stringKeyed); err != nil {
return nil
}
out := make(listingJSONMap, len(stringKeyed))
for k, v := range stringKeyed {
id, err := parseID[SubmissionID](k)
if err != nil || id == 0 {
continue
}
out[id] = v
}
if len(out) == 0 {
return nil
}
return out
}
// avatarURLFromMtime builds FA's avatar CDN URL from the mtime + lowercase
// login. Returns "" when either piece is missing.
func avatarURLFromMtime(lower, mtime string) string {
if lower == "" || mtime == "" {
return ""
}
return "https://a.furaffinity.net/" + mtime + "/" + lower + ".gif"
}

151
listing_json_test.go Normal file
View File

@@ -0,0 +1,151 @@
package fa
import (
"bytes"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
// TestReadListingJSON_RealFixture parses the embedded JSON blob out of the
// captured gallery_page1.html and asserts the shape we expect: keyed by
// SubmissionID, populated title/username/lower fields.
func TestReadListingJSON_RealFixture(t *testing.T) {
raw := loadFixture(t, "gallery_page1.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
m := readListingJSON(doc)
if m == nil {
t.Fatal("readListingJSON returned nil script tag missing or malformed?")
}
if len(m) == 0 {
t.Fatal("readListingJSON returned empty map")
}
t.Logf("parsed %d entries", len(m))
missingTitle, missingLower, missingMtime := 0, 0, 0
for id, entry := range m {
if id == 0 {
t.Errorf("entry has zero ID")
}
if entry.Title == "" {
missingTitle++
}
if entry.Lower == "" {
missingLower++
}
if entry.AvatarMtime == "" {
missingMtime++
}
}
// Allow some entries to lack avatar_mtime (FA sometimes omits it for
// stale avatars), but title/lower should be on every entry.
if missingTitle > 0 {
t.Errorf("%d/%d entries had empty Title", missingTitle, len(m))
}
if missingLower > 0 {
t.Errorf("%d/%d entries had empty Lower", missingLower, len(m))
}
t.Logf("missing avatar_mtime: %d/%d (acceptable)", missingMtime, len(m))
}
// TestParseGalleryPage_JSONMergePicksUpAvatars compares HTML-only and JSON
// merge modes on a favorites fixture (favorites lists submissions by many
// different artists, so the embedded JSON carries avatar_mtime for each;
// gallery pages don't, since every item is by the same artist).
//
// The JSON path should populate Author.AvatarURL on items where HTML
// scraping leaves it empty.
func TestParseGalleryPage_JSONMergePicksUpAvatars(t *testing.T) {
raw := loadFixture(t, "favorites_page1.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
htmlOnly, _ := parseGalleryPage(doc, false)
jsonMerged, _ := parseGalleryPage(doc, true)
if len(htmlOnly) != len(jsonMerged) {
t.Fatalf("item counts differ: html=%d json=%d", len(htmlOnly), len(jsonMerged))
}
if len(jsonMerged) == 0 {
t.Fatal("no items parsed")
}
htmlAvatars, jsonAvatars := 0, 0
for _, s := range htmlOnly {
if s.Author.AvatarURL != "" {
htmlAvatars++
}
}
for _, s := range jsonMerged {
if s.Author.AvatarURL != "" {
jsonAvatars++
}
}
t.Logf("html-only avatars: %d/%d; json-merged avatars: %d/%d",
htmlAvatars, len(htmlOnly), jsonAvatars, len(jsonMerged))
if jsonAvatars <= htmlAvatars {
t.Errorf("expected JSON-merged parse to populate more AvatarURLs than HTML-only; got html=%d json=%d",
htmlAvatars, jsonAvatars)
}
// Spot-check one avatar URL shape.
for _, s := range jsonMerged {
if s.Author.AvatarURL != "" {
if !strings.HasPrefix(s.Author.AvatarURL, "https://a.furaffinity.net/") {
t.Errorf("avatar URL has wrong prefix: %q", s.Author.AvatarURL)
}
if !strings.HasSuffix(s.Author.AvatarURL, "/"+s.Author.Name+".gif") {
t.Errorf("avatar URL doesn't end with /%s.gif: %q", s.Author.Name, s.Author.AvatarURL)
}
break
}
}
}
// TestParseGalleryPage_JSONMergeGracefullyFallsBack confirms that
// useJSON=true on a doc with no js-submissionData blob produces the same
// result as useJSON=false i.e. the merge is non-destructive.
func TestParseGalleryPage_JSONMergeGracefullyFallsBack(t *testing.T) {
// The synthetic gallery fixture has no js-submissionData tag.
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticGalleryHTML))
if err != nil {
t.Fatalf("setup: %v", err)
}
htmlOnly, _ := parseGalleryPage(doc, false)
jsonMerged, _ := parseGalleryPage(doc, true)
if len(htmlOnly) != len(jsonMerged) {
t.Fatalf("counts differ: %d vs %d", len(htmlOnly), len(jsonMerged))
}
for i := range htmlOnly {
if htmlOnly[i].ID != jsonMerged[i].ID || htmlOnly[i].Title != jsonMerged[i].Title {
t.Errorf("item %d: html=%+v json=%+v", i, htmlOnly[i], jsonMerged[i])
}
}
}
// TestReadListingJSON_MissingTagReturnsNil makes the fallback path
// explicit in a unit-style assertion.
func TestReadListingJSON_MissingTagReturnsNil(t *testing.T) {
doc, _ := goquery.NewDocumentFromReader(strings.NewReader("<html><body></body></html>"))
if m := readListingJSON(doc); m != nil {
t.Errorf("expected nil, got %d entries", len(m))
}
}
// TestReadListingJSON_MalformedJSONReturnsNil makes the fallback path
// explicit when FA's blob is present but somehow unparseable.
func TestReadListingJSON_MalformedJSONReturnsNil(t *testing.T) {
doc, _ := goquery.NewDocumentFromReader(strings.NewReader(
`<html><body><script id="js-submissionData" type="application/json">{not valid</script></body></html>`,
))
if m := readListingJSON(doc); m != nil {
t.Errorf("expected nil, got %d entries", len(m))
}
}

72
models.go Normal file
View File

@@ -0,0 +1,72 @@
package fa
import "time"
// UserRef is a lightweight reference to a user that appears next to other
// entities (a submission's author, a comment's author, etc.). Use [Client.GetUser]
// to load the full [User].
type UserRef struct {
// Name is the URL-safe login name FA uses for routing. Always lowercase.
Name string
// DisplayName preserves the user's chosen capitalisation and styling.
DisplayName string
// AvatarURL points to the user's avatar image on the CDN.
AvatarURL string
}
// SubmissionRef is a thin reference to another submission, used for things
// like a user's featured submission preview where we only want a handful of
// fields without paying for a full submission fetch.
type SubmissionRef struct {
ID SubmissionID
Title string
ThumbURL string
}
// FolderRef identifies a gallery folder a submission appears in.
type FolderRef struct {
Name string
URL string
}
// SubmissionStats are the headline counts shown in the submission sidebar.
type SubmissionStats struct {
Views int
Favorites int
Comments int
}
// UserStats are the per-user counts shown on the profile page.
type UserStats struct {
Submissions int
Favorites int
Views int
Comments int
Journals int
Watchers int
Watching int
}
// UserContact is one row of the "Contact Information" table on a profile,
// e.g. {Site: "Twitter", Handle: "@someone", URL: "https://twitter.com/someone"}.
type UserContact struct {
Site string
Handle string
URL string
}
// Shout is one entry from the shouts/guestbook on a user's profile.
type Shout struct {
Author UserRef
PostedAt time.Time
BodyHTML string
}
// SiteBanner is the image rendered in the page header of a user's profile.
// FA always shows one: either the artist's own banner uploaded via
// /controls/profilebanner/, or if none is set FA's site-wide promo
// banner. IsCustom distinguishes the two.
type SiteBanner struct {
ImageURL string // absolute CDN URL of the banner image
IsCustom bool // true when the artist set their own banner
}

107
notes.go Normal file
View File

@@ -0,0 +1,107 @@
package fa
import (
"context"
"fmt"
"iter"
"time"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// NoteID identifies a private message (note) thread on FA.
type NoteID int64
// NotePreview is a row from the notes inbox listing at /msg/pms/. It carries
// just enough to identify the thread and decide whether to fetch the full
// note via [Client.GetNote].
type NotePreview struct {
ID NoteID
Subject string
Sender UserRef // from-user as it appears in the inbox list
SentAt time.Time
Unread bool
ThreadURL string // the /msg/pms/1/{id}/#message link FA renders
}
// Note is a single private message thread, as rendered at /viewmessage/{id}/.
// Subject and Body are the headline note; FA shows quoted prior replies
// inline within Body rather than as a separate thread, so callers wanting
// to programmatically split a conversation will need to walk Body's HTML.
type Note struct {
ID NoteID
Subject string
From UserRef
To UserRef
SentAt time.Time
BodyHTML string
BodyText string
}
// Notes iterates the private-message inbox at /msg/pms/. Each yielded
// [*NotePreview] is one thread; call [Client.GetNote] with its ID to load
// the body.
//
// FA paginates /msg/pms/ with a similar messagecenter-navigation control
// to the submission inbox.
//
// ListOptions.StartPage is ignored the inbox uses cursor pagination
// (follow-the-Next-link), not page-numbered fetches.
//
// Requires a logged-in client.
func (c *Client) Notes(ctx context.Context, opts ListOptions) iter.Seq2[*NotePreview, error] {
return func(yield func(*NotePreview, error) bool) {
nextURL := urls.MsgPMs()
pagesFetched := 0
for nextURL != "" {
if opts.reachedLimit(pagesFetched) {
return
}
var (
items []*NotePreview
next string
)
err := c.fetch(ctx, nextURL, func(doc *goquery.Document) error {
items, next = parseNotesInboxPage(doc)
return nil
})
if err != nil {
yield(nil, err)
return
}
pagesFetched++
for _, it := range items {
if !yield(it, nil) {
return
}
}
if next == "" || len(items) == 0 {
return
}
nextURL = next
}
}
}
// GetNote fetches a single note (private message) by ID. Requires a
// logged-in client.
func (c *Client) GetNote(ctx context.Context, id NoteID) (*Note, error) {
if id <= 0 {
return nil, fmt.Errorf("fa: GetNote: id must be > 0")
}
var out *Note
err := c.fetch(ctx, urls.ViewMessage(int64(id)), func(doc *goquery.Document) error {
n, err := parseNote(id, doc)
if err != nil {
return err
}
out = n
return nil
})
if err != nil {
return nil, err
}
return out, nil
}

133
notes_parser.go Normal file
View File

@@ -0,0 +1,133 @@
package fa
import (
"fmt"
"strings"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// parseNotesInboxPage parses one page of the /msg/pms/ inbox listing,
// returning the previews and the absolute URL of the next page (or "" if
// there's no further page).
//
// Beta theme renders each thread as a <div class="c-noteListItem"> with:
// - subject link <a class="notelink"> whose href encodes the note id
// (/msg/pms/{folder}/{noteID}/#message)
// - sender block <div class="note-list-sender"> with a c-usernameBlock
// - send date <div class="note-list-senddate"> with a popup_date span
// - read-state class "note-read" / "note-unread" on the subject link
func parseNotesInboxPage(doc *goquery.Document) (items []*NotePreview, nextURL string) {
doc.Find("div#notes-list div.c-noteListItem").Each(func(_ int, item *goquery.Selection) {
np := parseNoteListItem(item)
if np != nil {
items = append(items, np)
}
})
// Pagination control on the message center.
if next := doc.Find("div.messagecenter-navigation a.button.more").First(); next.Length() > 0 {
href, _ := next.Attr("href")
nextURL = urls.AbsoluteCDN(href)
}
return items, nextURL
}
// parseNoteListItem lifts one <div class="c-noteListItem"> row.
func parseNoteListItem(item *goquery.Selection) *NotePreview {
subjectLink := item.Find("a.notelink").First()
if subjectLink.Length() == 0 {
return nil
}
href, _ := subjectLink.Attr("href")
np := &NotePreview{
Subject: trimText(subjectLink.Find(".c-noteListItem__subject").First()),
ThreadURL: urls.AbsoluteCDN(href),
}
if np.Subject == "" {
np.Subject = trimText(subjectLink)
}
// Note ID lives in the href: /msg/pms/{folder}/{id}/#message. Strip the
// fragment first so extractIntFromHref picks the trailing numeric path.
if i := strings.Index(href, "#"); i != -1 {
href = href[:i]
}
np.ID = NoteID(extractIntFromHref(href))
// Read/unread: classes on the subject link.
if class, _ := subjectLink.Attr("class"); strings.Contains(class, "note-unread") || strings.Contains(class, "unread") && !strings.Contains(class, "note-read") {
np.Unread = true
}
senderBox := item.Find("div.note-list-sender")
np.Sender = userRefFromUsernameBlock(senderBox)
// FA marks notes from removed accounts with <span class="user-name-deleted">.
// In that case there is no usernameBlock and Name stays empty by design;
// surface the visible "[deleted]" string in DisplayName so callers can
// distinguish "no sender info" from "sender's account is gone".
if np.Sender.Name == "" && np.Sender.DisplayName == "" {
if deleted := senderBox.Find("span.user-name-deleted").First(); deleted.Length() > 0 {
np.Sender = UserRef{DisplayName: trimText(deleted)}
}
}
np.SentAt = parsePopupDate(item.Find("div.note-list-senddate span.popup_date").First())
return np
}
// parseNote lifts a single private-message thread from /viewmessage/{id}/.
//
// FA renders the note inside a <section> whose section-header contains the
// avatar, subject (<h2>), sender block, sent date, and recipient block.
// The body lives in the following <div class="section-body"> wrapped in a
// .user-submitted-links div.
func parseNote(id NoteID, doc *goquery.Document) (*Note, error) {
n := &Note{ID: id}
header := doc.Find("div.message-center-note-information.addresses").First()
if header.Length() == 0 {
// Older / alternative layout: try the parent block.
header = doc.Find("div.message-center-note-information").First()
}
n.Subject = trimText(header.Find("h2").First())
if n.Subject == "" {
return nil, fmt.Errorf("%w: note %d: missing subject", ErrParse, id)
}
// Sender + recipient: the first and second c-usernameBlock inside the
// header, in document order (FA writes "Sent by … To …" sequentially).
blocks := header.Find("div.c-usernameBlock")
if blocks.Length() >= 1 {
n.From = userRefFromUsernameBlock(blocks.Eq(0))
}
if blocks.Length() >= 2 {
n.To = userRefFromUsernameBlock(blocks.Eq(1))
}
// Avatar lives in a sibling div within the surrounding container.
avatarSrc := trimAttr(doc.Find("div.message-center-note-information.avatar img.avatar").First(), "src")
if avatarSrc == "" {
avatarSrc = trimAttr(doc.Find("div.message-center-note-information img").First(), "src")
}
if avatarSrc != "" && n.From.AvatarURL == "" {
n.From.AvatarURL = urls.AbsoluteCDN(avatarSrc)
}
n.SentAt = parsePopupDate(header.Find("span.popup_date").First())
// Body. FA wraps it in section .section-body > .user-submitted-links and
// occasionally prepends a scam-warning div which we strip from the
// plaintext convenience field but leave intact in the raw HTML.
body := doc.Find("section div.section-body div.user-submitted-links").First()
if body.Length() == 0 {
body = doc.Find("section div.section-body").First()
}
n.BodyHTML = htmlOf(body)
bodyTextSel := body.Clone()
bodyTextSel.Find(".noteWarningMessage").Remove()
n.BodyText = strings.TrimSpace(bodyTextSel.Text())
return n, nil
}

73
notes_send.go Normal file
View File

@@ -0,0 +1,73 @@
package fa
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// SendNote sends a new private message (note) to a user. Requires login.
//
// FA's note form posts to /msg/send/ with a per-form CSRF key that must
// be scraped from the notes inbox (or any page that renders the send
// form). This method does the scrape + post in one call.
//
// Returns nil on success. ErrUnauthorized when not logged in.
// *SystemMessageError when FA rejects the send (recipient blocked, rate
// limited, etc.).
func (c *Client) SendNote(ctx context.Context, to string, subject string, body string) error {
to = strings.TrimSpace(to)
if to == "" {
return fmt.Errorf("fa: SendNote: empty recipient")
}
if subject == "" {
return fmt.Errorf("fa: SendNote: empty subject")
}
if body == "" {
return fmt.Errorf("fa: SendNote: empty body")
}
// Scrape the form key from /msg/pms/. The notes inbox renders the same
// CSRF key in any note-related form on the page; we don't actually need
// to be replying to anything to harvest it.
var key string
err := c.fetch(ctx, urls.MsgPMs(), func(doc *goquery.Document) error {
key = findNoteKey(doc)
if key == "" {
return fmt.Errorf("%w: SendNote: could not locate form key on /msg/pms/", ErrUnauthorized)
}
return nil
})
if err != nil {
return err
}
v := url.Values{}
v.Set("key", key)
v.Set("to", to)
v.Set("subject", subject)
v.Set("message", body)
v.Set("send", "Send Note")
_, err = c.postForm(ctx, urls.Host+"/msg/send/", v)
return err
}
// findNoteKey looks for a hidden input named "key" inside any form that
// targets the notes subsystem (/msg/send/ or /msg/pms/). FA reuses the
// same key value across these forms within a session.
func findNoteKey(doc *goquery.Document) string {
var key string
doc.Find("form[action='/msg/send/'] input[name='key'], form[action='/msg/pms/'] input[name='key'], form[action^='/msg/'] input[name='key']").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
if v := strings.TrimSpace(trimAttr(sel, "value")); v != "" {
key = v
return false
}
return true
})
return key
}

207
notifications.go Normal file
View File

@@ -0,0 +1,207 @@
package fa
import (
"context"
"time"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// Notifications is the parsed contents of /msg/others/ FA's catch-all
// notification page. Each category surfaces as a separate slice so callers
// don't have to type-switch over a heterogeneous list.
//
// Sections that aren't currently rendered (because you have none of that
// type pending) come back as nil/empty rather than as an error.
type Notifications struct {
Journals []JournalNotification
Watches []WatchNotification
SubmissionComments []CommentNotification
JournalComments []CommentNotification
Favorites []FavNotification
Shouts []ShoutNotification
}
// JournalNotification represents one entry in the "Journals" notification
// section a journal posted by someone you watch.
type JournalNotification struct {
JournalID JournalID
Title string
Author UserRef
Rating Rating
PostedAt time.Time
}
// WatchNotification is a user that started watching you.
type WatchNotification struct {
User UserRef
WatchedAt time.Time
}
// CommentNotification is a comment posted on one of your submissions or
// journals. OnSubmission is non-zero for submission comments;
// OnJournal is non-zero for journal comments. Only one is set per item;
// they're segregated into the two slices on [Notifications] but share this
// type for callers that want to merge.
type CommentNotification struct {
CommentID CommentID
OnSubmission SubmissionID
OnJournal JournalID
OnTitle string // title of the submission/journal that was commented on
Author UserRef
PostedAt time.Time
}
// FavNotification is someone favoriting one of your submissions.
type FavNotification struct {
SubmissionID SubmissionID
SubmissionTitle string
Favoriter UserRef
FavoritedAt time.Time
}
// ShoutNotification is a new shout left on your profile.
type ShoutNotification struct {
ShoutID int64 // FA's internal shout id (matches the anchor id="shout-N" on your profile)
Author UserRef
PostedAt time.Time
}
// NotificationsOption tunes a single [Client.Notifications] call.
type NotificationsOption func(*notificationsConfig)
type notificationsConfig struct {
resolveAvatars bool
avatarLimit int
}
// WithResolvedAvatars makes [Client.Notifications] fill in author avatar
// URLs that FurAffinity omits from the /msg/others/ markup.
//
// FA does not render an avatar image on every notification row journal
// notifications in particular are a purely textual list, so their author
// UserRef comes back with AvatarURL == "". When this option is set, the
// SDK resolves authors by fetching each one's profile page and reading the
// avatar from it.
//
// Each distinct author costs one extra HTTP request, deduplicated within
// the call and serialized through the client's rate limiter. Because that
// limiter is global, total wall time is roughly (distinct authors) ×
// (rate interval) on a busy account, dozens of seconds. limit caps how
// many distinct authors are resolved: pass a small value (e.g. 12) to
// bound a cold load. Authors are resolved in notification order with
// journals first, so a Home "recent journals" feed still gets real
// avatars; any author past the limit keeps AvatarURL == "" (callers
// typically render an initials fallback). A limit <= 0 means unlimited.
//
// Per-author failures are ignored: a user whose page can't be fetched
// simply keeps an empty AvatarURL.
func WithResolvedAvatars(limit int) NotificationsOption {
return func(c *notificationsConfig) {
c.resolveAvatars = true
c.avatarLimit = limit
}
}
// Notifications fetches /msg/others/ and returns the parsed notification
// page. Requires a logged-in client; anonymous calls surface as
// [ErrUnauthorized].
//
// All categories are returned in a single fetch there is no pagination
// on this page. Pass [WithResolvedAvatars] to additionally backfill author
// avatars that FA omits from the page (see that option's docs).
func (c *Client) Notifications(ctx context.Context, opts ...NotificationsOption) (*Notifications, error) {
var cfg notificationsConfig
for _, o := range opts {
o(&cfg)
}
var out *Notifications
err := c.fetch(ctx, urls.MsgOthers(), func(doc *goquery.Document) error {
n, err := parseNotifications(doc)
if err != nil {
return err
}
out = n
return nil
})
if err != nil {
return nil, err
}
if cfg.resolveAvatars {
c.resolveNotificationAvatars(ctx, out, cfg.avatarLimit)
}
return out, nil
}
// resolveNotificationAvatars backfills empty author AvatarURLs on a parsed
// [Notifications] by fetching each distinct author's profile page. Authors
// that already carry an avatar (FA does render one on, e.g., watch rows)
// are left untouched. Lookups are deduplicated by URL-safe name including
// negative results so each author costs at most one request.
//
// limit caps the number of distinct authors actually fetched; a limit <= 0
// is unlimited. Authors are visited journals-first, so when the budget is
// small the Home "recent journals" feed is the part that gets real avatars.
// An already-fetched author (cache hit) is still applied past the limit —
// that costs nothing only *new* fetches are gated.
//
// Failures are deliberately swallowed: a single unreachable profile must
// not fail the whole notifications call, and a stale ctx simply leaves the
// remaining avatars empty.
func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notifications, limit int) {
if n == nil {
return
}
cache := make(map[string]string)
fill := func(ref *UserRef) {
if ref.Name == "" || ref.AvatarURL != "" {
return
}
avatar, ok := cache[ref.Name]
if !ok {
if limit > 0 && len(cache) >= limit {
return // fetch budget exhausted leave AvatarURL empty
}
avatar = c.fetchUserAvatar(ctx, ref.Name)
cache[ref.Name] = avatar
}
ref.AvatarURL = avatar
}
for i := range n.Journals {
fill(&n.Journals[i].Author)
}
for i := range n.Watches {
fill(&n.Watches[i].User)
}
for i := range n.SubmissionComments {
fill(&n.SubmissionComments[i].Author)
}
for i := range n.JournalComments {
fill(&n.JournalComments[i].Author)
}
for i := range n.Favorites {
fill(&n.Favorites[i].Favoriter)
}
for i := range n.Shouts {
fill(&n.Shouts[i].Author)
}
}
// fetchUserAvatar fetches /user/{name}/ and returns just the profile
// owner's avatar URL. It returns "" on any failure callers treat a
// missing avatar as non-fatal.
func (c *Client) fetchUserAvatar(ctx context.Context, name string) string {
var avatar string
_ = c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error {
avatar = urls.AbsoluteCDN(firstNonEmpty(
trimAttr(doc.Find("userpage-nav-avatar img").First(), "src"),
trimAttr(doc.Find("div.userpage-nav-avatar img").First(), "src"),
))
return nil
})
return avatar
}

222
notifications_parser.go Normal file
View File

@@ -0,0 +1,222 @@
package fa
import (
"strings"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// parseNotifications walks /msg/others/ and fills in every category that
// the page renders. Sections FA omits (because you have nothing pending of
// that type) result in nil slices, not errors.
//
// Each section uses the same outer shape `section.section_container#messages-X`
// with a `ul.message-stream > li` body. The per-row content differs, so
// each category has its own per-row helper below.
func parseNotifications(doc *goquery.Document) (*Notifications, error) {
n := &Notifications{}
doc.Find("section.section_container#messages-journals ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
if j := parseJournalNotification(li); j != nil {
n.Journals = append(n.Journals, *j)
}
})
doc.Find("section.section_container#messages-watches ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
if w := parseWatchNotification(li); w != nil {
n.Watches = append(n.Watches, *w)
}
})
doc.Find("section.section_container#messages-comments-submission ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
if c := parseCommentNotification(li, false); c != nil {
n.SubmissionComments = append(n.SubmissionComments, *c)
}
})
doc.Find("section.section_container#messages-comments-journal ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
if c := parseCommentNotification(li, true); c != nil {
n.JournalComments = append(n.JournalComments, *c)
}
})
doc.Find("section.section_container#messages-favorites ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
if f := parseFavNotification(li); f != nil {
n.Favorites = append(n.Favorites, *f)
}
})
doc.Find("section.section_container#messages-shouts ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
if s := parseShoutNotification(li); s != nil {
n.Shouts = append(n.Shouts, *s)
}
})
return n, nil
}
// parseJournalNotification lifts one row from the Journals section. Layout:
//
// <li>
// <div class="table">
// <div class="cell"><input ... value="{journalID}"></div>
// <div class="cell">
// <a href="/journal/{id}/"><em class="journal_subject">Title</em></a>
// (<span class="c-contentRating--general">G</span>)
// posted by <span class="c-usernameBlockSimple"><a href="/user/.../"><span class="c-usernameBlockSimple__displayName" title=" name ">Display</span></a></span>
// <span class="popup_date" data-time="...">timestamp</span>
// </div>
// </div>
// </li>
func parseJournalNotification(li *goquery.Selection) *JournalNotification {
link := li.Find("a[href^='/journal/']").First()
if link.Length() == 0 {
return nil
}
href, _ := link.Attr("href")
id := JournalID(extractIntFromHref(href))
if id == 0 {
return nil
}
j := &JournalNotification{
JournalID: id,
Title: trimText(link.Find("em.journal_subject").First()),
}
if j.Title == "" {
j.Title = trimText(link)
}
// Rating: <span class="c-contentRating--general">G</span>
if r := li.Find("span[class*='c-contentRating--']").First(); r.Length() > 0 {
j.Rating = ratingFromClass(trimAttr(r, "class"))
}
j.Author = userRefFromUsernameBlock(li)
j.PostedAt = parsePopupDate(li.Find("span.popup_date").First())
return j
}
// parseWatchNotification: a user that started watching you. Typical layout
// is a <li> with the user's avatar link, display-name block, and date.
func parseWatchNotification(li *goquery.Selection) *WatchNotification {
w := &WatchNotification{User: userRefFromUsernameBlock(li)}
if w.User.Name == "" {
return nil
}
// Avatar often lives in the same <li> as a separate <a><img/></a>.
if av := li.Find("img").First(); av.Length() > 0 {
w.User.AvatarURL = urls.AbsoluteCDN(trimAttr(av, "src"))
}
w.WatchedAt = parsePopupDate(li.Find("span.popup_date").First())
return w
}
// parseCommentNotification: a comment on one of your submissions or journals.
// isJournal selects which target field to populate. Comment ID is on the
// link's #cid:N fragment or in a data attribute on the row.
func parseCommentNotification(li *goquery.Selection, isJournal bool) *CommentNotification {
// The link goes to /view/{id}/#cid:N or /journal/{id}/#cid:N
link := li.Find("a[href*='/view/'], a[href*='/journal/']").First()
if link.Length() == 0 {
return nil
}
href, _ := link.Attr("href")
c := &CommentNotification{
OnTitle: trimText(link),
Author: userRefFromUsernameBlock(li),
}
// Pull the comment ID out of the URL fragment, e.g. "#cid:12345" or "#comment-12345".
if i := strings.Index(href, "#"); i != -1 {
frag := href[i+1:]
frag = strings.TrimPrefix(frag, "cid:")
frag = strings.TrimPrefix(frag, "comment-")
if n, err := parseID[CommentID](strings.TrimSpace(frag)); err == nil {
c.CommentID = n
}
href = href[:i] // strip fragment for ID parsing below
}
id := extractIntFromHref(href)
if isJournal {
c.OnJournal = JournalID(id)
} else {
c.OnSubmission = SubmissionID(id)
}
c.PostedAt = parsePopupDate(li.Find("span.popup_date").First())
return c
}
// parseFavNotification: someone favorited one of your submissions.
func parseFavNotification(li *goquery.Selection) *FavNotification {
link := li.Find("a[href^='/view/']").First()
if link.Length() == 0 {
return nil
}
href, _ := link.Attr("href")
f := &FavNotification{
SubmissionID: SubmissionID(extractIntFromHref(href)),
SubmissionTitle: trimText(link),
Favoriter: userRefFromUsernameBlock(li),
}
f.FavoritedAt = parsePopupDate(li.Find("span.popup_date").First())
return f
}
// parseShoutNotification: someone left a shout on your profile.
func parseShoutNotification(li *goquery.Selection) *ShoutNotification {
s := &ShoutNotification{Author: userRefFromUsernameBlock(li)}
if s.Author.Name == "" {
return nil
}
// Shout id is sometimes carried on a checkbox value or anchor; not load-
// bearing if absent.
if v := trimAttr(li.Find("input[type=checkbox]").First(), "value"); v != "" {
if n, err := parseID[CommentID](v); err == nil {
s.ShoutID = int64(n)
}
}
s.PostedAt = parsePopupDate(li.Find("span.popup_date").First())
return s
}
// userRefFromUsernameBlock extracts a UserRef from any descendant
// c-usernameBlock / c-usernameBlockSimple structure. Returns the zero
// UserRef when no such block is found, so callers can keep parsing the row.
func userRefFromUsernameBlock(sel *goquery.Selection) UserRef {
display := sel.Find(".c-usernameBlockSimple__displayName, .c-usernameBlock__displayName .js-displayName").First()
if display.Length() == 0 {
display = sel.Find(".c-usernameBlock__displayName").First()
}
if display.Length() == 0 {
return UserRef{}
}
u := UserRef{DisplayName: trimText(display)}
// URL-safe name lives in the surrounding link's href, or in title attr.
if t := strings.TrimSpace(trimAttr(display, "title")); t != "" {
u.Name = strings.ToLower(t)
}
if u.Name == "" {
link := display.ParentsFiltered("a[href^='/user/']").First()
if link.Length() == 0 {
link = sel.Find("a[href^='/user/']").First()
}
href, _ := link.Attr("href")
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
u.Name = strings.ToLower(parts[1])
}
}
return u
}
// ratingFromClass maps a `c-contentRating--general/mature/adult` class
// token to one of the Rating constants. Returns "" when unrecognised so
// callers can leave the field empty rather than guess.
func ratingFromClass(class string) Rating {
low := strings.ToLower(class)
switch {
case strings.Contains(low, "general"):
return RatingGeneral
case strings.Contains(low, "mature"):
return RatingMature
case strings.Contains(low, "adult"):
return RatingAdult
}
return ""
}

196
notifications_test.go Normal file
View File

@@ -0,0 +1,196 @@
package fa
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
)
// journalNotifRow renders one /msg/others/ journal-notification <li>. FA's
// real markup carries no avatar image on these rows that is the whole
// point of issue #14 so this helper deliberately omits one.
func journalNotifRow(journalID int, title, authorName, authorDisplay string) string {
return fmt.Sprintf(`<li>
<div class="table">
<div class="cell"><input type="checkbox" name="journals[]" value="%d"></div>
<div class="cell">
<a href="/journal/%d/"><em class="journal_subject">%s</em></a>
(<span class="c-contentRating--general">G</span>)
posted by
<span class="c-usernameBlockSimple"><a href="/user/%s/"><span class="c-usernameBlockSimple__displayName" title=" %s ">%s</span></a></span>
<span class="popup_date" data-time="1779179727">recently</span>
</div>
</div>
</li>`, journalID, journalID, title, authorName, authorName, authorDisplay)
}
// fakeMsgOthersPage wraps journal rows in the section markup parseNotifications
// expects.
func fakeMsgOthersPage(rows ...string) string {
return `<html><head><title>Notifications</title></head><body>
<section class="section_container" id="messages-journals">
<ul class="message-stream">` + strings.Join(rows, "\n") + `</ul>
</section>
</body></html>`
}
// fakeUserAvatarPage renders the minimal /user/{name}/ markup fetchUserAvatar
// reads just the <userpage-nav-avatar> header element.
func fakeUserAvatarPage(name, avatarTS string) string {
return `<html><head><title>` + name + `</title></head><body>
<userpage-nav-avatar>
<a href="/user/` + name + `/"><img alt="` + name + `" src="//a.furaffinity.net/` + avatarTS + `/` + name + `.gif"/></a>
</userpage-nav-avatar>
</body></html>`
}
func TestNotifications_ResolvesAvatars(t *testing.T) {
var userHits sync.Map // name -> *atomic.Int32
hit := func(name string) int32 {
v, _ := userHits.LoadOrStore(name, &atomic.Int32{})
return v.(*atomic.Int32).Add(1)
}
mux := http.NewServeMux()
mux.HandleFunc("/msg/others/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(fakeMsgOthersPage(
journalNotifRow(101, "First", "authora", "AuthorA"),
journalNotifRow(102, "Second", "authora", "AuthorA"), // same author must dedup
journalNotifRow(103, "Third", "authorb", "AuthorB"),
)))
})
mux.HandleFunc("/user/authora/", func(w http.ResponseWriter, r *http.Request) {
hit("authora")
_, _ = w.Write([]byte(fakeUserAvatarPage("authora", "100")))
})
mux.HandleFunc("/user/authorb/", func(w http.ResponseWriter, r *http.Request) {
hit("authorb")
_, _ = w.Write([]byte(fakeUserAvatarPage("authorb", "200")))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
n, err := client.Notifications(context.Background(), WithResolvedAvatars(0))
if err != nil {
t.Fatalf("Notifications: %v", err)
}
if len(n.Journals) != 3 {
t.Fatalf("Journals = %d; want 3", len(n.Journals))
}
want := map[string]string{
"authora": "https://a.furaffinity.net/100/authora.gif",
"authorb": "https://a.furaffinity.net/200/authorb.gif",
}
for i, j := range n.Journals {
if got := j.Author.AvatarURL; got != want[j.Author.Name] {
t.Errorf("Journals[%d] (%s): AvatarURL = %q; want %q",
i, j.Author.Name, got, want[j.Author.Name])
}
}
// authora appears on two journals but must be fetched exactly once.
if v, ok := userHits.Load("authora"); !ok || v.(*atomic.Int32).Load() != 1 {
got := int32(0)
if ok {
got = v.(*atomic.Int32).Load()
}
t.Errorf("/user/authora/ fetched %d times; want 1 (dedup)", got)
}
if v, ok := userHits.Load("authorb"); !ok || v.(*atomic.Int32).Load() != 1 {
t.Error("/user/authorb/ not fetched exactly once")
}
}
func TestNotifications_ResolvedAvatarsRespectsLimit(t *testing.T) {
var userPageHits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/msg/others/", func(w http.ResponseWriter, r *http.Request) {
// Four journals, three distinct authors. authora appears twice.
_, _ = w.Write([]byte(fakeMsgOthersPage(
journalNotifRow(101, "First", "authora", "AuthorA"),
journalNotifRow(102, "Second", "authora", "AuthorA"),
journalNotifRow(103, "Third", "authorb", "AuthorB"),
journalNotifRow(104, "Fourth", "authorc", "AuthorC"),
)))
})
for _, name := range []string{"authora", "authorb", "authorc"} {
name := name
mux.HandleFunc("/user/"+name+"/", func(w http.ResponseWriter, r *http.Request) {
userPageHits.Add(1)
_, _ = w.Write([]byte(fakeUserAvatarPage(name, "100")))
})
}
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
// Budget of 2 distinct authors. Journals resolve in document order, so
// authora + authorb get fetched; authorc is past the budget.
n, err := client.Notifications(context.Background(), WithResolvedAvatars(2))
if err != nil {
t.Fatalf("Notifications: %v", err)
}
if len(n.Journals) != 4 {
t.Fatalf("Journals = %d; want 4", len(n.Journals))
}
// Exactly 2 profile fetches the limit caps *fetches*, not applications.
if got := userPageHits.Load(); got != 2 {
t.Errorf("/user/ fetched %d times; want 2 (limit)", got)
}
byName := map[string]string{}
for _, j := range n.Journals {
// Both authora rows must agree (cache hit applies past the limit).
if prev, seen := byName[j.Author.Name]; seen && prev != j.Author.AvatarURL {
t.Errorf("author %s: inconsistent AvatarURL %q vs %q", j.Author.Name, prev, j.Author.AvatarURL)
}
byName[j.Author.Name] = j.Author.AvatarURL
}
if byName["authora"] == "" || byName["authorb"] == "" {
t.Errorf("authora/authorb should be resolved within budget; got %q / %q",
byName["authora"], byName["authorb"])
}
if byName["authorc"] != "" {
t.Errorf("authorc is past the budget; AvatarURL = %q, want empty", byName["authorc"])
}
}
func TestNotifications_NoAvatarResolutionByDefault(t *testing.T) {
var userPageHits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/msg/others/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(fakeMsgOthersPage(
journalNotifRow(101, "First", "authora", "AuthorA"),
)))
})
mux.HandleFunc("/user/", func(w http.ResponseWriter, r *http.Request) {
userPageHits.Add(1)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
n, err := client.Notifications(context.Background())
if err != nil {
t.Fatalf("Notifications: %v", err)
}
if len(n.Journals) != 1 {
t.Fatalf("Journals = %d; want 1", len(n.Journals))
}
// Without WithResolvedAvatars the SDK must not touch /user/ pages, and
// the avatar stays empty (FA does not provide it on /msg/others/).
if n.Journals[0].Author.AvatarURL != "" {
t.Errorf("AvatarURL = %q; want empty without WithResolvedAvatars", n.Journals[0].Author.AvatarURL)
}
if userPageHits.Load() != 0 {
t.Errorf("/user/ fetched %d times; want 0 (no resolution requested)", userPageHits.Load())
}
}

193
options.go Normal file
View File

@@ -0,0 +1,193 @@
package fa
import (
"log/slog"
"net/http"
"time"
)
// Cookies are FA's session cookies as they appear in a browser's storage.
// Both A and B are required for authenticated requests; either may be empty
// for unauthenticated browsing of public pages.
type Cookies struct {
A string
B string
}
// CFCookies carries the Cloudflare clearance cookie obtained from a real
// browser. Cloudflare binds Clearance to the exact User-Agent string that
// produced it, so callers must also pass [WithUserAgent] with that UA.
type CFCookies struct {
Clearance string
}
// SFWMode controls whether FA serves the "safe for work" filtered view
// that hides mature and adult submission thumbnails. The site exposes this
// as a slider in the navbar; under the hood it writes a single `sfw`
// cookie that the page-render code reads server-side.
//
// Note: SFW mode does not change which submissions appear in listings —
// adult items still show up, but their thumbnails are replaced with a
// "blocked content" placeholder and the file URL is hidden. To filter
// listings by rating, use the browse/search rating filters (M4).
type SFWMode int
const (
// SFWAuto leaves the cookie alone. FA falls back to the account's
// saved preference (or, for anonymous clients, defaults to NSFW
// visible). Use this when you don't want the SDK to override what the
// user clicked in the browser.
SFWAuto SFWMode = iota
// SFWOn sets `sfw=1` mature and adult content is blocked from
// rendering on all pages this client fetches.
SFWOn
// SFWOff sets `sfw=0` mature and adult content renders fully. This
// is FA's default for logged-in adult-verified accounts.
SFWOff
)
// Option configures a [Client] at construction time.
type Option func(*config)
// config is the internal, fully-resolved client configuration. Defaults are
// applied in New before options run.
type config struct {
cookies Cookies
cf CFCookies
sfw SFWMode
userAgent string
rateInterval time.Duration
rateBurst int
logger *slog.Logger
httpClient *http.Client
maxRetries int
jsonListings bool
priorityRL bool
}
// defaultUserAgent identifies this SDK. Callers should override it with
// WithUserAgent most importantly so it matches the UA that produced their
// cf_clearance cookie.
const defaultUserAgent = "go-fa-api/0.1 (+https://git.anthrove.art/public/go-fa-api)"
// WithCookies sets the FA session cookies (a, b). Without these, requests to
// authenticated endpoints will surface as [ErrUnauthorized].
func WithCookies(c Cookies) Option {
return func(cfg *config) { cfg.cookies = c }
}
// WithCloudflare sets the cf_clearance cookie used to satisfy Cloudflare's
// challenges. Must be paired with [WithUserAgent] using the same UA the
// cookie was issued under.
func WithCloudflare(c CFCookies) Option {
return func(cfg *config) { cfg.cf = c }
}
// WithSFW overrides the account's saved SFW preference for this Client.
// Pass [SFWOn] to force the SFW filter on, [SFWOff] to force it off, or
// [SFWAuto] (the default) to leave it unset so the account or anonymous
// default takes effect.
func WithSFW(mode SFWMode) Option {
return func(cfg *config) { cfg.sfw = mode }
}
// WithUserAgent overrides the default User-Agent header. When using a
// cf_clearance cookie, this must match the browser UA the cookie came from.
func WithUserAgent(ua string) Option {
return func(cfg *config) { cfg.userAgent = ua }
}
// WithRateLimit sets the request interval and burst size for the token bucket
// that guards every HTTP request the SDK makes. The default (1s, burst 1) is
// the safest setting for FurAffinity; tightening it risks Cloudflare bans.
func WithRateLimit(interval time.Duration, burst int) Option {
return func(cfg *config) {
cfg.rateInterval = interval
cfg.rateBurst = burst
}
}
// WithRequestsPerSecond is a shorthand for [WithRateLimit] when you think in
// terms of throughput. A value of 0.5 means one request every two seconds.
func WithRequestsPerSecond(rps float64) Option {
return func(cfg *config) {
if rps <= 0 {
return
}
cfg.rateInterval = time.Duration(float64(time.Second) / rps)
cfg.rateBurst = 1
}
}
// WithLogger attaches a structured logger. The SDK emits debug records for
// retries and rate-limit waits; nothing is logged at info level.
func WithLogger(l *slog.Logger) Option {
return func(cfg *config) {
if l != nil {
cfg.logger = l
}
}
}
// WithHTTPClient lets callers supply a fully constructed *http.Client. Useful
// for tests (httptest.Server) and for plugging in custom transports such as
// uTLS. The SDK still wraps the client's Transport with its rate-limited
// transport supply http.DefaultTransport for the default behaviour.
func WithHTTPClient(hc *http.Client) Option {
return func(cfg *config) { cfg.httpClient = hc }
}
// WithMaxRetries caps the number of automatic retry attempts on 429/5xx.
// Defaults to 3. Set to 0 to disable retries entirely.
func WithMaxRetries(n int) Option {
return func(cfg *config) {
if n >= 0 {
cfg.maxRetries = n
}
}
}
// WithExperimentalJSONListings opts into the JSON-first merge strategy for
// listing-page parsers (Gallery / Scraps / Favorites / Browse / Search /
// SubmissionInbox).
//
// When enabled, the parser first reads the <script id="js-submissionData">
// blob FA embeds on every listing page and uses it as the primary source
// for title, author display name, URL-safe login, and avatar URL. HTML
// scraping fills in fields the JSON doesn't carry (ID, rating, thumbnail).
// If the script is absent or malformed on a given page, the parser
// transparently falls back to pure HTML scraping caller sees no error.
//
// This is gated as "experimental" because: (1) the JSON's description
// field carries BBCode rather than HTML and is occasionally truncated, so
// we intentionally don't use it; (2) FA could remove the script tag at
// any time, in which case the fallback path is what actually runs.
//
// Defaults to false. Most callers should leave it off until selector
// drift becomes a real problem.
func WithExperimentalJSONListings(enabled bool) Option {
return func(cfg *config) { cfg.jsonListings = enabled }
}
// WithPrioritizedRateLimiting enables multi-level priority on the client's
// rate limiter. It is an opt-in feature; by default the limiter serves every
// request in plain FIFO order.
//
// When enabled, each request is scheduled according to the [Priority] marker
// on its context (see [WithPriority] and [WithBackgroundPriority]): the
// limiter serves a higher-priority request before a lower-priority one. A
// request with no marker is [PriorityNormal]. The overall pace is unchanged —
// there is still a single global token bucket, since FA rate-limits per
// account only the order in which competing requests are served.
//
// Enable this when the application mixes user-driven requests with
// best-effort background work (preloading, crawling) and wants the former to
// never wait behind the latter. With the feature disabled, [Priority] markers
// are inert.
//
// Defaults to false.
func WithPrioritizedRateLimiting(enabled bool) Option {
return func(cfg *config) { cfg.priorityRL = enabled }
}

58
pagination.go Normal file
View File

@@ -0,0 +1,58 @@
package fa
import (
"strings"
"github.com/PuerkitoBio/goquery"
)
// ListOptions configures the pagination of a simple iterator method like
// [Client.Gallery] or [Client.Notes]. Filtered iterators ([Client.Search],
// [Client.Browse]) use their own option structs that fold the same fields
// in alongside their filter parameters.
//
// Zero values mean "use the SDK defaults": start at page 1, no upper bound
// on pages. Pass [ListOptions{MaxPages: 3}] to bound a crawl.
type ListOptions struct {
// StartPage is the 1-based page to begin iteration on. Zero or 1 = first
// page. Useful for resuming after a known-good page.
StartPage int
// MaxPages bounds the number of pages the iterator will request before
// stopping. Zero (the default) = unbounded; iteration stops when FA
// serves an empty page or omits the "next" link.
MaxPages int
}
// firstPage returns the effective starting page (≥ 1).
func (o ListOptions) firstPage() int {
if o.StartPage < 1 {
return 1
}
return o.StartPage
}
// reachedLimit reports whether the iterator has fetched MaxPages pages and
// should stop. Always false when MaxPages is 0 (unbounded).
func (o ListOptions) reachedLimit(pagesFetched int) bool {
return o.MaxPages > 0 && pagesFetched >= o.MaxPages
}
// detectNextPage returns true if doc shows there is a next page available.
// FA's beta theme renders pagination as either a Next form button or a
// hyperlink with a recognisable label.
func detectNextPage(doc *goquery.Document) bool {
if doc.Find("form button.button.standard:contains('Next')").Length() > 0 {
return true
}
hit := false
doc.Find("a.button.standard, a.button-link, a.pagination-next").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
text := strings.ToLower(trimText(sel))
if strings.Contains(text, "next") || strings.Contains(text, "older") {
hit = true
return false
}
return true
})
return hit
}

117
parse_helpers.go Normal file
View File

@@ -0,0 +1,117 @@
package fa
import (
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
)
// parsePopupDate reads a FurAffinity popup_date element. Every popup_date on
// the site carries an authoritative `data-time` attribute holding the post
// time as Unix seconds; that is preferred. The visible title attribute is a
// fallback for older rendered pages that omit data-time.
//
// Returns the zero time if neither source is parseable. Callers check IsZero
// rather than receiving an error, because dates are nice-to-have and
// shouldn't fail a whole parse on their own.
func parsePopupDate(s *goquery.Selection) time.Time {
if s == nil || s.Length() == 0 {
return time.Time{}
}
if v := trimAttr(s, "data-time"); v != "" {
if secs, err := strconv.ParseInt(v, 10, 64); err == nil && secs > 0 {
return time.Unix(secs, 0).UTC()
}
}
raw := firstNonEmpty(trimAttr(s, "title"), trimText(s))
if t, err := ParseFADate(raw); err == nil {
return t
}
return time.Time{}
}
// extractIntFromHref pulls the first /<numeric>/ segment from an href.
// Returns 0 if none is found. Used to lift submission/journal/comment IDs
// out of links like "/view/12345678/" or "/journal/4567890/".
func extractIntFromHref(href string) int64 {
for _, seg := range strings.Split(href, "/") {
if seg == "" {
continue
}
if n, err := strconv.ParseInt(seg, 10, 64); err == nil {
return n
}
}
return 0
}
// trimText is the goquery-friendly equivalent of strings.TrimSpace applied
// to a selection's text. Returns "" when the selection is empty.
func trimText(s *goquery.Selection) string {
if s == nil || s.Length() == 0 {
return ""
}
return strings.TrimSpace(s.Text())
}
// trimAttr returns the trimmed value of attr, or "" if missing.
func trimAttr(s *goquery.Selection, attr string) string {
if s == nil || s.Length() == 0 {
return ""
}
v, ok := s.Attr(attr)
if !ok {
return ""
}
return strings.TrimSpace(v)
}
// firstNonEmpty returns the first non-empty (after trim) string.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if t := strings.TrimSpace(v); t != "" {
return t
}
}
return ""
}
// htmlOf returns the inner HTML of a selection, with leading/trailing
// whitespace trimmed. Returns "" when the selection is empty or rendering fails.
func htmlOf(s *goquery.Selection) string {
if s == nil || s.Length() == 0 {
return ""
}
h, err := s.Html()
if err != nil {
return ""
}
return strings.TrimSpace(h)
}
// parseStatNumber parses a number that may carry commas or surrounding text
// (e.g. "1,234 views"). Returns 0 on any failure rather than propagating an
// error stats are nice-to-have, not load-bearing.
func parseStatNumber(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
// Strip everything that isn't a digit or comma; keep digits & commas only.
var b strings.Builder
for _, r := range s {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
if b.Len() == 0 {
return 0
}
n, err := strconv.Atoi(b.String())
if err != nil {
return 0
}
return n
}

295
ratelimit.go Normal file
View File

@@ -0,0 +1,295 @@
package fa
import (
"context"
"sync"
"time"
"golang.org/x/time/rate"
)
// Priority orders requests competing for the shared rate-limiter token. A
// lower numeric value is a higher priority: the limiter always serves a
// waiting request of higher priority before one of lower priority.
//
// Priority only decides the order in which competing requests reserve the
// next token never how fast tokens are emitted. There is still a single
// global token bucket, because FurAffinity rate-limits per account.
//
// Priority markers take effect only on a Client built with
// [WithPrioritizedRateLimiting]. Without it every request is served in plain
// FIFO order and the marker is inert so setting a priority is always safe.
type Priority int
const (
// PriorityInteractive is the highest priority: requests the user is
// actively waiting on the page on screen, user write actions.
PriorityInteractive Priority = iota
// PriorityNormal is the default priority of any request whose context
// carries no priority marker.
PriorityNormal
// PriorityLow is below normal: best-effort work that should yield to
// user-driven traffic, such as speculative preloading of likely-next
// submissions.
PriorityLow
// PriorityBackground is the lowest priority: bulk background work such
// as inbox or watchlist crawling. It yields to every other level.
PriorityBackground
)
// numPriorities is the count of defined Priority levels.
const numPriorities = 4
// String returns the constant name of p, for log readability.
func (p Priority) String() string {
switch p {
case PriorityInteractive:
return "interactive"
case PriorityNormal:
return "normal"
case PriorityLow:
return "low"
case PriorityBackground:
return "background"
default:
return "unknown"
}
}
// priorityKey is the context key carrying a request's [Priority].
type priorityKey struct{}
// WithPriority marks ctx and every SDK call made with it at priority p.
// A context that carries no marker resolves to [PriorityNormal].
//
// A p outside [PriorityInteractive, PriorityBackground] is clamped into that
// range; callers using the named constants never trigger this.
//
// This marker takes effect only on a Client built with
// [WithPrioritizedRateLimiting]; otherwise it is inert. See [Priority].
func WithPriority(ctx context.Context, p Priority) context.Context {
if p < PriorityInteractive {
p = PriorityInteractive
}
if p > PriorityBackground {
p = PriorityBackground
}
return context.WithValue(ctx, priorityKey{}, p)
}
// WithBackgroundPriority marks ctx and every SDK call made with it as
// lowest-priority background work. It is exactly
// WithPriority(ctx, [PriorityBackground]).
//
// A background request yields the rate limiter to every higher-priority
// request: it will not take the next token while any higher-priority request
// is queued for one. This does not change the overall pace there is still a
// single global token bucket, because FA rate-limits per account only the
// order in which competing requests are served.
//
// Use it for best-effort work (preloading, crawling) so it never delays a
// request the user is actively waiting on. Background requests can be starved
// indefinitely by a steady stream of higher-priority traffic; that is
// intentional for best-effort work.
//
// This marker only takes effect on a Client built with
// [WithPrioritizedRateLimiting]. On a client without it the marker is inert
// and every request is served in plain FIFO order.
func WithBackgroundPriority(ctx context.Context) context.Context {
return WithPriority(ctx, PriorityBackground)
}
// priorityOf reports the [Priority] ctx was decorated with, or
// [PriorityNormal] when ctx is nil or carries no marker.
func priorityOf(ctx context.Context) Priority {
if ctx == nil {
return PriorityNormal
}
p, ok := ctx.Value(priorityKey{}).(Priority)
if !ok {
return PriorityNormal
}
return p
}
// rateLimiter paces the HTTP requests the SDK makes.
//
// With priority scheduling disabled (the default) it is a plain FIFO token
// bucket: [rate.Limiter] grants reservations in call order.
//
// With priority scheduling enabled it is instead a purpose-built
// priority-aware token bucket. Tokens accrue at the same fixed rate, but a
// waiting request consumes the next token only once no higher-priority
// request is also waiting so the highest-priority waiter is always served
// first. The emission rate is unchanged; priority only reorders who consumes
// each token. Requests at the same priority level race for each token.
type rateLimiter struct {
lim *rate.Limiter // FIFO pacing; used when priority is disabled
priority bool // honour Priority markers via the bucket below
// Priority-aware token bucket, used only when priority is enabled.
mu sync.Mutex
interval time.Duration // time to accrue one token
burst float64 // token-bucket capacity
tokens float64 // tokens currently available
last time.Time // when tokens was last accrued
waiters [numPriorities]int // requests currently waiting at each level
changed chan struct{} // closed and replaced on every waiter-set change
}
// newRateLimiter constructs a token-bucket limiter that emits one token per
// interval, with the given burst size. A burst of 1 yields strict pacing;
// burst > 1 lets short bursts through before the steady rate kicks in.
//
// priority enables priority-aware scheduling; when false the limiter is a
// plain FIFO bucket and [Priority] markers are ignored.
func newRateLimiter(interval time.Duration, burst int, priority bool) *rateLimiter {
if interval <= 0 {
interval = time.Second
}
if burst <= 0 {
burst = 1
}
return &rateLimiter{
lim: rate.NewLimiter(rate.Every(interval), burst),
priority: priority,
interval: interval,
burst: float64(burst),
tokens: float64(burst),
last: time.Now(),
changed: make(chan struct{}),
}
}
// wait blocks until a token is available or ctx is cancelled.
//
// With priority scheduling disabled it is a plain FIFO token reservation.
// With it enabled the request waits until no higher-priority request is
// queued and a token is available, then consumes it. Cancellation propagates
// through both the limiter and the surrounding HTTP request.
//
// Requests at the same priority level are not ordered relative to one
// another they race for each token. Lower-priority levels can be starved
// indefinitely by a steady stream of higher-priority traffic; that is
// intentional for best-effort background work.
func (r *rateLimiter) wait(ctx context.Context) error {
if r == nil || r.lim == nil {
return nil
}
// Priority scheduling is opt-in (see WithPrioritizedRateLimiting). When
// off, every request is a plain FIFO reservation and any Priority marker
// on ctx is ignored.
if !r.priority {
return r.lim.Wait(ctx)
}
return r.waitPriority(ctx, priorityOf(ctx))
}
// waitPriority serves a request at priority p from the priority-aware token
// bucket. It registers the request so lower-priority requests defer to it,
// then loops until it can consume a token: it sleeps until the bucket should
// have refilled, waking early whenever the waiter set changes (a request
// arrived or left) to re-evaluate its turn.
func (r *rateLimiter) waitPriority(ctx context.Context, p Priority) error {
r.register(p)
defer r.unregister(p)
for {
if err := ctx.Err(); err != nil {
return err
}
r.mu.Lock()
r.refill()
// The eligibility check and the consume happen in one critical
// section, so a higher-priority register cannot interleave between
// them.
blocked := r.blockingAbove(p)
if !blocked && r.tokens >= 1 {
r.tokens--
r.mu.Unlock()
return nil
}
ch := r.changed
var sleep time.Duration
if !blocked {
// Highest-priority waiter, but short of a token: sleep until the
// bucket should have accrued one.
sleep = time.Duration((1 - r.tokens) * float64(r.interval))
}
r.mu.Unlock()
// sleep <= 0 means this request is blocked behind a higher-priority
// level: wait only for the waiter set to change (or for cancellation).
if sleep <= 0 {
select {
case <-ch:
case <-ctx.Done():
return ctx.Err()
}
continue
}
timer := time.NewTimer(sleep)
select {
case <-timer.C:
case <-ch:
timer.Stop()
case <-ctx.Done():
timer.Stop()
return ctx.Err()
}
}
}
// refill accrues tokens for the time elapsed since the last refill, capped at
// the bucket's burst capacity. Caller must hold r.mu.
func (r *rateLimiter) refill() {
now := time.Now()
r.tokens += float64(now.Sub(r.last)) / float64(r.interval)
if r.tokens > r.burst {
r.tokens = r.burst
}
r.last = now
}
// blockingAbove reports whether any request strictly higher-priority than p
// is currently waiting. Caller must hold r.mu.
func (r *rateLimiter) blockingAbove(p Priority) bool {
for q := PriorityInteractive; q < p; q++ {
if r.waiters[q] > 0 {
return true
}
}
return false
}
// register records a request now waiting at priority p and wakes every other
// waiter so it can re-evaluate its turn.
func (r *rateLimiter) register(p Priority) {
r.mu.Lock()
defer r.mu.Unlock()
r.waiters[p]++
r.broadcast()
}
// unregister records that a request waiting at priority p has finished (it
// consumed a token, or its context was cancelled) and wakes every other
// waiter so it can re-evaluate its turn.
func (r *rateLimiter) unregister(p Priority) {
r.mu.Lock()
defer r.mu.Unlock()
r.waiters[p]--
r.broadcast()
}
// broadcast wakes every goroutine selecting on the current changed channel
// and installs a fresh one for the next wait. It intentionally over-wakes —
// every waiter re-evaluates its turn even if its eligibility did not change.
// Caller must hold r.mu.
func (r *rateLimiter) broadcast() {
close(r.changed)
r.changed = make(chan struct{})
}

268
ratelimit_test.go Normal file
View File

@@ -0,0 +1,268 @@
package fa
import (
"context"
"strings"
"sync"
"testing"
"time"
)
// TestRateLimiter_ShapesThroughput asserts that the rate limiter actually
// paces requests. Uses real time (small intervals) rather than synctest so
// it works on any Go 1.25+ host without GOEXPERIMENT.
func TestRateLimiter_ShapesThroughput(t *testing.T) {
lim := newRateLimiter(50*time.Millisecond, 1, false)
ctx := context.Background()
start := time.Now()
const N = 4
for i := 0; i < N; i++ {
if err := lim.wait(ctx); err != nil {
t.Fatalf("wait: %v", err)
}
}
elapsed := time.Since(start)
// With burst=1 and interval=50ms, N=4 should take at least 3*50ms = 150ms.
// Allow a small lower-bound floor; the first token is free with burst=1.
if elapsed < 100*time.Millisecond {
t.Fatalf("expected at least 100ms for %d requests; got %v", N, elapsed)
}
}
func TestRateLimiter_RespectsContextCancel(t *testing.T) {
lim := newRateLimiter(time.Hour, 1, false) // effectively never refills
ctx, cancel := context.WithCancel(context.Background())
// Drain the burst token.
if err := lim.wait(ctx); err != nil {
t.Fatalf("first wait: %v", err)
}
go func() {
time.Sleep(20 * time.Millisecond)
cancel()
}()
err := lim.wait(ctx)
if err == nil {
t.Fatal("expected error from cancelled context")
}
}
func TestRateLimiter_NilSafe(t *testing.T) {
var r *rateLimiter
if err := r.wait(context.Background()); err != nil {
t.Fatalf("nil-receiver wait should be no-op; got %v", err)
}
}
func TestWithPriority_ContextMarker(t *testing.T) {
if got := priorityOf(context.Background()); got != PriorityNormal {
t.Errorf("undecorated context = %v; want PriorityNormal", got)
}
if got := priorityOf(nil); got != PriorityNormal {
t.Errorf("nil context = %v; want PriorityNormal", got)
}
if got := priorityOf(WithBackgroundPriority(context.Background())); got != PriorityBackground {
t.Errorf("WithBackgroundPriority = %v; want PriorityBackground", got)
}
if got := priorityOf(WithPriority(context.Background(), PriorityInteractive)); got != PriorityInteractive {
t.Errorf("WithPriority(Interactive) = %v; want PriorityInteractive", got)
}
// The marker survives further decoration.
ctx, cancel := context.WithCancel(WithPriority(context.Background(), PriorityLow))
defer cancel()
if got := priorityOf(ctx); got != PriorityLow {
t.Errorf("priority marker should survive a child context; got %v", got)
}
}
// orderRecorder collects the completion order of concurrent wait calls.
type orderRecorder struct {
mu sync.Mutex
seen []string
}
func (o *orderRecorder) wait(t *testing.T, wg *sync.WaitGroup, lim *rateLimiter, ctx context.Context, name string) {
t.Helper()
defer wg.Done()
if err := lim.wait(ctx); err != nil {
t.Errorf("%s wait: %v", name, err)
return
}
o.mu.Lock()
o.seen = append(o.seen, name)
o.mu.Unlock()
}
func (o *orderRecorder) joined() string {
o.mu.Lock()
defer o.mu.Unlock()
return strings.Join(o.seen, ",")
}
// TestRateLimiter_BackgroundDefersToForeground: with priority enabled, a
// background request (B) defers to BOTH foreground requests even C, which
// is issued *after* B so B completes last. A and C are the same priority
// and race, so their order relative to each other is unspecified.
func TestRateLimiter_BackgroundDefersToForeground(t *testing.T) {
lim := newRateLimiter(60*time.Millisecond, 1, true)
bg := context.Background()
if err := lim.wait(bg); err != nil { // drain the burst token
t.Fatalf("drain: %v", err)
}
var order orderRecorder
var wg sync.WaitGroup
wg.Add(3)
go order.wait(t, &wg, lim, bg, "A") // foreground, t0
time.Sleep(20 * time.Millisecond)
go order.wait(t, &wg, lim, WithBackgroundPriority(bg), "B") // background, t0+20ms
time.Sleep(20 * time.Millisecond)
go order.wait(t, &wg, lim, bg, "C") // foreground, t0+40ms
wg.Wait()
// A and C are both foreground (PriorityNormal); same-level requests race,
// so their relative order is unspecified. The guarantee is only that the
// background request B is served last.
if got := order.joined(); got != "A,C,B" && got != "C,A,B" {
t.Errorf("completion order = %q; want background (B) last, foreground A and C before it in either order", got)
}
}
// TestRateLimiter_PriorityDisabledServesFIFO: the same scenario with priority
// disabled the WithBackgroundPriority marker is inert, so requests are
// served strictly in the order they queued: A, B, C.
func TestRateLimiter_PriorityDisabledServesFIFO(t *testing.T) {
lim := newRateLimiter(60*time.Millisecond, 1, false)
bg := context.Background()
if err := lim.wait(bg); err != nil { // drain the burst token
t.Fatalf("drain: %v", err)
}
var order orderRecorder
var wg sync.WaitGroup
wg.Add(3)
go order.wait(t, &wg, lim, bg, "A")
time.Sleep(20 * time.Millisecond)
go order.wait(t, &wg, lim, WithBackgroundPriority(bg), "B") // marked, but ignored
time.Sleep(20 * time.Millisecond)
go order.wait(t, &wg, lim, bg, "C")
wg.Wait()
if got := order.joined(); got != "A,B,C" {
t.Errorf("completion order = %q; want \"A,B,C\" (priority off marker must be inert)", got)
}
}
// TestRateLimiter_BackgroundProceedsWithoutForeground confirms a background
// request does not hang when nothing foreground is competing.
func TestRateLimiter_BackgroundProceedsWithoutForeground(t *testing.T) {
lim := newRateLimiter(20*time.Millisecond, 1, true)
ctx := WithBackgroundPriority(context.Background())
for i := 0; i < 3; i++ {
c, cancel := context.WithTimeout(ctx, time.Second)
err := lim.wait(c)
cancel()
if err != nil {
t.Fatalf("background wait %d: %v", i, err)
}
}
}
// TestRateLimiter_BackgroundWaitRespectsCancel confirms a background request
// blocked behind a foreground request still honours context cancellation.
func TestRateLimiter_BackgroundWaitRespectsCancel(t *testing.T) {
lim := newRateLimiter(time.Hour, 1, true) // bucket effectively never refills
bg := context.Background()
if err := lim.wait(bg); err != nil {
t.Fatalf("drain: %v", err)
}
// A foreground request parks in wait forever, keeping the gate closed.
fgCtx, fgCancel := context.WithCancel(bg)
defer fgCancel()
fgStarted := make(chan struct{})
go func() {
close(fgStarted)
_ = lim.wait(fgCtx)
}()
<-fgStarted
time.Sleep(20 * time.Millisecond) // ensure the foreground counter is set
ctx, cancel := context.WithCancel(WithBackgroundPriority(bg))
go func() {
time.Sleep(20 * time.Millisecond)
cancel()
}()
if err := lim.wait(ctx); err == nil {
t.Fatal("expected cancellation error for background wait blocked behind foreground")
}
}
// TestNew_PrioritizedRateLimitingOption verifies the construction-time
// opt-in wires through to the limiter.
func TestNew_PrioritizedRateLimitingOption(t *testing.T) {
if New().limiter.priority {
t.Error("default client: priority scheduling should be off")
}
if !New(WithPrioritizedRateLimiting(true)).limiter.priority {
t.Error("WithPrioritizedRateLimiting(true): priority scheduling should be on")
}
if New(WithPrioritizedRateLimiting(false)).limiter.priority {
t.Error("WithPrioritizedRateLimiting(false): priority scheduling should be off")
}
}
func TestPriority_String(t *testing.T) {
cases := map[Priority]string{
PriorityInteractive: "interactive",
PriorityNormal: "normal",
PriorityLow: "low",
PriorityBackground: "background",
Priority(42): "unknown",
}
for p, want := range cases {
if got := p.String(); got != want {
t.Errorf("Priority(%d).String() = %q; want %q", int(p), got, want)
}
}
}
func TestWithPriority_ClampsOutOfRange(t *testing.T) {
if got := priorityOf(WithPriority(context.Background(), Priority(-5))); got != PriorityInteractive {
t.Errorf("below-range priority = %v; want PriorityInteractive", got)
}
if got := priorityOf(WithPriority(context.Background(), Priority(99))); got != PriorityBackground {
t.Errorf("above-range priority = %v; want PriorityBackground", got)
}
}
// TestRateLimiter_NLevelPriorityOrder: with priority enabled, four requests
// queued lowest-first must complete highest-priority-first, not in arrival
// order.
func TestRateLimiter_NLevelPriorityOrder(t *testing.T) {
lim := newRateLimiter(60*time.Millisecond, 1, true)
bg := context.Background()
if err := lim.wait(bg); err != nil { // drain the burst token
t.Fatalf("drain: %v", err)
}
var order orderRecorder
var wg sync.WaitGroup
wg.Add(4)
go order.wait(t, &wg, lim, WithPriority(bg, PriorityBackground), "background")
time.Sleep(15 * time.Millisecond)
go order.wait(t, &wg, lim, WithPriority(bg, PriorityLow), "low")
time.Sleep(15 * time.Millisecond)
go order.wait(t, &wg, lim, WithPriority(bg, PriorityNormal), "normal")
time.Sleep(15 * time.Millisecond)
go order.wait(t, &wg, lim, WithPriority(bg, PriorityInteractive), "interactive")
wg.Wait()
if got := order.joined(); got != "interactive,normal,low,background" {
t.Errorf("completion order = %q; want \"interactive,normal,low,background\"", got)
}
}

461
real_fixtures_test.go Normal file
View File

@@ -0,0 +1,461 @@
package fa
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
// This file groups parser tests that exercise the *additional* fixtures
// captured by the extended TestRefreshFixtures (story / shouts / last page
// / scraps / favorites / journals listing / journal comments / system
// message). Each test t.Skip's cleanly when its fixture isn't present.
// TestParseSubmission_StoryRealFixture verifies the non-image submission
// path. FA still renders a #submissionImg for stories, but it's a generated
// thumbnail (a .gif preview of the document) FileURL must point at the
// real document URL from the Download button, not the thumbnail.
func TestParseSubmission_StoryRealFixture(t *testing.T) {
raw := loadFixture(t, "submission_story.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
sub, err := parseSubmission(0, doc)
if err != nil {
t.Fatalf("parseSubmission(story): %v", err)
}
if sub.Title == "" {
t.Error("story fixture: Title is empty")
}
if sub.Author.Name == "" {
t.Error("story fixture: Author.Name is empty")
}
if sub.FileURL == "" {
t.Fatal("story fixture: FileURL is empty (Download button selector missed?)")
}
// The thumbnail FA injects into #submissionImg ends in .gif; the real
// document does not. Catching this is the whole point of the fixture.
if strings.HasSuffix(sub.FileURL, ".gif") {
t.Errorf("story FileURL = %q; points at the #submissionImg thumbnail gif, not the document", sub.FileURL)
}
if !strings.Contains(sub.FileURL, "/download/") {
t.Errorf("story FileURL = %q; want the Download-button URL (.../download/...)", sub.FileURL)
}
if sub.Category != "Story" {
t.Errorf("story Category = %q; want %q", sub.Category, "Story")
}
// The captured page renders a "+Fav" link (viewer has not favorited it),
// so Favorited must be false against this real markup.
if sub.Favorited {
t.Error("story fixture: Favorited = true; fixture page shows the +Fav link")
}
t.Logf("story struct: %+v", sub)
}
// TestParseUser_WithShoutsRealFixture exercises the shouts parser. If the
// captured profile happens to have zero shouts (e.g. shouts disabled), the
// test logs that rather than failing the assertion is that parsing didn't
// crash, not that the user had shouts.
func TestParseUser_WithShoutsRealFixture(t *testing.T) {
raw := loadFixture(t, "user_with_shouts.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
u, err := parseUser("fixture", doc)
if err != nil {
t.Fatalf("parseUser(shouts): %v", err)
}
if u.DisplayName == "" {
t.Error("shouts fixture: DisplayName is empty")
}
if len(u.Shouts) == 0 {
t.Logf("shouts fixture: 0 shouts parsed either the user has none, or the selector missed them")
} else {
t.Logf("shouts fixture: parsed %d shouts", len(u.Shouts))
first := u.Shouts[0]
if first.Author.DisplayName == "" && first.BodyHTML == "" {
t.Error("shouts fixture: first shout has empty Author + Body")
}
}
}
// TestParseGalleryPage_LastPageRealFixture asserts that detectNextPage
// returns false on the last gallery page. A trailing page that still
// reports "next" usually means our pagination selector matched a button on
// the page header instead of the paginator.
func TestParseGalleryPage_LastPageRealFixture(t *testing.T) {
raw := loadFixture(t, "gallery_page_last.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
items, hasNext := parseGalleryPage(doc, false)
t.Logf("last page: %d items, hasNext=%v", len(items), hasNext)
if hasNext {
t.Error("last page fixture: hasNext = true; detectNextPage likely matched a non-paginator button")
}
}
// TestParseGalleryPage_ScrapsRealFixture confirms the same parser works on
// /scraps/ pages. Scraps and gallery share figure[id^=sid-] markup.
func TestParseGalleryPage_ScrapsRealFixture(t *testing.T) {
raw := loadFixture(t, "scraps_page1.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
items, hasNext := parseGalleryPage(doc, false)
t.Logf("scraps page1: %d items, hasNext=%v", len(items), hasNext)
for i, it := range items {
if it.ID == 0 {
t.Errorf("scraps item %d: ID == 0", i)
}
if it.Title == "" {
t.Errorf("scraps item %d: empty Title", i)
}
}
}
// TestParseGalleryPage_FavoritesRealFixture verifies that on a favorites
// page, the per-item Author reflects the original artist (not the user
// whose favorites we are walking). This is the single load-bearing
// difference between gallery and favorites parsing.
func TestParseGalleryPage_FavoritesRealFixture(t *testing.T) {
raw := loadFixture(t, "favorites_page1.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
items, hasNext := parseGalleryPage(doc, false)
if len(items) == 0 {
t.Fatal("favorites fixture: no items parsed")
}
t.Logf("favorites: %d items, hasNext=%v", len(items), hasNext)
withAuthor := 0
for i, it := range items {
if it.ID == 0 {
t.Errorf("fav item %d: ID == 0", i)
}
if it.Title == "" {
t.Errorf("fav item %d: empty Title", i)
}
if it.Author.Name != "" {
withAuthor++
}
}
// We require Author on at least the majority of items; FA occasionally
// renders a "blocked" placeholder figure without a usable author link.
if withAuthor < len(items)/2 {
t.Errorf("favorites fixture: only %d/%d items had Author.Name set figcaption /user/ selector likely off",
withAuthor, len(items))
}
}
// TestParseUserJournalsPage_RealFixture parses the journals listing
// captured for FA_TEST_JOURNALS_USER. Zero entries is acceptable (the user
// may have no journals); the test asserts the parser doesn't crash and
// pagination detection doesn't return a false-positive next link.
func TestParseUserJournalsPage_RealFixture(t *testing.T) {
raw := loadFixture(t, "journals_listing_page1.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
entries, hasNext := parseUserJournalsPage(doc)
t.Logf("journals listing: %d entries, hasNext=%v", len(entries), hasNext)
if len(entries) == 0 && hasNext {
t.Error("journals listing fixture: zero entries but hasNext=true; pagination selector likely matched a header button")
}
for i, j := range entries {
if j.ID == 0 && j.Title == "" {
t.Errorf("journals entry %d: both ID and Title empty", i)
}
}
}
// TestParseComments_JournalRealFixture confirms the comment parser works
// on /journal/ pages, not just /view/ pages.
func TestParseComments_JournalRealFixture(t *testing.T) {
raw := loadFixture(t, "comments_journal.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
cs := parseComments(doc)
t.Logf("journal comments: %d", len(cs))
for i, c := range cs {
if c.Depth < 0 {
t.Errorf("journal comment %d: negative depth %d", i, c.Depth)
}
}
}
// TestParseSearchResults_RealFixture verifies parseSearchResults against
// the captured /search/?q=dragon fixture. Expects ~72 items, hasNext=true
// (there are over a million dragon submissions), and per-item Author
// populated from the figcaption.
func TestParseSearchResults_RealFixture(t *testing.T) {
raw := loadFixture(t, "search_results.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
items, hasNext := parseSearchResults(doc, false)
t.Logf("search: %d items, hasNext=%v", len(items), hasNext)
if len(items) == 0 {
t.Fatal("search fixture: no items parsed")
}
if len(items) < 50 {
t.Errorf("search fixture: only %d items parsed; expected ~72 selector drift?", len(items))
}
if !hasNext {
t.Error("search fixture: hasNext = false; pagination Next anchor not detected")
}
withAuthor := 0
for _, it := range items {
if it.Author.Name != "" {
withAuthor++
}
}
if withAuthor < len(items)/2 {
t.Errorf("search: only %d/%d items have Author.Name", withAuthor, len(items))
}
}
// TestParseNotifications_RealFixture parses the captured /msg/others/ page.
// The captured account only has Journal notifications pending, so this
// test asserts journals are populated and the other categories at least
// don't crash (they come back as nil slices, which is the correct shape).
func TestParseNotifications_RealFixture(t *testing.T) {
raw := loadFixture(t, "msg_others.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
n, err := parseNotifications(doc)
if err != nil {
t.Fatalf("parseNotifications: %v", err)
}
t.Logf("notifications: journals=%d watches=%d subComments=%d journalComments=%d favs=%d shouts=%d",
len(n.Journals), len(n.Watches),
len(n.SubmissionComments), len(n.JournalComments),
len(n.Favorites), len(n.Shouts))
if len(n.Journals) == 0 {
t.Fatal("expected at least one Journal notification in fixture")
}
for i, j := range n.Journals {
if j.JournalID == 0 {
t.Errorf("journal[%d]: JournalID == 0", i)
}
if j.Title == "" {
t.Errorf("journal[%d]: empty Title", i)
}
if j.Author.Name == "" {
t.Errorf("journal[%d]: empty Author.Name", i)
}
if j.PostedAt.IsZero() {
t.Errorf("journal[%d]: zero PostedAt", i)
}
if j.Rating == "" {
t.Errorf("journal[%d]: empty Rating", i)
}
}
}
// TestParseNotesInboxPage_RealFixture parses /msg/pms/. We assert subject,
// sender, sent-at, and note id were extracted for each row.
func TestParseNotesInboxPage_RealFixture(t *testing.T) {
raw := loadFixture(t, "msg_pms.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
notes, nextURL := parseNotesInboxPage(doc)
t.Logf("notes inbox: %d items, nextURL=%q", len(notes), nextURL)
if len(notes) == 0 {
t.Fatal("expected at least one note in inbox fixture")
}
deletedSenders := 0
for i, np := range notes {
if np.ID == 0 {
t.Errorf("note[%d]: ID == 0 (href=%q)", i, np.ThreadURL)
}
if np.Subject == "" {
t.Errorf("note[%d]: empty Subject", i)
}
// FA renders notes from removed accounts with no usernameBlock and a
// [deleted] sentinel; that's expected. Count and don't fail.
if np.Sender.Name == "" {
if np.Sender.DisplayName == "[deleted]" {
deletedSenders++
} else {
t.Errorf("note[%d]: empty Sender.Name (Display=%q)", i, np.Sender.DisplayName)
}
}
if np.SentAt.IsZero() {
t.Errorf("note[%d]: zero SentAt", i)
}
}
if deletedSenders > 0 {
t.Logf("notes inbox: %d/%d items had deleted senders", deletedSenders, len(notes))
}
}
// TestParseNote_RealFixture parses /viewmessage/{id}/ and asserts subject,
// from, to, and body are populated.
func TestParseNote_RealFixture(t *testing.T) {
raw := loadFixture(t, "note_view.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
n, err := parseNote(0, doc)
if err != nil {
t.Fatalf("parseNote: %v", err)
}
if n.Subject == "" {
t.Error("note: empty Subject")
}
if n.From.Name == "" {
t.Error("note: empty From.Name")
}
if n.To.Name == "" {
t.Error("note: empty To.Name")
}
if n.BodyText == "" {
t.Error("note: empty BodyText")
}
if n.SentAt.IsZero() {
t.Error("note: zero SentAt")
}
t.Logf("note: subject=%q from=%s to=%s body-len=%d", n.Subject, n.From.Name, n.To.Name, len(n.BodyText))
}
// TestParseSubmissionInboxPage_RealFixture parses the captured
// /msg/submissions/ page (the "new stuff from people you watch" feed) and
// asserts: items are extracted, dates are lifted from the date-divider,
// authors are populated from figcaptions, and the cursor link is found.
func TestParseSubmissionInboxPage_RealFixture(t *testing.T) {
raw := loadFixture(t, "msg_submissions.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
items, nextURL := parseSubmissionInboxPage(doc, false)
t.Logf("inbox: %d items, nextURL=%q", len(items), nextURL)
if len(items) == 0 {
t.Fatal("inbox fixture: no items parsed")
}
withAuthor := 0
withDate := 0
for _, it := range items {
if it.ID == 0 {
t.Errorf("inbox item: ID == 0")
}
if it.Author.Name != "" {
withAuthor++
}
if !it.PostedAt.IsZero() {
withDate++
}
}
if withAuthor < len(items)/2 {
t.Errorf("inbox: only %d/%d items have Author.Name", withAuthor, len(items))
}
if withDate < len(items)/2 {
t.Errorf("inbox: only %d/%d items have PostedAt group-date lift failing?", withDate, len(items))
}
if nextURL == "" {
t.Log("inbox: no cursor link found (fixture may be on the last page)")
} else if !strings.Contains(nextURL, "/msg/submissions/") {
t.Errorf("inbox: cursor href looks wrong: %q", nextURL)
}
}
// TestParseGalleryPage_BrowseRealFixture verifies parseGalleryPage works
// against FA's /browse/ feed the front-page firehose. Same figure[id^=sid-]
// structure as user galleries, plus a u-{name} class that encodes the
// artist; figcaption still carries the artist link too.
func TestParseGalleryPage_BrowseRealFixture(t *testing.T) {
raw := loadFixture(t, "browse.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
items, hasNext := parseGalleryPage(doc, false)
t.Logf("browse: %d items, hasNext=%v", len(items), hasNext)
if len(items) == 0 {
t.Fatal("browse fixture: no items parsed")
}
// FA serves 72 per page by default; assert we got close to that so we
// notice if a selector starts dropping silently.
if len(items) < 50 {
t.Errorf("browse fixture: only %d items parsed; expected ~72 selector drift?", len(items))
}
withAuthor := 0
for _, it := range items {
if it.Author.Name != "" {
withAuthor++
}
}
if withAuthor < len(items)/2 {
t.Errorf("browse: only %d/%d items have Author.Name", withAuthor, len(items))
}
}
// TestClassifySystemMessage_NotFoundRealFixture pins the not-found
// classifier against FA's real System Error template captured by visiting
// a non-existent submission ID.
func TestClassifySystemMessage_NotFoundRealFixture(t *testing.T) {
raw := loadFixture(t, "system_message_not_found.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
got := classifySystemMessage(doc)
if !errors.Is(got, ErrNotFound) {
t.Fatalf("classifySystemMessage = %v; want ErrNotFound", got)
}
}
// Sanity helper: list the document title to help diagnose fixtures whose
// content shape doesn't match parser expectations. Useful when adding new
// fixtures and triaging which selector to update.
func docTitleFor(t *testing.T, name string) string {
t.Helper()
raw := loadFixture(t, name)
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
return ""
}
return strings.TrimSpace(doc.Find("title").First().Text())
}
// TestFixtureTitles is a non-failing diagnostic that prints the <title> of
// every captured fixture. Skipped silently when no fixtures are present.
func TestFixtureTitles(t *testing.T) {
names := []string{
"submission.html", "submission_story.html",
"user.html", "user_with_shouts.html",
"gallery_page1.html", "gallery_page_last.html",
"scraps_page1.html", "favorites_page1.html",
"journals_listing_page1.html", "journal.html",
"comments_submission.html", "comments_journal.html",
"system_message_not_found.html",
"msg_submissions.html", "msg_others.html", "msg_pms.html", "note_view.html",
"search_results.html", "browse.html",
}
for _, n := range names {
t.Run(n, func(t *testing.T) {
title := docTitleFor(t, n) // t.Skip fires inside if missing
t.Logf("title: %s", title)
})
}
}

218
search.go Normal file
View File

@@ -0,0 +1,218 @@
package fa
import (
"context"
"iter"
"net/url"
"strconv"
"time"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// SearchType is the submission media type filter on /search/. FA's form
// emits a separate `type-{name}=1` checkbox per type; this enum maps to
// those field names verbatim.
type SearchType string
const (
SearchTypeArt SearchType = "art"
SearchTypeMusic SearchType = "music"
SearchTypeFlash SearchType = "flash"
SearchTypeStory SearchType = "story"
SearchTypePhoto SearchType = "photo"
SearchTypePoetry SearchType = "poetry"
)
// SearchMode controls how FA interprets the query string: AND all terms,
// OR any term, or its "extended" syntax (the default in the web UI).
type SearchMode string
const (
SearchModeAll SearchMode = "all"
SearchModeAny SearchMode = "any"
SearchModeExtended SearchMode = "extended"
)
// SearchOrder is the result-sort key.
type SearchOrder string
const (
SearchOrderRelevancy SearchOrder = "relevancy"
SearchOrderDate SearchOrder = "date"
SearchOrderPopularity SearchOrder = "popularity"
)
// SearchRange is the "posted within" window. SearchRangeManual lets the
// caller specify [SearchOptions.RangeFrom] / [SearchOptions.RangeTo].
type SearchRange string
const (
SearchRange1Day SearchRange = "1day"
SearchRange3Days SearchRange = "3days"
SearchRange7Days SearchRange = "7days"
SearchRange30Days SearchRange = "30days"
SearchRange90Days SearchRange = "90days"
SearchRange1Year SearchRange = "1year"
SearchRange3Years SearchRange = "3years"
SearchRange5Years SearchRange = "5years"
SearchRangeAll SearchRange = "all"
SearchRangeManual SearchRange = "manual"
)
// SearchOptions is the full filter configuration for [Client.Search].
// Zero-value defaults match what the web UI shows: extended-mode query,
// relevancy-desc order, 5-year window, 72/page, all ratings and types
// enabled. Override any field to narrow the search.
type SearchOptions struct {
// Ratings restricts results to these rating levels. nil/empty = all
// three (general + mature + adult), matching the web default.
Ratings []Rating
// Types restricts results to these media types. nil/empty = all six.
Types []SearchType
// Mode selects all-words / any-words / extended. Empty defaults to
// extended (FA's web default).
Mode SearchMode
// OrderBy is the sort key. Empty defaults to relevancy.
OrderBy SearchOrder
// OrderAsc swaps order direction. Default is descending.
OrderAsc bool
// Range is the time window. Empty defaults to 5years (FA's default).
Range SearchRange
// RangeFrom / RangeTo are only consulted when Range == SearchRangeManual.
RangeFrom time.Time
RangeTo time.Time
// PerPage is the page size. Allowed values on the web UI are 24/36/48/60/72.
// Zero defaults to 72.
PerPage int
// StartPage is the 1-based page to begin iteration on. Zero/1 = first page.
StartPage int
// MaxPages bounds the number of pages the iterator will request. Zero
// = unbounded; iteration stops when FA serves an empty page or omits
// the "Next" link.
MaxPages int
}
// Search runs a /search/?q=... query and returns an iterator over the
// matching submissions. Each yielded *Submission carries ID, Title,
// Author, ThumbURL, and Rating from the search results page (call
// [Client.GetSubmission] with the ID for the full record).
//
// Pagination follows FA's "Next" anchor page-numbered GET, capped by
// [SearchOptions.MaxPages] when set.
//
// Search works anonymously for most queries; some adult-content searches
// require login and will surface as [ErrUnauthorized] via the system-
// message classifier.
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) iter.Seq2[*Submission, error] {
return func(yield func(*Submission, error) bool) {
page := opts.StartPage
if page < 1 {
page = 1
}
pagesFetched := 0
for {
if opts.MaxPages > 0 && pagesFetched >= opts.MaxPages {
return
}
pageURL := buildSearchURL(query, page, opts)
var (
items []*Submission
hasNext bool
)
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
items, hasNext = parseSearchResults(doc, c.cfg.jsonListings)
return nil
})
if err != nil {
yield(nil, err)
return
}
pagesFetched++
if len(items) == 0 {
return
}
for _, s := range items {
if !yield(s, nil) {
return
}
}
if !hasNext {
return
}
page++
}
}
}
// buildSearchURL constructs a /search/ URL with the query plus every
// non-default filter field the web UI sends. The form does method=GET so
// the encoded params land directly in the URL.
func buildSearchURL(query string, page int, opts SearchOptions) string {
v := url.Values{}
v.Set("q", query)
if page > 1 {
v.Set("page", strconv.Itoa(page))
}
if opts.PerPage > 0 {
v.Set("perpage", strconv.Itoa(opts.PerPage))
}
if opts.OrderBy != "" {
v.Set("order-by", string(opts.OrderBy))
}
if opts.OrderAsc {
v.Set("order-direction", "asc")
}
if opts.Mode != "" {
v.Set("mode", string(opts.Mode))
}
if opts.Range != "" {
v.Set("range", string(opts.Range))
}
if opts.Range == SearchRangeManual {
if !opts.RangeFrom.IsZero() {
v.Set("range_from", opts.RangeFrom.Format("2006-01-02"))
}
if !opts.RangeTo.IsZero() {
v.Set("range_to", opts.RangeTo.Format("2006-01-02"))
}
}
// Ratings: nil means all three are sent (FA's default state).
ratings := opts.Ratings
if len(ratings) == 0 {
ratings = []Rating{RatingGeneral, RatingMature, RatingAdult}
}
for _, r := range ratings {
switch r {
case RatingGeneral:
v.Set("rating-general", "1")
case RatingMature:
v.Set("rating-mature", "1")
case RatingAdult:
v.Set("rating-adult", "1")
}
}
// Types: nil means all six. Send only the ones the caller requested.
types := opts.Types
if len(types) == 0 {
types = []SearchType{SearchTypeArt, SearchTypeMusic, SearchTypeFlash, SearchTypeStory, SearchTypePhoto, SearchTypePoetry}
}
for _, t := range types {
v.Set("type-"+string(t), "1")
}
return urls.Host + "/search/?" + v.Encode()
}

38
search_parser.go Normal file
View File

@@ -0,0 +1,38 @@
package fa
import (
"strings"
"github.com/PuerkitoBio/goquery"
)
// parseSearchResults walks /search/ output, returning each result and
// whether a "Next" page link exists.
//
// FA renders results inside <section id="gallery-search-results"> with the
// same <figure id="sid-…"> shape as gallery/browse/inbox, so the per-item
// extraction reuses parseGalleryFigure. Pagination is page-numbered (GET
// ?page=N) and the Next anchor is in <div class="pagination"> at the top
// (and bottom) of the results.
//
// useJSON controls the experimental JSON-first merge see parseGalleryPage.
func parseSearchResults(doc *goquery.Document, useJSON bool) (items []*Submission, hasNext bool) {
var jsonData listingJSONMap
if useJSON {
jsonData = readListingJSON(doc)
}
doc.Find("section#gallery-search-results figure[id^=sid-]").Each(func(_ int, sel *goquery.Selection) {
if s := parseGalleryFigure(sel, jsonData); s != nil {
items = append(items, s)
}
})
doc.Find("div.pagination a.button").EachWithBreak(func(_ int, a *goquery.Selection) bool {
if strings.EqualFold(trimText(a), "next") {
hasNext = true
return false
}
return true
})
return items, hasNext
}

120
search_test.go Normal file
View File

@@ -0,0 +1,120 @@
package fa
import (
"net/url"
"strings"
"testing"
"time"
)
// TestBuildSearchURL_DefaultsSendAllRatingsAndTypes asserts that an empty
// SearchOptions encodes every rating and every type as enabled, matching
// FA's web-form default state.
func TestBuildSearchURL_DefaultsSendAllRatingsAndTypes(t *testing.T) {
got := buildSearchURL("dragon", 1, SearchOptions{})
u, err := url.Parse(got)
if err != nil {
t.Fatalf("parse: %v", err)
}
q := u.Query()
if q.Get("q") != "dragon" {
t.Errorf("q = %q", q.Get("q"))
}
for _, k := range []string{"rating-general", "rating-mature", "rating-adult"} {
if q.Get(k) != "1" {
t.Errorf("%s = %q; want 1", k, q.Get(k))
}
}
for _, k := range []string{"type-art", "type-music", "type-flash", "type-story", "type-photo", "type-poetry"} {
if q.Get(k) != "1" {
t.Errorf("%s = %q; want 1", k, q.Get(k))
}
}
if q.Get("page") != "" {
t.Errorf("page should be unset on page 1, got %q", q.Get("page"))
}
}
func TestBuildSearchURL_RatingsSubsetEncodesOnlyRequested(t *testing.T) {
got := buildSearchURL("x", 1, SearchOptions{Ratings: []Rating{RatingGeneral}})
q := mustQuery(t, got)
if q.Get("rating-general") != "1" {
t.Errorf("rating-general missing")
}
if q.Get("rating-mature") != "" || q.Get("rating-adult") != "" {
t.Errorf("non-requested ratings should be unset; got mature=%q adult=%q",
q.Get("rating-mature"), q.Get("rating-adult"))
}
}
func TestBuildSearchURL_TypesSubsetEncodesOnlyRequested(t *testing.T) {
got := buildSearchURL("x", 1, SearchOptions{Types: []SearchType{SearchTypeArt, SearchTypeStory}})
q := mustQuery(t, got)
for _, k := range []string{"type-art", "type-story"} {
if q.Get(k) != "1" {
t.Errorf("%s should be set", k)
}
}
for _, k := range []string{"type-music", "type-flash", "type-photo", "type-poetry"} {
if q.Get(k) != "" {
t.Errorf("%s should be unset; got %q", k, q.Get(k))
}
}
}
func TestBuildSearchURL_ManualRangeFormatsDates(t *testing.T) {
from := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
to := time.Date(2024, 6, 30, 0, 0, 0, 0, time.UTC)
got := buildSearchURL("x", 1, SearchOptions{
Range: SearchRangeManual,
RangeFrom: from,
RangeTo: to,
})
q := mustQuery(t, got)
if q.Get("range") != "manual" {
t.Errorf("range = %q", q.Get("range"))
}
if q.Get("range_from") != "2024-01-15" {
t.Errorf("range_from = %q", q.Get("range_from"))
}
if q.Get("range_to") != "2024-06-30" {
t.Errorf("range_to = %q", q.Get("range_to"))
}
}
func TestBuildSearchURL_OrderAndPage(t *testing.T) {
got := buildSearchURL("x", 3, SearchOptions{
OrderBy: SearchOrderDate,
OrderAsc: true,
PerPage: 48,
})
q := mustQuery(t, got)
if q.Get("page") != "3" {
t.Errorf("page = %q", q.Get("page"))
}
if q.Get("order-by") != "date" {
t.Errorf("order-by = %q", q.Get("order-by"))
}
if q.Get("order-direction") != "asc" {
t.Errorf("order-direction = %q", q.Get("order-direction"))
}
if q.Get("perpage") != "48" {
t.Errorf("perpage = %q", q.Get("perpage"))
}
}
func TestBuildSearchURL_RootPath(t *testing.T) {
got := buildSearchURL("x", 1, SearchOptions{})
if !strings.HasPrefix(got, "https://www.furaffinity.net/search/?") {
t.Errorf("URL prefix wrong: %s", got)
}
}
func mustQuery(t *testing.T, raw string) url.Values {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
return u.Query()
}

108
sfw_test.go Normal file
View File

@@ -0,0 +1,108 @@
package fa
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// TestSFW_CookieOnOutboundRequest sets up an httptest server that records
// the Cookie header it received, then asserts each SFW mode produces the
// expected `sfw=…` cookie value (or none, for SFWAuto).
func TestSFW_CookieOnOutboundRequest(t *testing.T) {
cases := []struct {
name string
mode SFWMode
wantSFW string // "" = expect no sfw cookie at all
}{
{"auto omits cookie", SFWAuto, ""},
{"on sets sfw=1", SFWOn, "1"},
{"off sets sfw=0", SFWOff, "0"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var seen string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("sfw"); err == nil {
seen = c.Value
}
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<html><body>
<div class="submission-title"><h2>OK</h2></div>
<div class="submission-description-artist">
<a href="/user/x/"><img class="submission-user-icon avatar" src="/a.png"/></a>
<div>
<div class="submission-title"><h2>OK</h2></div>
<div><span class="c-usernameBlockSimple__displayName" title="x">x</span></div>
</div>
</div>
<img id="submissionImg" src="/file.png" data-fullview-src="/file.png" data-preview-src="/thumb.png"/>
</body></html>`))
}))
defer srv.Close()
target, _ := url.Parse(srv.URL)
hc := &http.Client{
Transport: &rewritingRT{target: target, base: http.DefaultTransport},
Timeout: 5 * time.Second,
}
client := New(
WithHTTPClient(hc),
WithRateLimit(time.Microsecond, 16),
WithMaxRetries(0),
WithSFW(tc.mode),
)
if _, err := client.GetSubmission(context.Background(), 1); err != nil {
t.Fatalf("GetSubmission: %v", err)
}
if seen != tc.wantSFW {
t.Errorf("sfw cookie sent = %q; want %q", seen, tc.wantSFW)
}
})
}
}
// TestSFW_CookieDoesNotClobberAuth confirms that enabling SFW does not
// wipe out the a/b session cookies on the same jar both should ride on
// the same request.
func TestSFW_CookieDoesNotClobberAuth(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("Cookie")
_, _ = w.Write([]byte(`<html><body>
<div class="submission-description-artist">
<div><div class="submission-title"><h2>T</h2></div></div>
</div></body></html>`))
}))
defer srv.Close()
target, _ := url.Parse(srv.URL)
hc := &http.Client{
Transport: &rewritingRT{target: target, base: http.DefaultTransport},
Timeout: 5 * time.Second,
}
client := New(
WithHTTPClient(hc),
WithRateLimit(time.Microsecond, 16),
WithMaxRetries(0),
WithCookies(Cookies{A: "aaa", B: "bbb"}),
WithSFW(SFWOn),
)
if _, err := client.GetSubmission(context.Background(), 1); err != nil {
t.Fatalf("GetSubmission: %v", err)
}
for _, want := range []string{"a=aaa", "b=bbb", "sfw=1"} {
if !strings.Contains(got, want) {
t.Errorf("Cookie header %q missing %q", got, want)
}
}
}

98
submission.go Normal file
View File

@@ -0,0 +1,98 @@
package fa
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// Submission is a fully resolved FA submission as seen on /view/{id}/.
type Submission struct {
ID SubmissionID
Title string
Author UserRef
PostedAt time.Time
Rating Rating
Category Category
Type Type
Species Species
Gender Gender
Description string // raw HTML; sanitise before rendering to a browser
DescriptionText string // plaintext convenience
Tags []string
FileURL string // absolute CDN URL; pass to Download
ThumbURL string
Width int // 0 if unknown / non-image
Height int
Stats SubmissionStats
Folders []FolderRef
Prev SubmissionID // 0 if this is the oldest in the gallery
Next SubmissionID // 0 if this is the newest
// Favorited reports whether the authenticated viewer has favorited this
// submission. It is true only when the page was fetched with valid
// cookies and FA rendered the "Fav" (/unfav/) link. An anonymous fetch
// always yields false.
Favorited bool
}
// GetSubmission fetches the submission with the given numeric ID.
// Returns [ErrNotFound] if FA renders a "submission not found" system message,
// [ErrUnauthorized] for restricted-visibility submissions when called
// without valid cookies, or a wrapped parse error if the markup has shifted.
func (c *Client) GetSubmission(ctx context.Context, id SubmissionID) (*Submission, error) {
if id <= 0 {
return nil, fmt.Errorf("fa: GetSubmission: id must be > 0")
}
var out *Submission
err := c.fetch(ctx, urls.Submission(int64(id)), func(doc *goquery.Document) error {
s, err := parseSubmission(id, doc)
if err != nil {
return err
}
out = s
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// Download streams the submission's main file from the CDN into w. The same
// rate limiter that paces /view/ fetches paces CDN fetches, so an in-flight
// gallery iteration will yield correctly when Download is interleaved.
//
// Returns the number of bytes written. Errors from the writer are wrapped
// as-is; HTTP errors come back as [*HTTPError].
func (c *Client) Download(ctx context.Context, sub *Submission, w io.Writer) (int64, error) {
if sub == nil {
return 0, errors.New("fa: Download: nil submission")
}
if sub.FileURL == "" {
return 0, errors.New("fa: Download: submission has no FileURL")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sub.FileURL, nil)
if err != nil {
return 0, err
}
// CDN fetches share the same rate-limited transport as page fetches —
// see RoundTrip in transport.go where the limiter gates every request.
resp, err := c.http.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
return 0, &HTTPError{StatusCode: resp.StatusCode, URL: sub.FileURL}
}
return io.Copy(w, resp.Body)
}

266
submission_parser.go Normal file
View File

@@ -0,0 +1,266 @@
package fa
import (
"fmt"
"strconv"
"strings"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// parseSubmission lifts a [Submission] out of a /view/{id}/ document. The
// selectors target FA's beta theme as captured in testdata/html/submission.html.
//
// FA's beta page renders submission metadata as two parallel <span> columns
// inside .submission-content-stats labels in the first, values in the
// second so this parser pairs them up positionally rather than scanning
// label-then-value rows.
func parseSubmission(id SubmissionID, doc *goquery.Document) (*Submission, error) {
s := &Submission{ID: id}
// Resolve the canonical ID from the og:url meta tag when caller passed 0
// (e.g. the real-fixture test). Lets the parser stand on its own.
if s.ID == 0 {
if og := trimAttr(doc.Find(`meta[property="og:url"]`).First(), "content"); og != "" {
if n := extractIntFromHref(og); n > 0 {
s.ID = SubmissionID(n)
}
}
}
// Author scoping: there can be multiple .iconusername references on the
// page (e.g. inside the description). The submission's true author lives
// inside .submission-description-artist.
authorBox := doc.Find("div.submission-description-artist").First()
// Title.
s.Title = strings.TrimSpace(authorBox.Find("div.submission-title h2").First().Text())
if s.Title == "" {
s.Title = strings.TrimSpace(doc.Find("div.submission-title h2").First().Text())
}
if s.Title == "" {
// Surface what FA actually served so the caller can tell the
// difference between a CF challenge, an SFW guard, a deleted
// submission, and a real markup-drift bug.
pageTitle := strings.TrimSpace(doc.Find("title").First().Text())
return nil, fmt.Errorf("%w: submission %d: missing title (page <title>=%q)",
ErrParse, id, pageTitle)
}
// Author.
if authorBox.Length() > 0 {
avatarLink := authorBox.Find("a[href^='/user/'] img").First()
nameSpan := authorBox.Find(".c-usernameBlockSimple__displayName").First()
s.Author = UserRef{
DisplayName: trimText(nameSpan),
AvatarURL: urls.AbsoluteCDN(trimAttr(avatarLink, "src")),
}
// Prefer the title attr (URL-safe login) when present; fall back to href.
if t := strings.TrimSpace(trimAttr(nameSpan, "title")); t != "" {
s.Author.Name = strings.ToLower(t)
}
if s.Author.Name == "" {
href, _ := authorBox.Find("a[href^='/user/']").First().Attr("href")
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
s.Author.Name = strings.ToLower(parts[1])
}
}
}
// Posted date popup_date carries authoritative data-time.
s.PostedAt = parsePopupDate(authorBox.Find("span.popup_date").First())
if s.PostedAt.IsZero() {
s.PostedAt = parsePopupDate(doc.Find("span.popup_date").First())
}
// Rating div with class c-contentRating--{general,mature,adult} in the
// page stats panel; fall back to legacy .rating-box for older markup.
doc.Find("div.submission-page-stats div[class*='c-contentRating--']").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
s.Rating = ParseRating(trimText(sel))
return false
})
if s.Rating == "" {
doc.Find(".rating-box").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
s.Rating = ParseRating(trimText(sel))
return false
})
}
// Stats .submission-page-stats > div[title=...] each holds <div>N</div>
// <div class="highlight">Label</div>.
doc.Find("div.submission-page-stats > div[title]").Each(func(_ int, sel *goquery.Selection) {
title := strings.ToLower(trimAttr(sel, "title"))
val := parseStatNumber(trimText(sel.Find("div").First()))
switch title {
case "views":
s.Stats.Views = val
case "favorites":
s.Stats.Favorites = val
case "comments":
s.Stats.Comments = val
}
})
// Category / Theme / Species / Resolution / File Size are two parallel
// span columns inside .submission-content-stats. Pair them up by index.
statsBlock := doc.Find("div.submission-content-stats").First()
if statsBlock.Length() > 0 {
var labels []string
statsBlock.Find("span.highlight > span").Each(func(_ int, sel *goquery.Selection) {
labels = append(labels, strings.ToLower(strings.TrimSpace(sel.Text())))
})
var values []string
statsBlock.ChildrenFiltered("span").Each(func(_ int, sel *goquery.Selection) {
if class, _ := sel.Attr("class"); strings.Contains(class, "highlight") {
return // skip the labels column
}
sel.ChildrenFiltered("span").Each(func(_ int, inner *goquery.Selection) {
values = append(values, strings.TrimSpace(inner.Text()))
})
})
for i := 0; i < len(labels) && i < len(values); i++ {
switch labels[i] {
case "category":
s.Category = Category(values[i])
case "type", "theme":
s.Type = Type(values[i])
case "species":
s.Species = Species(values[i])
case "gender":
s.Gender = Gender(values[i])
case "resolution":
if w, h, ok := parseResolution(values[i]); ok {
s.Width, s.Height = w, h
}
}
}
}
// Description section.submission-description holds the body inside
// .section-body > .submission-description-text.
descBody := doc.Find("section.submission-description div.submission-description-text").First()
if descBody.Length() == 0 {
descBody = doc.Find("div.submission-description").First()
}
s.Description = htmlOf(descBody)
s.DescriptionText = strings.TrimSpace(descBody.Text())
// Tags anchors inside .submission-tags whose href targets the search.
// Tag-block helper anchors and invalid system tags are filtered out.
doc.Find("div.submission-tags span.tags a[href*='/search/']").Each(func(_ int, a *goquery.Selection) {
t := strings.TrimSpace(a.Text())
if t != "" {
s.Tags = append(s.Tags, t)
}
})
// File URL FA renders a "Download" button in #submission-options that
// links to the canonical file for *every* submission type. For visual
// art it equals the #submissionImg source; for stories and music it's
// the only correct source, because FA injects a generated thumbnail
// (e.g. ".thumbnail.<name>.docx.gif") into #submissionImg there. So the
// Download button is authoritative; #submissionImg is only a fallback.
doc.Find("div#submission-options a").EachWithBreak(func(_ int, a *goquery.Selection) bool {
if strings.EqualFold(trimText(a), "download") {
s.FileURL = urls.AbsoluteCDN(trimAttr(a, "href"))
return false
}
return true
})
// #submissionImg holds the inline image for visual art, or a generated
// thumbnail for non-image submissions. It always supplies ThumbURL, but
// only supplies FileURL when no Download button was found.
img := doc.Find("#submissionImg").First()
if img.Length() > 0 {
s.ThumbURL = urls.AbsoluteCDN(firstNonEmpty(
trimAttr(img, "data-preview-src"),
trimAttr(img, "src"),
))
if s.FileURL == "" {
s.FileURL = urls.AbsoluteCDN(firstNonEmpty(
trimAttr(img, "data-fullview-src"),
trimAttr(img, "src"),
))
}
// Dimensions also live in width/height attrs on some pages.
if w, err := strconv.Atoi(trimAttr(img, "data-fullview-width")); err == nil {
s.Width = w
}
if h, err := strconv.Atoi(trimAttr(img, "data-fullview-height")); err == nil {
s.Height = h
}
}
// Legacy fallback for older themes that predate #submission-options.
if s.FileURL == "" {
dl := doc.Find("div.submission-controls-upper a[href*='/d.furaffinity.net/'], div.download a, a.download-logged-in").First()
s.FileURL = urls.AbsoluteCDN(trimAttr(dl, "href"))
}
// Prev / Next (FA's minigallery-navigation: "Newer" / "Older").
doc.Find("div.minigallery-navigation a").Each(func(_ int, a *goquery.Selection) {
href, _ := a.Attr("href")
text := strings.ToLower(trimText(a))
id := SubmissionID(extractIntFromHref(href))
if id == 0 {
return
}
switch {
case strings.Contains(text, "newer"):
// "Newer" goes to a more recent submission surface as Prev so
// callers walking a gallery can call client.GetSubmission(s.Prev)
// to step toward the newest.
s.Prev = id
case strings.Contains(text, "older"):
s.Next = id
}
})
// Legacy fallback for older themes that still use favorite-nav.
if s.Prev == 0 && s.Next == 0 {
doc.Find("div.favorite-nav a, .submission-nav a").Each(func(_ int, a *goquery.Selection) {
href, _ := a.Attr("href")
text := strings.ToLower(trimText(a))
id := SubmissionID(extractIntFromHref(href))
if id == 0 {
return
}
switch {
case strings.Contains(text, "prev"):
s.Prev = id
case strings.Contains(text, "next"):
s.Next = id
}
})
}
// Favorite state FA renders exactly one of the "+Fav" / "Fav" anchors
// for an authenticated viewer; the "Fav" (/unfav/) link means this
// submission is currently favorited. findFavLinks (actions.go) already
// scrapes both. An anonymous fetch shows neither, leaving Favorited false.
if _, unfav := findFavLinks(doc, int64(s.ID)); unfav != "" {
s.Favorited = true
}
return s, nil
}
// parseResolution splits a "2071 x 1779" string into width and height ints.
// Returns ok=false on any malformed input so callers can leave Width/Height
// at zero.
func parseResolution(s string) (w, h int, ok bool) {
parts := strings.Split(s, "x")
if len(parts) != 2 {
return 0, 0, false
}
wn, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return 0, 0, false
}
hn, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return 0, 0, false
}
return wn, hn, true
}

188
submission_parser_test.go Normal file
View File

@@ -0,0 +1,188 @@
package fa
import (
"bytes"
"fmt"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
// syntheticSubmissionHTML is a minimal hand-rolled page that exercises every
// selector the parser cares about. Real FA HTML differs in ways that
// fixture-driven tests will catch; this synthetic input pins the parser
// against a stable, deterministic input independent of FA's mood.
const syntheticSubmissionHTML = `<html><body>
<meta property="og:url" content="https://www.furaffinity.net/view/1234/"/>
<section class="submission-description">
<div class="submission-description-header">
<div class="submission-description-artist">
<div><a href="/user/somefurry/"><img class="submission-user-icon avatar" src="//d.example/avatars/somefurry.png"/></a></div>
<div>
<div class="submission-title"><h2>My Test Submission</h2></div>
<div>by <span class="c-usernameBlockSimple"><a href="/user/somefurry/"><span class="c-usernameBlockSimple__displayName" title="somefurry">SomeFurry</span></a></span></div>
<div>Posted <span class="popup_date" title="March 17, 2026 04:21:21 PM">5 hours ago</span></div>
</div>
</div>
</div>
<div class="section-body">
<div class="submission-description-text"><p>Hello <b>world</b>.</p></div>
</div>
</section>
<img id="submissionImg" src="//d.example/art/somefurry/1234.png" data-fullview-src="//d.example/art/somefurry/1234_full.png" data-preview-src="//d.example/art/somefurry/1234_thumb.png" data-fullview-width="1920" data-fullview-height="1080"/>
<div class="submission-page-stats">
<div title="Views"><div>1,234</div><div class="highlight">Views</div></div>
<div title="Comments"><div>7</div><div class="highlight">Comments</div></div>
<div title="Favorites"><div>56</div><div class="highlight">Favorites</div></div>
<div><div class="font-large inline c-contentRating--general">General</div><div class="highlight">Rating</div></div>
</div>
<div class="submission-content-stats">
<span class="highlight">
<span>Category</span><span>Theme</span><span>Species</span><span>Gender</span><span>Resolution</span>
</span>
<span>
<span>Artwork (Digital)</span><span>Digital</span><span>Wolf</span><span>Male</span><span>1920 x 1080</span>
</span>
</div>
<div class="submission-tags">
<div class="highlight">Keywords</div>
<div>
<span class="tags"><span><a href="javascript:void(0);" class="tag-block"></a><a href="/search/@keywords wolf">wolf</a></span></span>
<span class="tags"><span><a href="javascript:void(0);" class="tag-block"></a><a href="/search/@keywords art">art</a></span></span>
</div>
</div>
<div class="minigallery-navigation">
<a href="/view/1235/">&laquo; Newer</a>
<a href="/view/1233/">Older &raquo;</a>
</div>
</body></html>`
func TestParseSubmission_Synthetic(t *testing.T) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticSubmissionHTML))
if err != nil {
t.Fatalf("setup: %v", err)
}
sub, err := parseSubmission(1234, doc)
if err != nil {
t.Fatalf("parseSubmission: %v", err)
}
checks := []struct {
name string
got any
want any
}{
{"ID", sub.ID, SubmissionID(1234)},
{"Title", sub.Title, "My Test Submission"},
{"Author.Name", sub.Author.Name, "somefurry"},
{"Author.DisplayName", sub.Author.DisplayName, "SomeFurry"},
{"Author.AvatarURL", sub.Author.AvatarURL, "https://d.example/avatars/somefurry.png"},
{"Rating", sub.Rating, RatingGeneral},
{"Category", sub.Category, Category("Artwork (Digital)")},
{"Type", sub.Type, Type("Digital")},
{"Species", sub.Species, Species("Wolf")},
{"Gender", sub.Gender, Gender("Male")},
{"FileURL", sub.FileURL, "https://d.example/art/somefurry/1234_full.png"},
{"ThumbURL", sub.ThumbURL, "https://d.example/art/somefurry/1234_thumb.png"},
{"Width", sub.Width, 1920},
{"Height", sub.Height, 1080},
{"Stats.Views", sub.Stats.Views, 1234},
{"Stats.Favorites", sub.Stats.Favorites, 56},
{"Stats.Comments", sub.Stats.Comments, 7},
{"Prev (Newer)", sub.Prev, SubmissionID(1235)},
{"Next (Older)", sub.Next, SubmissionID(1233)},
{"len(Tags)", len(sub.Tags), 2},
}
for _, c := range checks {
if c.got != c.want {
t.Errorf("%s = %v; want %v", c.name, c.got, c.want)
}
}
if !sub.PostedAt.IsZero() && sub.PostedAt.Year() != 2026 {
t.Errorf("PostedAt year = %d; want 2026", sub.PostedAt.Year())
}
if !strings.Contains(sub.Description, "world") {
t.Errorf("Description missing expected content: %q", sub.Description)
}
}
// TestParseSubmission_FavoritedState verifies parseSubmission reports the
// authenticated viewer's favorite state. FA renders exactly one of the
// "+Fav" (/fav/) or "Fav" (/unfav/) anchors, matching the viewer's current
// state; an anonymous fetch shows neither.
func TestParseSubmission_FavoritedState(t *testing.T) {
const tmpl = `<html><body>
<meta property="og:url" content="https://www.furaffinity.net/view/1234/"/>
<section class="submission-description"><div class="submission-description-header">
<div class="submission-description-artist"><div></div>
<div><div class="submission-title"><h2>T</h2></div></div></div></div></section>
<div id="submission-options">%s</div>
</body></html>`
cases := []struct {
name string
link string
want bool
}{
{"favorited shows unfav link", `<a href="/unfav/1234/?key=abc">Fav</a>`, true},
{"not favorited shows fav link", `<a href="/fav/1234/?key=abc">+Fav</a>`, false},
{"anonymous shows neither", ``, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(fmt.Sprintf(tmpl, c.link)))
if err != nil {
t.Fatalf("setup: %v", err)
}
sub, err := parseSubmission(1234, doc)
if err != nil {
t.Fatalf("parseSubmission: %v", err)
}
if sub.Favorited != c.want {
t.Errorf("Favorited = %v; want %v", sub.Favorited, c.want)
}
})
}
}
func TestParseSubmission_MissingTitleErrors(t *testing.T) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader("<html><body></body></html>"))
if err != nil {
t.Fatalf("setup: %v", err)
}
if _, err := parseSubmission(1, doc); err == nil {
t.Fatal("expected parse error for missing title")
}
}
// TestParseSubmission_RealFixture runs the parser against a real FA HTML
// dump captured by the `fixtures` build tag. Skips cleanly if no fixture
// has been recorded.
func TestParseSubmission_RealFixture(t *testing.T) {
raw := loadFixture(t, "submission.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
sub, err := parseSubmission(0, doc)
if err != nil {
t.Fatalf("parseSubmission(real): %v", err)
}
// We can't assert exact values against a fixture whose contents we don't
// pin in this repo. Instead assert that the load-bearing fields populated.
if sub.Title == "" {
t.Error("real fixture: Title is empty")
}
if sub.Author.Name == "" {
t.Error("real fixture: Author.Name is empty")
}
if sub.FileURL == "" {
t.Error("real fixture: FileURL is empty")
}
}

65
system_message.go Normal file
View File

@@ -0,0 +1,65 @@
package fa
import (
"strings"
"github.com/PuerkitoBio/goquery"
)
// classifySystemMessage looks at a fetched document and, when it is one of
// FA's gate/error templates rather than a real content page, maps it to the
// most specific sentinel error. Returns nil for normal content pages so
// parsing continues.
//
// FA emits at least two distinct gate templates:
//
// - <title>System Error</title> generic "not found / no permission /
// rate limited" page with the human message in a section-body.
// - <title>Login Required …</title> auth-gated content (some submissions
// are not viewable anonymously). Always maps to ErrUnauthorized.
//
// We classify by <title> first to pick a template, then by message text
// within the section-body when needed. Anything else is left for the
// downstream parser to handle.
func classifySystemMessage(doc *goquery.Document) error {
pageTitle := strings.ToLower(strings.TrimSpace(doc.Find("title").First().Text()))
switch {
case strings.HasPrefix(pageTitle, "login required"):
return ErrUnauthorized
case pageTitle == "system error",
strings.HasPrefix(pageTitle, "system error"):
// fall through to body classification below
default:
return nil
}
headerTitle := trimText(doc.Find("section .section-header h2").First())
if headerTitle == "" {
headerTitle = trimText(doc.Find("h2").First())
}
body := strings.TrimSpace(doc.Find("section .section-body").First().Text())
if body == "" {
body = strings.TrimSpace(doc.Find(".section-body").First().Text())
}
low := strings.ToLower(headerTitle + " " + body)
switch {
case strings.Contains(low, "not in our database"),
strings.Contains(low, "submission not found"),
strings.Contains(low, "user not found"),
strings.Contains(low, "journal not found"),
strings.Contains(low, "page not found"),
strings.Contains(low, "no such"):
return ErrNotFound
case strings.Contains(low, "you must be logged in"),
strings.Contains(low, "log in to view"),
strings.Contains(low, "permission to view"),
strings.Contains(low, "permission to access"):
return ErrUnauthorized
case strings.Contains(low, "rate limit"),
strings.Contains(low, "too many requests"):
return ErrRateLimited
}
return &SystemMessageError{Title: headerTitle, Body: body}
}

2150
testdata/html/browse.html vendored Normal file

File diff suppressed because one or more lines are too long

771
testdata/html/comments_journal.html vendored Normal file

File diff suppressed because one or more lines are too long

1049
testdata/html/comments_submission.html vendored Normal file

File diff suppressed because one or more lines are too long

457
testdata/html/favorites_page1.html vendored Normal file
View File

@@ -0,0 +1,457 @@
<!DOCTYPE html>
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>Favorites Gallery for SoXX-TheFennec -- Fur Affinity [dot] net</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
<meta name="distribution" content="global" />
<meta name="copyright" content="Frost Dragon Art LLC" />
<meta name="robots" content="noai, noimageai" />
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
<!-- generic -->
<meta name="robots" content="noindex" />
<!-- og -->
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<!-- twitter -->
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<script type="text/javascript">
var _faurl = {
d: '//d.furaffinity.net',
s: '//d.furaffinity.net/media/static',
a: '//a.furaffinity.net',
r: '//rv.furaffinity.net',
t: '//t.furaffinity.net',
};
</script>
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
<!-- browser hints -->
<link rel="preconnect" href="//t.furaffinity.net" />
<link rel="preconnect" href="//a.furaffinity.net" />
<link rel="preconnect" href="//rv.furaffinity.net" />
<link rel="preconnect" href="https://www15.smartadserver.com" />
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
<!-- Playwire Pre-connect <head> links -->
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
</head>
<!-- EU request: yes -->
<body class="c-bodyColor"
id="pageid-redirect" data-static-path="/themes/beta"
>
<script type="text/javascript">
0; // attempted fix for fouc in ff
</script>
<!-- sidebar -->
<div class="mobile-navigation">
<div class="mobile-nav-container">
<div class="mobile-nav-container-item left">
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
</div>
<div class="mobile-nav-container-item center">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-container-item right">
</div>
</div>
<div class="nav-ac-container">
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
<article class="nav-ac-content mobile-menu">
<div class="mobile-nav-content-container">
<div class="aligncenter">
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
<br />
</div>
<hr>
<h2><a href="/browse/">Browse</a></h2>
<h2><a href="/search/">Search</a></h2>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support &#x25BC;</h2></label>
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
<article class="nav-ac-content nav-ac-content-dropdown">
<a href="/journals/fender">News & Updates</a><br>
<a href="/journals/rednef">Events & Spotlight</a><br>
<a href="/help/">Help & Support</a><br>
<a href="/advertising.html">Advertising</a><br>
<a href="/blm">Black Lives Matter</a><br />
<a href="/changelog">Changelog</a><br />
<a href="/fight_spam">Internet Safety</a><br />
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a><br />
<a href="/route/banner_museum">Banner Museum</a><br />
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>SUPPORT FA</h3>
<a href="/plus/">Subscribe to FA+ </a><br>
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
<h3>RULES & POLICIES</h3>
<a href="/tos">Terms of Service</a><br>
<a href="/privacy">Privacy</a><br>
<a href="/coc">Code of Conduct</a><br>
<a href="/aup">Upload Policy</a>
<h3>SUPPORT</h3>
<a href="/help/#contact">Contact Us</a><br />
<a href="https://status.furaffinity.net/">Site Status</a>
</article>
</div>
<hr>
<h2><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</h2>
<h2></h2>
</div>
</article>
</div>
</div>
<nav id="ddmenu">
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
<div class="mobile-nav-logo">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
</div>
<div class="menu-icon"></div>
<ul class="navhideonmobile">
<li class="lileft">
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
<a class="top-heading" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
</li>
<li class="lileft">
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
<i class="caret"></i>
<div class="dropdown dropdown-left ">
<div class="dd-inner">
<div class="column">
<h3>Community</h3>
<a href="/journals/fender">News & Updates</a>
<a href="/journals/rednef">Events & Spotlight</a>
<a href="/help/">Help & Support</a>
<a href="/advertising.html">Advertising</a>
<a href="/blm/">Black Lives Matter</a>
<a href="/changelog">Changelog</a>
<a href="/fight_spam">Internet Safety</a>
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a>
<a href="/route/banner_museum">Banner Museum</a>
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>Rules & Policies</h3>
<a href="/tos">Terms of Service</a>
<a href="/privacy">Privacy</a>
<a href="/coc">Code of Conduct</a>
<a href="/aup">Upload Policy</a>
<h3>Support</h3>
<a href="/help/#contact">Contact Us</a>
<a href="https://status.furaffinity.net/">Site Status</a>
</div>
</div>
</div>
</li>
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
<div class="lileft hideonmobile">
<form id="searchbox" method="get" action="/search/">
<input type="search" name="q" placeholder="SEARCH">
<a href="/search">&nbsp;</a>
</form>
</div>
<li class="no-sub">
<span class="top-heading"><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</span>
</li>
</ul>
<script type="text/javascript">
_fajs.push(['init_sfw_button', '.sfw-toggle']);
</script>
</nav>
<script type="text/javascript">
_fajs.push(function(){
// all menus that should be opened only one at a time
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
var curr_input = $(evt.findElement('label').getAttribute('for'));
curr_input.next('.nav-ac-content').removeClassName('no-transition');
if(curr_input.checked === false) {
$$('.css-menu-toggle.only-one').each(function(elm){
var elm_input = $(elm.getAttribute('for'));
if(elm_input.checked === true) {
elm_input.next('.nav-ac-content').addClassName('no-transition');
elm_input.checked = false;
}
});
}
});
});
</script>
<div class="news-block">
</div>
<div id="main-window" class="footer-mobile-tweak g-wrapper">
<div id="header">
<!-- site banner -->
<site-banner >
<map name="banner-map">
<area
shape="rect"
coords="441,144,1042,197"
href="https://link.vgen.co/furaffinity"
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
target="_blank" />
</map>
<a href="/">
<picture>
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
</picture>
</a>
</site-banner>
<a name="top"></a>
</div>
<div id="site-content">
<!-- /header -->
<!-- {redirect} -->
<div id="standardpage">
<section class="aligncenter notice-message user-submitted-links">
<div class="section-body alignleft">
<h2>System Message</h2>
<div class="redirect-message"><p class="link-override">The owner of this page has elected to make it available to registered users only.<br />To view the contents of this page please <a href="/login?ref=%2Ffavorites%2Fsoxx-thefennec%2F">log in</a> or <a href="/register/">create an account</a>.</p></div>
<div class="proceed-btn-container">
<a class="button standard go" href="javascript: history.back(-1)">Continue &raquo;</a>
</div>
</div>
</section>
</div>
</div>
<!-- /<div id="site-content"> -->
<div id="footer">
<div class="auto_link footer-links">
<a href="/advertising">Advertise</a> |
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
<a href="https://status.furaffinity.net/">Site Status</a> |
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
<a href="/route/i_was_here">Memorial</a> |
<a href="/tos">Terms of Service</a> |
<a href="/privacy">Privacy</a> |
<a href="/coc">Code of Conduct</a> |
<a href="/aup">Upload Policy</a>
</div>
<div class="footerAds">
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
<div class="footerAds__column">
<div class="footerAds__slot footerAds__slot--faLogo">
<img src="/themes/beta/img/banners/fa_logo.png?v2">
</div>
</div>
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_right"></ins>
</div>
</div>
<div class="online-stats">
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
3334 <strong>guests</strong>,
8900 <strong>registered</strong>
and 76340 <strong>other</strong>
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
</div>
<small>Limit bot activity to periods with less than 10k registered users online.</small>
<br><br>
<strong>&copy; 2005-2026 Frost Dragon Art LLC</strong>
<div class="footnote">
Server Time: May 24, 2026 04:31 AM<br />
Page generated in 0.01 seconds<br />[ 25.4% PHP, 74.6% SQL ] (18 queries)<br />
</div>
</div>
</div>
<script type="text/javascript">
_fajs.push(function() {
var exists = getCookie('sz');
var saved = save_viewport_size();
if((!exists && saved) || (exists && saved && exists != saved)) {
//window.location.reload();
}
});
</script>
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
<script type="text/javascript">
var server_timestamp = 1779622303;
var client_timestamp = Date.now() / 1000;
var server_timestamp_delta = server_timestamp - client_timestamp;
var sfw_cookie_name = 'sfw';
var news_cookie_name = 'n';
//
document.addEventListener("DOMContentLoaded", (event) => {
//
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
});
</script>
<script type="text/javascript">
_fajs.push(function() {
var ddmenuOptions = {
menuId: "ddmenu",
linkIdToMenuHtml: null,
open: "onmouseover", // or "onclick"
delay: 1,
speed: 1,
keysNav: true,
license: "2c1f72"
};
var ddmenu = new Ddmenu(ddmenuOptions);
});
</script>
</body>
<!---
|\ /|
/_^ ^_\
\v/
The fox goes "moo!"
--->
</html>

1610
testdata/html/gallery_page1.html vendored Normal file

File diff suppressed because one or more lines are too long

1615
testdata/html/gallery_page_last.html vendored Normal file

File diff suppressed because one or more lines are too long

771
testdata/html/journal.html vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,458 @@
<!DOCTYPE html>
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>SoXX-TheFennec&#039;s Journals -- Fur Affinity [dot] net</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
<meta name="distribution" content="global" />
<meta name="copyright" content="Frost Dragon Art LLC" />
<meta name="robots" content="noai, noimageai" />
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
<!-- generic -->
<meta name="robots" content="noindex" />
<!-- og -->
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<!-- twitter -->
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<script type="text/javascript">
var _faurl = {
d: '//d.furaffinity.net',
s: '//d.furaffinity.net/media/static',
a: '//a.furaffinity.net',
r: '//rv.furaffinity.net',
t: '//t.furaffinity.net',
};
</script>
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
<!-- browser hints -->
<link rel="preconnect" href="//t.furaffinity.net" />
<link rel="preconnect" href="//a.furaffinity.net" />
<link rel="preconnect" href="//rv.furaffinity.net" />
<link rel="preconnect" href="https://www15.smartadserver.com" />
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
<!-- Playwire Pre-connect <head> links -->
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
</head>
<!-- EU request: yes -->
<body class="c-bodyColor"
id="pageid-redirect" data-static-path="/themes/beta"
>
<script type="text/javascript">
0; // attempted fix for fouc in ff
</script>
<!-- sidebar -->
<div class="mobile-navigation">
<div class="mobile-nav-container">
<div class="mobile-nav-container-item left">
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
</div>
<div class="mobile-nav-container-item center">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-container-item right">
</div>
</div>
<div class="nav-ac-container">
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
<article class="nav-ac-content mobile-menu">
<div class="mobile-nav-content-container">
<div class="aligncenter">
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
<br />
</div>
<hr>
<h2><a href="/browse/">Browse</a></h2>
<h2><a href="/search/">Search</a></h2>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support &#x25BC;</h2></label>
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
<article class="nav-ac-content nav-ac-content-dropdown">
<a href="/journals/fender">News & Updates</a><br>
<a href="/journals/rednef">Events & Spotlight</a><br>
<a href="/help/">Help & Support</a><br>
<a href="/advertising.html">Advertising</a><br>
<a href="/blm">Black Lives Matter</a><br />
<a href="/changelog">Changelog</a><br />
<a href="/fight_spam">Internet Safety</a><br />
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a><br />
<a href="/route/banner_museum">Banner Museum</a><br />
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>SUPPORT FA</h3>
<a href="/plus/">Subscribe to FA+ </a><br>
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
<h3>RULES & POLICIES</h3>
<a href="/tos">Terms of Service</a><br>
<a href="/privacy">Privacy</a><br>
<a href="/coc">Code of Conduct</a><br>
<a href="/aup">Upload Policy</a>
<h3>SUPPORT</h3>
<a href="/help/#contact">Contact Us</a><br />
<a href="https://status.furaffinity.net/">Site Status</a>
</article>
</div>
<hr>
<h2><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</h2>
<h2></h2>
</div>
</article>
</div>
</div>
<nav id="ddmenu">
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
<div class="mobile-nav-logo">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
</div>
<div class="menu-icon"></div>
<ul class="navhideonmobile">
<li class="lileft">
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
<a class="top-heading" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
</li>
<li class="lileft">
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
<i class="caret"></i>
<div class="dropdown dropdown-left ">
<div class="dd-inner">
<div class="column">
<h3>Community</h3>
<a href="/journals/fender">News & Updates</a>
<a href="/journals/rednef">Events & Spotlight</a>
<a href="/help/">Help & Support</a>
<a href="/advertising.html">Advertising</a>
<a href="/blm/">Black Lives Matter</a>
<a href="/changelog">Changelog</a>
<a href="/fight_spam">Internet Safety</a>
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a>
<a href="/route/banner_museum">Banner Museum</a>
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>Rules & Policies</h3>
<a href="/tos">Terms of Service</a>
<a href="/privacy">Privacy</a>
<a href="/coc">Code of Conduct</a>
<a href="/aup">Upload Policy</a>
<h3>Support</h3>
<a href="/help/#contact">Contact Us</a>
<a href="https://status.furaffinity.net/">Site Status</a>
</div>
</div>
</div>
</li>
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
<div class="lileft hideonmobile">
<form id="searchbox" method="get" action="/search/">
<input type="search" name="q" placeholder="SEARCH">
<a href="/search">&nbsp;</a>
</form>
</div>
<li class="no-sub">
<span class="top-heading"><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</span>
</li>
</ul>
<script type="text/javascript">
_fajs.push(['init_sfw_button', '.sfw-toggle']);
</script>
</nav>
<script type="text/javascript">
_fajs.push(function(){
// all menus that should be opened only one at a time
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
var curr_input = $(evt.findElement('label').getAttribute('for'));
curr_input.next('.nav-ac-content').removeClassName('no-transition');
if(curr_input.checked === false) {
$$('.css-menu-toggle.only-one').each(function(elm){
var elm_input = $(elm.getAttribute('for'));
if(elm_input.checked === true) {
elm_input.next('.nav-ac-content').addClassName('no-transition');
elm_input.checked = false;
}
});
}
});
});
</script>
<div class="news-block">
</div>
<div id="main-window" class="footer-mobile-tweak g-wrapper">
<div id="header">
<!-- site banner -->
<site-banner >
<map name="banner-map">
<area
shape="rect"
coords="441,144,1042,197"
href="https://link.vgen.co/furaffinity"
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
target="_blank" />
</map>
<a href="/">
<picture>
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
</picture>
</a>
</site-banner>
<a name="top"></a>
</div>
<div id="site-content">
<!-- /header -->
<!-- {redirect} -->
<div id="standardpage">
<section class="aligncenter notice-message user-submitted-links">
<div class="section-body alignleft">
<h2>System Message</h2>
<div class="redirect-message"><p class="link-override">The owner of this page has elected to make it available to registered users only.<br />To view the contents of this page please <a href="/login?ref=%2Fjournals%2Fsoxx-thefennec%2F">log in</a> or <a href="/register/">create an account</a>.</p></div>
<div class="proceed-btn-container">
<a class="button standard go" href="javascript: history.back(-1)">Continue &raquo;</a>
</div>
</div>
</section>
</div>
</div>
<!-- /<div id="site-content"> -->
<div id="footer">
<div class="auto_link footer-links">
<a href="/advertising">Advertise</a> |
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
<a href="https://status.furaffinity.net/">Site Status</a> |
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
<a href="/route/i_was_here">Memorial</a> |
<a href="/tos">Terms of Service</a> |
<a href="/privacy">Privacy</a> |
<a href="/coc">Code of Conduct</a> |
<a href="/aup">Upload Policy</a>
</div>
<div class="footerAds">
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
<div class="footerAds__column">
<div class="footerAds__slot footerAds__slot--faLogo">
<img src="/themes/beta/img/banners/fa_logo.png?v2">
</div>
</div>
<div class="footerAds__column">
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_top"></ins>
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_bottom"></ins>
</div>
</div>
<div class="online-stats">
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
3334 <strong>guests</strong>,
8900 <strong>registered</strong>
and 76340 <strong>other</strong>
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
</div>
<small>Limit bot activity to periods with less than 10k registered users online.</small>
<br><br>
<strong>&copy; 2005-2026 Frost Dragon Art LLC</strong>
<div class="footnote">
Server Time: May 24, 2026 04:31 AM<br />
Page generated in 0.011 seconds<br />[ 27.3% PHP, 72.7% SQL ] (18 queries)<br />
</div>
</div>
</div>
<script type="text/javascript">
_fajs.push(function() {
var exists = getCookie('sz');
var saved = save_viewport_size();
if((!exists && saved) || (exists && saved && exists != saved)) {
//window.location.reload();
}
});
</script>
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
<script type="text/javascript">
var server_timestamp = 1779622304;
var client_timestamp = Date.now() / 1000;
var server_timestamp_delta = server_timestamp - client_timestamp;
var sfw_cookie_name = 'sfw';
var news_cookie_name = 'n';
//
document.addEventListener("DOMContentLoaded", (event) => {
//
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
});
</script>
<script type="text/javascript">
_fajs.push(function() {
var ddmenuOptions = {
menuId: "ddmenu",
linkIdToMenuHtml: null,
open: "onmouseover", // or "onclick"
delay: 1,
speed: 1,
keysNav: true,
license: "2c1f72"
};
var ddmenu = new Ddmenu(ddmenuOptions);
});
</script>
</body>
<!---
|\ /|
/_^ ^_\
\v/
The fox goes "moo!"
--->
</html>

456
testdata/html/msg_others.html vendored Normal file
View File

@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>User control panel -- Fur Affinity [dot] net</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
<meta name="distribution" content="global" />
<meta name="copyright" content="Frost Dragon Art LLC" />
<meta name="robots" content="noai, noimageai" />
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
<!-- og -->
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<!-- twitter -->
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<script type="text/javascript">
var _faurl = {
d: '//d.furaffinity.net',
s: '//d.furaffinity.net/media/static',
a: '//a.furaffinity.net',
r: '//rv.furaffinity.net',
t: '//t.furaffinity.net',
};
</script>
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
<!-- browser hints -->
<link rel="preconnect" href="//t.furaffinity.net" />
<link rel="preconnect" href="//a.furaffinity.net" />
<link rel="preconnect" href="//rv.furaffinity.net" />
<link rel="preconnect" href="https://www15.smartadserver.com" />
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
<!-- Playwire Pre-connect <head> links -->
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
</head>
<!-- EU request: yes -->
<body class="c-bodyColor"
id="pageid-redirect" data-static-path="/themes/beta"
>
<script type="text/javascript">
0; // attempted fix for fouc in ff
</script>
<!-- sidebar -->
<div class="mobile-navigation">
<div class="mobile-nav-container">
<div class="mobile-nav-container-item left">
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
</div>
<div class="mobile-nav-container-item center">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-container-item right">
</div>
</div>
<div class="nav-ac-container">
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
<article class="nav-ac-content mobile-menu">
<div class="mobile-nav-content-container">
<div class="aligncenter">
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
<br />
</div>
<hr>
<h2><a href="/browse/">Browse</a></h2>
<h2><a href="/search/">Search</a></h2>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support &#x25BC;</h2></label>
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
<article class="nav-ac-content nav-ac-content-dropdown">
<a href="/journals/fender">News & Updates</a><br>
<a href="/journals/rednef">Events & Spotlight</a><br>
<a href="/help/">Help & Support</a><br>
<a href="/advertising.html">Advertising</a><br>
<a href="/blm">Black Lives Matter</a><br />
<a href="/changelog">Changelog</a><br />
<a href="/fight_spam">Internet Safety</a><br />
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a><br />
<a href="/route/banner_museum">Banner Museum</a><br />
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>SUPPORT FA</h3>
<a href="/plus/">Subscribe to FA+ </a><br>
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
<h3>RULES & POLICIES</h3>
<a href="/tos">Terms of Service</a><br>
<a href="/privacy">Privacy</a><br>
<a href="/coc">Code of Conduct</a><br>
<a href="/aup">Upload Policy</a>
<h3>SUPPORT</h3>
<a href="/help/#contact">Contact Us</a><br />
<a href="https://status.furaffinity.net/">Site Status</a>
</article>
</div>
<hr>
<h2><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</h2>
<h2></h2>
</div>
</article>
</div>
</div>
<nav id="ddmenu">
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
<div class="mobile-nav-logo">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
</div>
<div class="menu-icon"></div>
<ul class="navhideonmobile">
<li class="lileft">
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
<a class="top-heading" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
</li>
<li class="lileft">
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
<i class="caret"></i>
<div class="dropdown dropdown-left ">
<div class="dd-inner">
<div class="column">
<h3>Community</h3>
<a href="/journals/fender">News & Updates</a>
<a href="/journals/rednef">Events & Spotlight</a>
<a href="/help/">Help & Support</a>
<a href="/advertising.html">Advertising</a>
<a href="/blm/">Black Lives Matter</a>
<a href="/changelog">Changelog</a>
<a href="/fight_spam">Internet Safety</a>
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a>
<a href="/route/banner_museum">Banner Museum</a>
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>Rules & Policies</h3>
<a href="/tos">Terms of Service</a>
<a href="/privacy">Privacy</a>
<a href="/coc">Code of Conduct</a>
<a href="/aup">Upload Policy</a>
<h3>Support</h3>
<a href="/help/#contact">Contact Us</a>
<a href="https://status.furaffinity.net/">Site Status</a>
</div>
</div>
</div>
</li>
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
<div class="lileft hideonmobile">
<form id="searchbox" method="get" action="/search/">
<input type="search" name="q" placeholder="SEARCH">
<a href="/search">&nbsp;</a>
</form>
</div>
<li class="no-sub">
<span class="top-heading"><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</span>
</li>
</ul>
<script type="text/javascript">
_fajs.push(['init_sfw_button', '.sfw-toggle']);
</script>
</nav>
<script type="text/javascript">
_fajs.push(function(){
// all menus that should be opened only one at a time
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
var curr_input = $(evt.findElement('label').getAttribute('for'));
curr_input.next('.nav-ac-content').removeClassName('no-transition');
if(curr_input.checked === false) {
$$('.css-menu-toggle.only-one').each(function(elm){
var elm_input = $(elm.getAttribute('for'));
if(elm_input.checked === true) {
elm_input.next('.nav-ac-content').addClassName('no-transition');
elm_input.checked = false;
}
});
}
});
});
</script>
<div class="news-block">
</div>
<div id="main-window" class="footer-mobile-tweak g-wrapper">
<div id="header">
<!-- site banner -->
<site-banner >
<map name="banner-map">
<area
shape="rect"
coords="441,144,1042,197"
href="https://link.vgen.co/furaffinity"
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
target="_blank" />
</map>
<a href="/">
<picture>
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
</picture>
</a>
</site-banner>
<a name="top"></a>
</div>
<div id="site-content">
<!-- /header -->
<!-- {redirect} -->
<div id="standardpage">
<section class="aligncenter notice-message user-submitted-links">
<div class="section-body alignleft">
<h2>System Message</h2>
<div class="redirect-message">Please log in!</div>
<div class="proceed-btn-container">
<a class="button standard go" href="/login/">Continue &raquo;</a>
</div>
</div>
</section>
</div>
</div>
<!-- /<div id="site-content"> -->
<div id="footer">
<div class="auto_link footer-links">
<a href="/advertising">Advertise</a> |
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
<a href="https://status.furaffinity.net/">Site Status</a> |
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
<a href="/route/i_was_here">Memorial</a> |
<a href="/tos">Terms of Service</a> |
<a href="/privacy">Privacy</a> |
<a href="/coc">Code of Conduct</a> |
<a href="/aup">Upload Policy</a>
</div>
<div class="footerAds">
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
<div class="footerAds__column">
<div class="footerAds__slot footerAds__slot--faLogo">
<img src="/themes/beta/img/banners/fa_logo.png?v2">
</div>
</div>
<div class="footerAds__column">
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_top"></ins>
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_bottom"></ins>
</div>
</div>
<div class="online-stats">
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
3334 <strong>guests</strong>,
8900 <strong>registered</strong>
and 76340 <strong>other</strong>
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
</div>
<small>Limit bot activity to periods with less than 10k registered users online.</small>
<br><br>
<strong>&copy; 2005-2026 Frost Dragon Art LLC</strong>
<div class="footnote">
Server Time: May 24, 2026 04:31 AM<br />
Page generated in 0.005 seconds<br />[ 36.7% PHP, 63.3% SQL ] (9 queries)<br />
</div>
</div>
</div>
<script type="text/javascript">
_fajs.push(function() {
var exists = getCookie('sz');
var saved = save_viewport_size();
if((!exists && saved) || (exists && saved && exists != saved)) {
//window.location.reload();
}
});
</script>
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
<script type="text/javascript">
var server_timestamp = 1779622310;
var client_timestamp = Date.now() / 1000;
var server_timestamp_delta = server_timestamp - client_timestamp;
var sfw_cookie_name = 'sfw';
var news_cookie_name = 'n';
//
document.addEventListener("DOMContentLoaded", (event) => {
//
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
});
</script>
<script type="text/javascript">
_fajs.push(function() {
var ddmenuOptions = {
menuId: "ddmenu",
linkIdToMenuHtml: null,
open: "onmouseover", // or "onclick"
delay: 1,
speed: 1,
keysNav: true,
license: "2c1f72"
};
var ddmenu = new Ddmenu(ddmenuOptions);
});
</script>
</body>
<!---
|\ /|
/_^ ^_\
\v/
The fox goes "moo!"
--->
</html>

455
testdata/html/msg_pms.html vendored Normal file
View File

@@ -0,0 +1,455 @@
<!DOCTYPE html>
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>User control panel -- Fur Affinity [dot] net</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
<meta name="distribution" content="global" />
<meta name="copyright" content="Frost Dragon Art LLC" />
<meta name="robots" content="noai, noimageai" />
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
<!-- og -->
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<!-- twitter -->
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<script type="text/javascript">
var _faurl = {
d: '//d.furaffinity.net',
s: '//d.furaffinity.net/media/static',
a: '//a.furaffinity.net',
r: '//rv.furaffinity.net',
t: '//t.furaffinity.net',
};
</script>
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
<!-- browser hints -->
<link rel="preconnect" href="//t.furaffinity.net" />
<link rel="preconnect" href="//a.furaffinity.net" />
<link rel="preconnect" href="//rv.furaffinity.net" />
<link rel="preconnect" href="https://www15.smartadserver.com" />
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
<!-- Playwire Pre-connect <head> links -->
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
</head>
<!-- EU request: yes -->
<body class="c-bodyColor"
id="pageid-redirect" data-static-path="/themes/beta"
>
<script type="text/javascript">
0; // attempted fix for fouc in ff
</script>
<!-- sidebar -->
<div class="mobile-navigation">
<div class="mobile-nav-container">
<div class="mobile-nav-container-item left">
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
</div>
<div class="mobile-nav-container-item center">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-container-item right">
</div>
</div>
<div class="nav-ac-container">
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
<article class="nav-ac-content mobile-menu">
<div class="mobile-nav-content-container">
<div class="aligncenter">
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
<br />
</div>
<hr>
<h2><a href="/browse/">Browse</a></h2>
<h2><a href="/search/">Search</a></h2>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support &#x25BC;</h2></label>
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
<article class="nav-ac-content nav-ac-content-dropdown">
<a href="/journals/fender">News & Updates</a><br>
<a href="/journals/rednef">Events & Spotlight</a><br>
<a href="/help/">Help & Support</a><br>
<a href="/advertising.html">Advertising</a><br>
<a href="/blm">Black Lives Matter</a><br />
<a href="/changelog">Changelog</a><br />
<a href="/fight_spam">Internet Safety</a><br />
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a><br />
<a href="/route/banner_museum">Banner Museum</a><br />
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>SUPPORT FA</h3>
<a href="/plus/">Subscribe to FA+ </a><br>
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
<h3>RULES & POLICIES</h3>
<a href="/tos">Terms of Service</a><br>
<a href="/privacy">Privacy</a><br>
<a href="/coc">Code of Conduct</a><br>
<a href="/aup">Upload Policy</a>
<h3>SUPPORT</h3>
<a href="/help/#contact">Contact Us</a><br />
<a href="https://status.furaffinity.net/">Site Status</a>
</article>
</div>
<hr>
<h2><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</h2>
<h2></h2>
</div>
</article>
</div>
</div>
<nav id="ddmenu">
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
<div class="mobile-nav-logo">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
</div>
<div class="menu-icon"></div>
<ul class="navhideonmobile">
<li class="lileft">
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
<a class="top-heading" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
</li>
<li class="lileft">
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
<i class="caret"></i>
<div class="dropdown dropdown-left ">
<div class="dd-inner">
<div class="column">
<h3>Community</h3>
<a href="/journals/fender">News & Updates</a>
<a href="/journals/rednef">Events & Spotlight</a>
<a href="/help/">Help & Support</a>
<a href="/advertising.html">Advertising</a>
<a href="/blm/">Black Lives Matter</a>
<a href="/changelog">Changelog</a>
<a href="/fight_spam">Internet Safety</a>
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a>
<a href="/route/banner_museum">Banner Museum</a>
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>Rules & Policies</h3>
<a href="/tos">Terms of Service</a>
<a href="/privacy">Privacy</a>
<a href="/coc">Code of Conduct</a>
<a href="/aup">Upload Policy</a>
<h3>Support</h3>
<a href="/help/#contact">Contact Us</a>
<a href="https://status.furaffinity.net/">Site Status</a>
</div>
</div>
</div>
</li>
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
<div class="lileft hideonmobile">
<form id="searchbox" method="get" action="/search/">
<input type="search" name="q" placeholder="SEARCH">
<a href="/search">&nbsp;</a>
</form>
</div>
<li class="no-sub">
<span class="top-heading"><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</span>
</li>
</ul>
<script type="text/javascript">
_fajs.push(['init_sfw_button', '.sfw-toggle']);
</script>
</nav>
<script type="text/javascript">
_fajs.push(function(){
// all menus that should be opened only one at a time
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
var curr_input = $(evt.findElement('label').getAttribute('for'));
curr_input.next('.nav-ac-content').removeClassName('no-transition');
if(curr_input.checked === false) {
$$('.css-menu-toggle.only-one').each(function(elm){
var elm_input = $(elm.getAttribute('for'));
if(elm_input.checked === true) {
elm_input.next('.nav-ac-content').addClassName('no-transition');
elm_input.checked = false;
}
});
}
});
});
</script>
<div class="news-block">
</div>
<div id="main-window" class="footer-mobile-tweak g-wrapper">
<div id="header">
<!-- site banner -->
<site-banner >
<map name="banner-map">
<area
shape="rect"
coords="441,144,1042,197"
href="https://link.vgen.co/furaffinity"
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
target="_blank" />
</map>
<a href="/">
<picture>
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
</picture>
</a>
</site-banner>
<a name="top"></a>
</div>
<div id="site-content">
<!-- /header -->
<!-- {redirect} -->
<div id="standardpage">
<section class="aligncenter notice-message user-submitted-links">
<div class="section-body alignleft">
<h2>System Message</h2>
<div class="redirect-message">Please log in!</div>
<div class="proceed-btn-container">
<a class="button standard go" href="/login/">Continue &raquo;</a>
</div>
</div>
</section>
</div>
</div>
<!-- /<div id="site-content"> -->
<div id="footer">
<div class="auto_link footer-links">
<a href="/advertising">Advertise</a> |
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
<a href="https://status.furaffinity.net/">Site Status</a> |
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
<a href="/route/i_was_here">Memorial</a> |
<a href="/tos">Terms of Service</a> |
<a href="/privacy">Privacy</a> |
<a href="/coc">Code of Conduct</a> |
<a href="/aup">Upload Policy</a>
</div>
<div class="footerAds">
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
<div class="footerAds__column">
<div class="footerAds__slot footerAds__slot--faLogo">
<img src="/themes/beta/img/banners/fa_logo.png?v2">
</div>
</div>
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_right"></ins>
</div>
</div>
<div class="online-stats">
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
3334 <strong>guests</strong>,
8900 <strong>registered</strong>
and 76340 <strong>other</strong>
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
</div>
<small>Limit bot activity to periods with less than 10k registered users online.</small>
<br><br>
<strong>&copy; 2005-2026 Frost Dragon Art LLC</strong>
<div class="footnote">
Server Time: May 24, 2026 04:31 AM<br />
Page generated in 0.007 seconds<br />[ 26.6% PHP, 73.4% SQL ] (9 queries)<br />
</div>
</div>
</div>
<script type="text/javascript">
_fajs.push(function() {
var exists = getCookie('sz');
var saved = save_viewport_size();
if((!exists && saved) || (exists && saved && exists != saved)) {
//window.location.reload();
}
});
</script>
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
<script type="text/javascript">
var server_timestamp = 1779622311;
var client_timestamp = Date.now() / 1000;
var server_timestamp_delta = server_timestamp - client_timestamp;
var sfw_cookie_name = 'sfw';
var news_cookie_name = 'n';
//
document.addEventListener("DOMContentLoaded", (event) => {
//
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
});
</script>
<script type="text/javascript">
_fajs.push(function() {
var ddmenuOptions = {
menuId: "ddmenu",
linkIdToMenuHtml: null,
open: "onmouseover", // or "onclick"
delay: 1,
speed: 1,
keysNav: true,
license: "2c1f72"
};
var ddmenu = new Ddmenu(ddmenuOptions);
});
</script>
</body>
<!---
|\ /|
/_^ ^_\
\v/
The fox goes "moo!"
--->
</html>

455
testdata/html/msg_submissions.html vendored Normal file
View File

@@ -0,0 +1,455 @@
<!DOCTYPE html>
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>User control panel -- Fur Affinity [dot] net</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
<meta name="distribution" content="global" />
<meta name="copyright" content="Frost Dragon Art LLC" />
<meta name="robots" content="noai, noimageai" />
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
<!-- og -->
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<!-- twitter -->
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<script type="text/javascript">
var _faurl = {
d: '//d.furaffinity.net',
s: '//d.furaffinity.net/media/static',
a: '//a.furaffinity.net',
r: '//rv.furaffinity.net',
t: '//t.furaffinity.net',
};
</script>
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
<!-- browser hints -->
<link rel="preconnect" href="//t.furaffinity.net" />
<link rel="preconnect" href="//a.furaffinity.net" />
<link rel="preconnect" href="//rv.furaffinity.net" />
<link rel="preconnect" href="https://www15.smartadserver.com" />
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
<!-- Playwire Pre-connect <head> links -->
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
</head>
<!-- EU request: yes -->
<body class="c-bodyColor"
id="pageid-redirect" data-static-path="/themes/beta"
>
<script type="text/javascript">
0; // attempted fix for fouc in ff
</script>
<!-- sidebar -->
<div class="mobile-navigation">
<div class="mobile-nav-container">
<div class="mobile-nav-container-item left">
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
</div>
<div class="mobile-nav-container-item center">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-container-item right">
</div>
</div>
<div class="nav-ac-container">
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
<article class="nav-ac-content mobile-menu">
<div class="mobile-nav-content-container">
<div class="aligncenter">
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
<br />
</div>
<hr>
<h2><a href="/browse/">Browse</a></h2>
<h2><a href="/search/">Search</a></h2>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support &#x25BC;</h2></label>
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
<article class="nav-ac-content nav-ac-content-dropdown">
<a href="/journals/fender">News & Updates</a><br>
<a href="/journals/rednef">Events & Spotlight</a><br>
<a href="/help/">Help & Support</a><br>
<a href="/advertising.html">Advertising</a><br>
<a href="/blm">Black Lives Matter</a><br />
<a href="/changelog">Changelog</a><br />
<a href="/fight_spam">Internet Safety</a><br />
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a><br />
<a href="/route/banner_museum">Banner Museum</a><br />
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>SUPPORT FA</h3>
<a href="/plus/">Subscribe to FA+ </a><br>
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
<h3>RULES & POLICIES</h3>
<a href="/tos">Terms of Service</a><br>
<a href="/privacy">Privacy</a><br>
<a href="/coc">Code of Conduct</a><br>
<a href="/aup">Upload Policy</a>
<h3>SUPPORT</h3>
<a href="/help/#contact">Contact Us</a><br />
<a href="https://status.furaffinity.net/">Site Status</a>
</article>
</div>
<hr>
<h2><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</h2>
<h2></h2>
</div>
</article>
</div>
</div>
<nav id="ddmenu">
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
<div class="mobile-nav-logo">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
</div>
<div class="menu-icon"></div>
<ul class="navhideonmobile">
<li class="lileft">
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
<a class="top-heading" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
</li>
<li class="lileft">
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
<i class="caret"></i>
<div class="dropdown dropdown-left ">
<div class="dd-inner">
<div class="column">
<h3>Community</h3>
<a href="/journals/fender">News & Updates</a>
<a href="/journals/rednef">Events & Spotlight</a>
<a href="/help/">Help & Support</a>
<a href="/advertising.html">Advertising</a>
<a href="/blm/">Black Lives Matter</a>
<a href="/changelog">Changelog</a>
<a href="/fight_spam">Internet Safety</a>
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a>
<a href="/route/banner_museum">Banner Museum</a>
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>Rules & Policies</h3>
<a href="/tos">Terms of Service</a>
<a href="/privacy">Privacy</a>
<a href="/coc">Code of Conduct</a>
<a href="/aup">Upload Policy</a>
<h3>Support</h3>
<a href="/help/#contact">Contact Us</a>
<a href="https://status.furaffinity.net/">Site Status</a>
</div>
</div>
</div>
</li>
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
<div class="lileft hideonmobile">
<form id="searchbox" method="get" action="/search/">
<input type="search" name="q" placeholder="SEARCH">
<a href="/search">&nbsp;</a>
</form>
</div>
<li class="no-sub">
<span class="top-heading"><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</span>
</li>
</ul>
<script type="text/javascript">
_fajs.push(['init_sfw_button', '.sfw-toggle']);
</script>
</nav>
<script type="text/javascript">
_fajs.push(function(){
// all menus that should be opened only one at a time
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
var curr_input = $(evt.findElement('label').getAttribute('for'));
curr_input.next('.nav-ac-content').removeClassName('no-transition');
if(curr_input.checked === false) {
$$('.css-menu-toggle.only-one').each(function(elm){
var elm_input = $(elm.getAttribute('for'));
if(elm_input.checked === true) {
elm_input.next('.nav-ac-content').addClassName('no-transition');
elm_input.checked = false;
}
});
}
});
});
</script>
<div class="news-block">
</div>
<div id="main-window" class="footer-mobile-tweak g-wrapper">
<div id="header">
<!-- site banner -->
<site-banner >
<map name="banner-map">
<area
shape="rect"
coords="441,144,1042,197"
href="https://link.vgen.co/furaffinity"
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
target="_blank" />
</map>
<a href="/">
<picture>
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
</picture>
</a>
</site-banner>
<a name="top"></a>
</div>
<div id="site-content">
<!-- /header -->
<!-- {redirect} -->
<div id="standardpage">
<section class="aligncenter notice-message user-submitted-links">
<div class="section-body alignleft">
<h2>System Message</h2>
<div class="redirect-message">Please log in!</div>
<div class="proceed-btn-container">
<a class="button standard go" href="/login/">Continue &raquo;</a>
</div>
</div>
</section>
</div>
</div>
<!-- /<div id="site-content"> -->
<div id="footer">
<div class="auto_link footer-links">
<a href="/advertising">Advertise</a> |
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
<a href="https://status.furaffinity.net/">Site Status</a> |
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
<a href="/route/i_was_here">Memorial</a> |
<a href="/tos">Terms of Service</a> |
<a href="/privacy">Privacy</a> |
<a href="/coc">Code of Conduct</a> |
<a href="/aup">Upload Policy</a>
</div>
<div class="footerAds">
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
<div class="footerAds__column">
<div class="footerAds__slot footerAds__slot--faLogo">
<img src="/themes/beta/img/banners/fa_logo.png?v2">
</div>
</div>
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_right"></ins>
</div>
</div>
<div class="online-stats">
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
3334 <strong>guests</strong>,
8900 <strong>registered</strong>
and 76340 <strong>other</strong>
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
</div>
<small>Limit bot activity to periods with less than 10k registered users online.</small>
<br><br>
<strong>&copy; 2005-2026 Frost Dragon Art LLC</strong>
<div class="footnote">
Server Time: May 24, 2026 04:31 AM<br />
Page generated in 0.007 seconds<br />[ 26.7% PHP, 73.3% SQL ] (9 queries)<br />
</div>
</div>
</div>
<script type="text/javascript">
_fajs.push(function() {
var exists = getCookie('sz');
var saved = save_viewport_size();
if((!exists && saved) || (exists && saved && exists != saved)) {
//window.location.reload();
}
});
</script>
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
<script type="text/javascript">
var server_timestamp = 1779622309;
var client_timestamp = Date.now() / 1000;
var server_timestamp_delta = server_timestamp - client_timestamp;
var sfw_cookie_name = 'sfw';
var news_cookie_name = 'n';
//
document.addEventListener("DOMContentLoaded", (event) => {
//
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
});
</script>
<script type="text/javascript">
_fajs.push(function() {
var ddmenuOptions = {
menuId: "ddmenu",
linkIdToMenuHtml: null,
open: "onmouseover", // or "onclick"
delay: 1,
speed: 1,
keysNav: true,
license: "2c1f72"
};
var ddmenu = new Ddmenu(ddmenuOptions);
});
</script>
</body>
<!---
|\ /|
/_^ ^_\
\v/
The fox goes "moo!"
--->
</html>

456
testdata/html/note_view.html vendored Normal file
View File

@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>User control panel -- Fur Affinity [dot] net</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
<meta name="distribution" content="global" />
<meta name="copyright" content="Frost Dragon Art LLC" />
<meta name="robots" content="noai, noimageai" />
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
<!-- og -->
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<!-- twitter -->
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<script type="text/javascript">
var _faurl = {
d: '//d.furaffinity.net',
s: '//d.furaffinity.net/media/static',
a: '//a.furaffinity.net',
r: '//rv.furaffinity.net',
t: '//t.furaffinity.net',
};
</script>
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
<!-- browser hints -->
<link rel="preconnect" href="//t.furaffinity.net" />
<link rel="preconnect" href="//a.furaffinity.net" />
<link rel="preconnect" href="//rv.furaffinity.net" />
<link rel="preconnect" href="https://www15.smartadserver.com" />
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
<!-- Playwire Pre-connect <head> links -->
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
</head>
<!-- EU request: yes -->
<body class="c-bodyColor"
id="pageid-redirect" data-static-path="/themes/beta"
>
<script type="text/javascript">
0; // attempted fix for fouc in ff
</script>
<!-- sidebar -->
<div class="mobile-navigation">
<div class="mobile-nav-container">
<div class="mobile-nav-container-item left">
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
</div>
<div class="mobile-nav-container-item center">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-container-item right">
</div>
</div>
<div class="nav-ac-container">
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
<article class="nav-ac-content mobile-menu">
<div class="mobile-nav-content-container">
<div class="aligncenter">
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
<br />
</div>
<hr>
<h2><a href="/browse/">Browse</a></h2>
<h2><a href="/search/">Search</a></h2>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support &#x25BC;</h2></label>
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
<article class="nav-ac-content nav-ac-content-dropdown">
<a href="/journals/fender">News & Updates</a><br>
<a href="/journals/rednef">Events & Spotlight</a><br>
<a href="/help/">Help & Support</a><br>
<a href="/advertising.html">Advertising</a><br>
<a href="/blm">Black Lives Matter</a><br />
<a href="/changelog">Changelog</a><br />
<a href="/fight_spam">Internet Safety</a><br />
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a><br />
<a href="/route/banner_museum">Banner Museum</a><br />
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>SUPPORT FA</h3>
<a href="/plus/">Subscribe to FA+ </a><br>
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
<h3>RULES & POLICIES</h3>
<a href="/tos">Terms of Service</a><br>
<a href="/privacy">Privacy</a><br>
<a href="/coc">Code of Conduct</a><br>
<a href="/aup">Upload Policy</a>
<h3>SUPPORT</h3>
<a href="/help/#contact">Contact Us</a><br />
<a href="https://status.furaffinity.net/">Site Status</a>
</article>
</div>
<hr>
<h2><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</h2>
<h2></h2>
</div>
</article>
</div>
</div>
<nav id="ddmenu">
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
<div class="mobile-nav-logo">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
</div>
<div class="menu-icon"></div>
<ul class="navhideonmobile">
<li class="lileft">
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
<a class="top-heading" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
</li>
<li class="lileft">
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
<i class="caret"></i>
<div class="dropdown dropdown-left ">
<div class="dd-inner">
<div class="column">
<h3>Community</h3>
<a href="/journals/fender">News & Updates</a>
<a href="/journals/rednef">Events & Spotlight</a>
<a href="/help/">Help & Support</a>
<a href="/advertising.html">Advertising</a>
<a href="/blm/">Black Lives Matter</a>
<a href="/changelog">Changelog</a>
<a href="/fight_spam">Internet Safety</a>
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a>
<a href="/route/banner_museum">Banner Museum</a>
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>Rules & Policies</h3>
<a href="/tos">Terms of Service</a>
<a href="/privacy">Privacy</a>
<a href="/coc">Code of Conduct</a>
<a href="/aup">Upload Policy</a>
<h3>Support</h3>
<a href="/help/#contact">Contact Us</a>
<a href="https://status.furaffinity.net/">Site Status</a>
</div>
</div>
</div>
</li>
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
<div class="lileft hideonmobile">
<form id="searchbox" method="get" action="/search/">
<input type="search" name="q" placeholder="SEARCH">
<a href="/search">&nbsp;</a>
</form>
</div>
<li class="no-sub">
<span class="top-heading"><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</span>
</li>
</ul>
<script type="text/javascript">
_fajs.push(['init_sfw_button', '.sfw-toggle']);
</script>
</nav>
<script type="text/javascript">
_fajs.push(function(){
// all menus that should be opened only one at a time
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
var curr_input = $(evt.findElement('label').getAttribute('for'));
curr_input.next('.nav-ac-content').removeClassName('no-transition');
if(curr_input.checked === false) {
$$('.css-menu-toggle.only-one').each(function(elm){
var elm_input = $(elm.getAttribute('for'));
if(elm_input.checked === true) {
elm_input.next('.nav-ac-content').addClassName('no-transition');
elm_input.checked = false;
}
});
}
});
});
</script>
<div class="news-block">
</div>
<div id="main-window" class="footer-mobile-tweak g-wrapper">
<div id="header">
<!-- site banner -->
<site-banner >
<map name="banner-map">
<area
shape="rect"
coords="441,144,1042,197"
href="https://link.vgen.co/furaffinity"
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
target="_blank" />
</map>
<a href="/">
<picture>
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
</picture>
</a>
</site-banner>
<a name="top"></a>
</div>
<div id="site-content">
<!-- /header -->
<!-- {redirect} -->
<div id="standardpage">
<section class="aligncenter notice-message user-submitted-links">
<div class="section-body alignleft">
<h2>System Message</h2>
<div class="redirect-message">Please log in!</div>
<div class="proceed-btn-container">
<a class="button standard go" href="/login/">Continue &raquo;</a>
</div>
</div>
</section>
</div>
</div>
<!-- /<div id="site-content"> -->
<div id="footer">
<div class="auto_link footer-links">
<a href="/advertising">Advertise</a> |
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
<a href="https://status.furaffinity.net/">Site Status</a> |
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
<a href="/route/i_was_here">Memorial</a> |
<a href="/tos">Terms of Service</a> |
<a href="/privacy">Privacy</a> |
<a href="/coc">Code of Conduct</a> |
<a href="/aup">Upload Policy</a>
</div>
<div class="footerAds">
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
<div class="footerAds__column">
<div class="footerAds__slot footerAds__slot--faLogo">
<img src="/themes/beta/img/banners/fa_logo.png?v2">
</div>
</div>
<div class="footerAds__column">
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_top"></ins>
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_bottom"></ins>
</div>
</div>
<div class="online-stats">
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
3334 <strong>guests</strong>,
8900 <strong>registered</strong>
and 76340 <strong>other</strong>
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
</div>
<small>Limit bot activity to periods with less than 10k registered users online.</small>
<br><br>
<strong>&copy; 2005-2026 Frost Dragon Art LLC</strong>
<div class="footnote">
Server Time: May 24, 2026 04:31 AM<br />
Page generated in 0.009 seconds<br />[ 26.5% PHP, 73.5% SQL ] (9 queries)<br />
</div>
</div>
</div>
<script type="text/javascript">
_fajs.push(function() {
var exists = getCookie('sz');
var saved = save_viewport_size();
if((!exists && saved) || (exists && saved && exists != saved)) {
//window.location.reload();
}
});
</script>
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
<script type="text/javascript">
var server_timestamp = 1779622312;
var client_timestamp = Date.now() / 1000;
var server_timestamp_delta = server_timestamp - client_timestamp;
var sfw_cookie_name = 'sfw';
var news_cookie_name = 'n';
//
document.addEventListener("DOMContentLoaded", (event) => {
//
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
});
</script>
<script type="text/javascript">
_fajs.push(function() {
var ddmenuOptions = {
menuId: "ddmenu",
linkIdToMenuHtml: null,
open: "onmouseover", // or "onclick"
delay: 1,
speed: 1,
keysNav: true,
license: "2c1f72"
};
var ddmenu = new Ddmenu(ddmenuOptions);
});
</script>
</body>
<!---
|\ /|
/_^ ^_\
\v/
The fox goes "moo!"
--->
</html>

669
testdata/html/scraps_page1.html vendored Normal file
View File

@@ -0,0 +1,669 @@
<!DOCTYPE html>
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>Scraps Gallery for KazuCreations -- Fur Affinity [dot] net</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
<meta name="distribution" content="global" />
<meta name="copyright" content="Frost Dragon Art LLC" />
<meta name="robots" content="noai, noimageai" />
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
<!-- og -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Scraps Gallery for KazuCreations -- Fur Affinity [dot] net" />
<meta property="og:url" content="https://www.furaffinity.net/gallery/kazucreations/" />
<meta property="og:description" content="moved to featherworks.studio" />
<meta property="og:image" content="https://t.furaffinity.net/30549394@600-1550693644.jpg" />
<meta property="og:image:secure_url" content="https://t.furaffinity.net/30549394@600-1550693644.jpg" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:width" content="423" />
<meta property="og:image:height" content="600" />
<!-- twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:domain" content="furaffinity.net" />
<meta name="twitter:site" content="@furaffinity" />
<meta name="twitter:title" content="Scraps Gallery for KazuCreations -- Fur Affinity [dot] net" />
<meta name="twitter:description" content="moved to featherworks.studio" />
<meta name="twitter:url" content="https://www.furaffinity.net/gallery/kazucreations/" />
<meta name="twitter:image" content="https://t.furaffinity.net/30549394@600-1550693644.jpg" />
<meta name="twitter:label1" content="Submission Title" />
<meta name="twitter:data1" content="[NDS] #1 Octopus" />
<script type="text/javascript">
var _faurl = {
d: '//d.furaffinity.net',
s: '//d.furaffinity.net/media/static',
a: '//a.furaffinity.net',
r: '//rv.furaffinity.net',
t: '//t.furaffinity.net',
};
</script>
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
<!-- browser hints -->
<link rel="preconnect" href="//t.furaffinity.net" />
<link rel="preconnect" href="//a.furaffinity.net" />
<link rel="preconnect" href="//rv.furaffinity.net" />
<link rel="preconnect" href="https://www15.smartadserver.com" />
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
<!-- Playwire Pre-connect <head> links -->
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
</head>
<!-- EU request: yes -->
<body class="c-bodyColor"
id="pageid-gallery" data-static-path="/themes/beta"
>
<script type="text/javascript">
0; // attempted fix for fouc in ff
</script>
<!-- sidebar -->
<div class="mobile-navigation">
<div class="mobile-nav-container">
<div class="mobile-nav-container-item left">
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
</div>
<div class="mobile-nav-container-item center">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-container-item right">
</div>
</div>
<div class="nav-ac-container">
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
<article class="nav-ac-content mobile-menu">
<div class="mobile-nav-content-container">
<div class="aligncenter">
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
<br />
</div>
<hr>
<h2><a href="/browse/">Browse</a></h2>
<h2><a href="/search/">Search</a></h2>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support &#x25BC;</h2></label>
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
<article class="nav-ac-content nav-ac-content-dropdown">
<a href="/journals/fender">News & Updates</a><br>
<a href="/journals/rednef">Events & Spotlight</a><br>
<a href="/help/">Help & Support</a><br>
<a href="/advertising.html">Advertising</a><br>
<a href="/blm">Black Lives Matter</a><br />
<a href="/changelog">Changelog</a><br />
<a href="/fight_spam">Internet Safety</a><br />
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a><br />
<a href="/route/banner_museum">Banner Museum</a><br />
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>SUPPORT FA</h3>
<a href="/plus/">Subscribe to FA+ </a><br>
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
<h3>RULES & POLICIES</h3>
<a href="/tos">Terms of Service</a><br>
<a href="/privacy">Privacy</a><br>
<a href="/coc">Code of Conduct</a><br>
<a href="/aup">Upload Policy</a>
<h3>SUPPORT</h3>
<a href="/help/#contact">Contact Us</a><br />
<a href="https://status.furaffinity.net/">Site Status</a>
</article>
</div>
<hr>
<h2><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</h2>
<h2></h2>
</div>
</article>
</div>
</div>
<nav id="ddmenu">
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
<div class="mobile-nav-logo">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
</div>
<div class="menu-icon"></div>
<ul class="navhideonmobile">
<li class="lileft">
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
<a class="top-heading" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
</li>
<li class="lileft">
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
<i class="caret"></i>
<div class="dropdown dropdown-left ">
<div class="dd-inner">
<div class="column">
<h3>Community</h3>
<a href="/journals/fender">News & Updates</a>
<a href="/journals/rednef">Events & Spotlight</a>
<a href="/help/">Help & Support</a>
<a href="/advertising.html">Advertising</a>
<a href="/blm/">Black Lives Matter</a>
<a href="/changelog">Changelog</a>
<a href="/fight_spam">Internet Safety</a>
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a>
<a href="/route/banner_museum">Banner Museum</a>
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>Rules & Policies</h3>
<a href="/tos">Terms of Service</a>
<a href="/privacy">Privacy</a>
<a href="/coc">Code of Conduct</a>
<a href="/aup">Upload Policy</a>
<h3>Support</h3>
<a href="/help/#contact">Contact Us</a>
<a href="https://status.furaffinity.net/">Site Status</a>
</div>
</div>
</div>
</li>
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
<div class="lileft hideonmobile">
<form id="searchbox" method="get" action="/search/">
<input type="search" name="q" placeholder="SEARCH">
<a href="/search">&nbsp;</a>
</form>
</div>
<li class="no-sub">
<span class="top-heading"><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</span>
</li>
</ul>
<script type="text/javascript">
_fajs.push(['init_sfw_button', '.sfw-toggle']);
</script>
</nav>
<script type="text/javascript">
_fajs.push(function(){
// all menus that should be opened only one at a time
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
var curr_input = $(evt.findElement('label').getAttribute('for'));
curr_input.next('.nav-ac-content').removeClassName('no-transition');
if(curr_input.checked === false) {
$$('.css-menu-toggle.only-one').each(function(elm){
var elm_input = $(elm.getAttribute('for'));
if(elm_input.checked === true) {
elm_input.next('.nav-ac-content').addClassName('no-transition');
elm_input.checked = false;
}
});
}
});
});
</script>
<div class="news-block">
</div>
<div id="main-window" class="footer-mobile-tweak g-wrapper">
<div id="header">
<!-- user profile banner -->
<site-banner>
<a href="/">
<picture>
<source media="(max-width: 799px)" srcset="//d.furaffinity.net/art/kazucreations/1770312990/profile_banner_mobile.jpg">
<source media="(min-width: 800px)" srcset="//d.furaffinity.net/art/kazucreations/1770312990/profile_banner.jpg">
<img src="//d.furaffinity.net/art/kazucreations/1770312990/profile_banner.jpg" alt="Profile Banner image">
</picture>
</a>
</site-banner>
<a name="top"></a>
</div>
<div id="site-content">
<!-- /header -->
<userpage-nav-header>
<userpage-nav-avatar>
<a class="current" href="/user/kazucreations/"><img alt="kazucreations" src="//a.furaffinity.net/1770312952/kazucreations.gif"/></a>
</userpage-nav-avatar>
<userpage-nav-user-details>
<div class="top-bar">
<username>
<div class="c-usernameBlock username-in-nav-bar">
<a class="c-usernameBlock__displayName js-displayName-block" href="/user/kazucreations/">
<span class="js-displayName">KazuCreations</span>
</a>
<a class="c-usernameBlock__userName js-userName-block" href="/user/kazucreations/">
<span><span class="c-usernameBlock__symbol" title="Member" alt="Member">~</span>kazucreations</span>
</a>
</div>
</username>
</div>
<div class="font-small">
<span class="user-title">
Fursuit Maker | <span class="hideonmobile">Registered:</span> <span class="popup_date" data-title-date="0" data-24-hour="0" data-time="1443468107" title="10 years ago" disabled>September 28, 2015 03:21:47 PM</span> </span>
</div>
<userpage-nav-links>
<ul class="user-nav-page-links">
<li><h3><a href="/user/kazucreations/">Home</a></h3></li>
<li><h3><a href="/gallery/kazucreations/">Gallery</a></h3></li>
<li><h3><a class="current" href="/scraps/kazucreations/">Scraps</a></h3></li>
<li><h3><a href="/favorites/kazucreations/">Favs</a></h3></li>
<li><h3><a href="/journals/kazucreations/">Journals</a></h3></li>
<li><h3><a href="/commissions/kazucreations/">Commissions</a></h3></li>
</ul>
</userpage-nav-links>
</userpage-nav-user-details>
<userpage-nav-interface-buttons>
<a class="button standard samewidth go" style="text-transform: capitalize;" id="watch-button" href="/watch/kazucreations/?key=">Watch</a>
<a class="button standard" href="/newpm/kazucreations/"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path d="M20 4H4c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm0 2v.511l-8 6.223-8-6.222V6h16zM4 18V9.044l7.386 5.745a.994.994 0 0 0 1.228 0L20 9.044 20.002 18H4z"></path></svg></a>
</userpage-nav-interface-buttons>
<script src="/themes/beta/js/widgets/user-nav-block-watch.js?u=2026050915"></script>
</userpage-nav-header>
<div class="clear"></div>
<!--- /USER NAV --->
<div id="page-galleryscraps">
<div id="columnpage">
<div class="sidebar">
<div class="folder-list">
<div class="user-folders">
<div class="container-item-top">
<h4>Gallery Folders </h4>
</div>
<div class="default-folders">
<ul style="list-style-type:none">
<li>
<a href="/gallery/kazucreations/" class="dotted">Main Gallery</a>
</li>
<li class="active">
&#x276f;&#x276f; <strong>Scraps</strong>
</li>
</ul>
</div>
<ul class="default-group" style="list-style-type:none">
<li>
<a href="/gallery/kazucreations/folder/88225/Headshots" title="25 submissions" class="dotted">Headshots</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/89257/Badges" title="23 submissions" class="dotted">Badges</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/104407/Multiple-Characters" title="9 submissions" class="dotted">Multiple Characters</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/176479/Digital-Art" title="55 submissions" class="dotted">Digital Art</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/191089/Fullbody" title="21 submissions" class="dotted">Fullbody</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/196612/Photos" title="6 submissions" class="dotted">Photos</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/294235/My-Suits" title="6 submissions" class="dotted">My Suits</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/312886/Traditional-Art" title="23 submissions" class="dotted">Traditional Art</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/442425/Videos" title="2 submissions" class="dotted">Videos</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/534625/Plushies" title="6 submissions" class="dotted">Plushies</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/537118/Clothing" title="1 submissions" class="dotted">Clothing</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/538750/Fursuit" title="8 submissions" class="dotted">Fursuit</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/559610/Inktober" title="13 submissions" class="dotted">Inktober</a>
</li>
<li>
<a href="/gallery/kazucreations/folder/620685/NDS" title="1 submissions" class="dotted">NDS</a>
</li>
</ul>
</div>
</div>
<div class="rectangleAd">
<ins data-id="sidebar" class="rectangleAd__slot format--mediumRectangle jsAdSlot"></ins>
</div>
</div>
<div class="content">
<div class="leaderboardAd">
<ins data-id="header_middle" class="leaderboardAd__slot format--leaderboard jsAdSlot hidden"></ins>
</div>
<section class="gallery-section">
<div class="section-body">
<div class="submission-list">
<div class="gallery-navigation aligncenter">
<div class="inline" style="width:32%">
&nbsp;
</div>
<div class="navigation-page-name inline" style="width:32%;">
Page #1 </div>
<div class="inline" style="width:32%">
&nbsp;
<!--button class="button standard" type="button">Next</button-->
</div>
</div>
<section id="gallery-gallery" class="gallery no-padding aligncenter no-artistname s-200 ">
<figure id="sid-30549394" class="r-general t-image">
<b>
<u>
<a href="/view/30549394/">
<noscript><span class="is-noScriptEnabled"></span></noscript>
<img class="blocked-content" data-tags="u_kazucreations c_artwork_digital t_all s_aquatic_other octopus sketch" alt="" src="//t.furaffinity.net/30549394@200-1550693644.jpg" data-width="141.406" data-height="200" style="width:141.406px; height:200px" loading="lazy" decoding="async" />
<i title="Click for description"></i>
</a>
</u>
</b>
<figcaption>
<p>
<a href="/view/30549394/" title="[NDS] #1 Octopus">[NDS] #1 Octopus</a>
</p>
<p>
<i>by</i> <a href="/user/kazucreations/" title="KazuCreations">KazuCreations</a>
</p>
</figcaption>
</figure>
</section>
<script type="text/javascript">
_fajs.push(['init_gallery', 'gallery-gallery']);
</script>
<div class="gallery-navigation aligncenter">
<div class="inline" style="width:32%">
&nbsp;
</div>
<div class="inline" style="width:32%">
<div class="mobile-button">
<button type="button" class="button standard toggle_titles">Disable Titles</button>
</div>
</div>
<div class="inline" style="width:32%">
&nbsp;
<!--button class="button standard" type="button">Next</button-->
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
<script id="js-submissionData" type="application/json">{"30549394":{"title":"[NDS] #1 Octopus","description":"asked a classmate what to draw and she said octopus,so yeah, have some xD","username":"KazuCreations","lower":"kazucreations"}}</script>
<script type="text/javascript">
var descriptions = JSON.parse(document.getElementById('js-submissionData').textContent);
</script>
</div>
<!-- /<div id="site-content"> -->
<div id="footer">
<div class="auto_link footer-links">
<a href="/advertising">Advertise</a> |
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
<a href="https://status.furaffinity.net/">Site Status</a> |
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
<a href="/route/i_was_here">Memorial</a> |
<a href="/tos">Terms of Service</a> |
<a href="/privacy">Privacy</a> |
<a href="/coc">Code of Conduct</a> |
<a href="/aup">Upload Policy</a>
</div>
<div class="footerAds">
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
<div class="footerAds__column">
<div class="footerAds__slot footerAds__slot--faLogo">
<img src="/themes/beta/img/banners/fa_logo.png?v2">
</div>
</div>
<div class="footerAds__column">
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_top"></ins>
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_bottom"></ins>
</div>
</div>
<div class="online-stats">
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
3334 <strong>guests</strong>,
8900 <strong>registered</strong>
and 76340 <strong>other</strong>
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
</div>
<small>Limit bot activity to periods with less than 10k registered users online.</small>
<br><br>
<strong>&copy; 2005-2026 Frost Dragon Art LLC</strong>
<div class="footnote">
Server Time: May 24, 2026 04:31 AM<br />
Page generated in 0.015 seconds<br />[ 38.7% PHP, 61.3% SQL ] (21 queries)<br />
</div>
</div>
</div>
<script type="text/javascript">
_fajs.push(function() {
var exists = getCookie('sz');
var saved = save_viewport_size();
if((!exists && saved) || (exists && saved && exists != saved)) {
//window.location.reload();
}
});
</script>
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
<script type="text/javascript">
var server_timestamp = 1779622302;
var client_timestamp = Date.now() / 1000;
var server_timestamp_delta = server_timestamp - client_timestamp;
var sfw_cookie_name = 'sfw';
var news_cookie_name = 'n';
//
document.addEventListener("DOMContentLoaded", (event) => {
//
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
});
</script>
<script type="text/javascript">
_fajs.push(function() {
var ddmenuOptions = {
menuId: "ddmenu",
linkIdToMenuHtml: null,
open: "onmouseover", // or "onclick"
delay: 1,
speed: 1,
keysNav: true,
license: "2c1f72"
};
var ddmenu = new Ddmenu(ddmenuOptions);
});
</script>
</body>
<!---
|\ /|
/_^ ^_\
\v/
The fox goes "moo!"
--->
</html>

2212
testdata/html/search_results.html vendored Normal file

File diff suppressed because one or more lines are too long

1048
testdata/html/submission.html vendored Normal file

File diff suppressed because one or more lines are too long

1008
testdata/html/submission_story.html vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!-- -->
<html>
<head>
<title>System Error</title>
<link href="/themes/beta/img/favicon.ico"/>
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" />
</head>
<body class="c-bodyColor">
<section style="margin: 30px auto; max-width: 800px;">
<div class="section-header">
<h2>System Error</h2>
</div>
<div class="section-body">
The submission you are trying to find is not in our database. <br>
<div class="alignright"><a class="button standard" href="javascript:history.go(-1)">Click here to go back</a></div>
</div>
</section>
</body>
</html>

458
testdata/html/user.html vendored Normal file
View File

@@ -0,0 +1,458 @@
<!DOCTYPE html>
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>Userpage of SoXX-TheFennec -- Fur Affinity [dot] net</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
<meta name="distribution" content="global" />
<meta name="copyright" content="Frost Dragon Art LLC" />
<meta name="robots" content="noai, noimageai" />
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
<!-- generic -->
<meta name="robots" content="noindex" />
<!-- og -->
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<!-- twitter -->
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<script type="text/javascript">
var _faurl = {
d: '//d.furaffinity.net',
s: '//d.furaffinity.net/media/static',
a: '//a.furaffinity.net',
r: '//rv.furaffinity.net',
t: '//t.furaffinity.net',
};
</script>
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
<!-- browser hints -->
<link rel="preconnect" href="//t.furaffinity.net" />
<link rel="preconnect" href="//a.furaffinity.net" />
<link rel="preconnect" href="//rv.furaffinity.net" />
<link rel="preconnect" href="https://www15.smartadserver.com" />
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
<!-- Playwire Pre-connect <head> links -->
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
</head>
<!-- EU request: yes -->
<body class="c-bodyColor"
id="pageid-redirect" data-static-path="/themes/beta"
>
<script type="text/javascript">
0; // attempted fix for fouc in ff
</script>
<!-- sidebar -->
<div class="mobile-navigation">
<div class="mobile-nav-container">
<div class="mobile-nav-container-item left">
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
</div>
<div class="mobile-nav-container-item center">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-container-item right">
</div>
</div>
<div class="nav-ac-container">
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
<article class="nav-ac-content mobile-menu">
<div class="mobile-nav-content-container">
<div class="aligncenter">
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
<br />
</div>
<hr>
<h2><a href="/browse/">Browse</a></h2>
<h2><a href="/search/">Search</a></h2>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support &#x25BC;</h2></label>
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
<article class="nav-ac-content nav-ac-content-dropdown">
<a href="/journals/fender">News & Updates</a><br>
<a href="/journals/rednef">Events & Spotlight</a><br>
<a href="/help/">Help & Support</a><br>
<a href="/advertising.html">Advertising</a><br>
<a href="/blm">Black Lives Matter</a><br />
<a href="/changelog">Changelog</a><br />
<a href="/fight_spam">Internet Safety</a><br />
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a><br />
<a href="/route/banner_museum">Banner Museum</a><br />
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>SUPPORT FA</h3>
<a href="/plus/">Subscribe to FA+ </a><br>
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
<h3>RULES & POLICIES</h3>
<a href="/tos">Terms of Service</a><br>
<a href="/privacy">Privacy</a><br>
<a href="/coc">Code of Conduct</a><br>
<a href="/aup">Upload Policy</a>
<h3>SUPPORT</h3>
<a href="/help/#contact">Contact Us</a><br />
<a href="https://status.furaffinity.net/">Site Status</a>
</article>
</div>
<hr>
<h2><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</h2>
<h2></h2>
</div>
</article>
</div>
</div>
<nav id="ddmenu">
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
<div class="mobile-nav-logo">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
</div>
<div class="menu-icon"></div>
<ul class="navhideonmobile">
<li class="lileft">
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
<a class="top-heading" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
</li>
<li class="lileft">
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
<i class="caret"></i>
<div class="dropdown dropdown-left ">
<div class="dd-inner">
<div class="column">
<h3>Community</h3>
<a href="/journals/fender">News & Updates</a>
<a href="/journals/rednef">Events & Spotlight</a>
<a href="/help/">Help & Support</a>
<a href="/advertising.html">Advertising</a>
<a href="/blm/">Black Lives Matter</a>
<a href="/changelog">Changelog</a>
<a href="/fight_spam">Internet Safety</a>
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a>
<a href="/route/banner_museum">Banner Museum</a>
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>Rules & Policies</h3>
<a href="/tos">Terms of Service</a>
<a href="/privacy">Privacy</a>
<a href="/coc">Code of Conduct</a>
<a href="/aup">Upload Policy</a>
<h3>Support</h3>
<a href="/help/#contact">Contact Us</a>
<a href="https://status.furaffinity.net/">Site Status</a>
</div>
</div>
</div>
</li>
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
<div class="lileft hideonmobile">
<form id="searchbox" method="get" action="/search/">
<input type="search" name="q" placeholder="SEARCH">
<a href="/search">&nbsp;</a>
</form>
</div>
<li class="no-sub">
<span class="top-heading"><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</span>
</li>
</ul>
<script type="text/javascript">
_fajs.push(['init_sfw_button', '.sfw-toggle']);
</script>
</nav>
<script type="text/javascript">
_fajs.push(function(){
// all menus that should be opened only one at a time
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
var curr_input = $(evt.findElement('label').getAttribute('for'));
curr_input.next('.nav-ac-content').removeClassName('no-transition');
if(curr_input.checked === false) {
$$('.css-menu-toggle.only-one').each(function(elm){
var elm_input = $(elm.getAttribute('for'));
if(elm_input.checked === true) {
elm_input.next('.nav-ac-content').addClassName('no-transition');
elm_input.checked = false;
}
});
}
});
});
</script>
<div class="news-block">
</div>
<div id="main-window" class="footer-mobile-tweak g-wrapper">
<div id="header">
<!-- site banner -->
<site-banner >
<map name="banner-map">
<area
shape="rect"
coords="441,144,1042,197"
href="https://link.vgen.co/furaffinity"
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
target="_blank" />
</map>
<a href="/">
<picture>
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
</picture>
</a>
</site-banner>
<a name="top"></a>
</div>
<div id="site-content">
<!-- /header -->
<!-- {redirect} -->
<div id="standardpage">
<section class="aligncenter notice-message user-submitted-links">
<div class="section-body alignleft">
<h2>System Message</h2>
<div class="redirect-message"><p class="link-override">The owner of this page has elected to make it available to registered users only.<br />To view the contents of this page please <a href="/login?ref=%2Fuser%2Fsoxx-thefennec%2F">log in</a> or <a href="/register/">create an account</a>.</p></div>
<div class="proceed-btn-container">
<a class="button standard go" href="javascript: history.back(-1)">Continue &raquo;</a>
</div>
</div>
</section>
</div>
</div>
<!-- /<div id="site-content"> -->
<div id="footer">
<div class="auto_link footer-links">
<a href="/advertising">Advertise</a> |
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
<a href="https://status.furaffinity.net/">Site Status</a> |
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
<a href="/route/i_was_here">Memorial</a> |
<a href="/tos">Terms of Service</a> |
<a href="/privacy">Privacy</a> |
<a href="/coc">Code of Conduct</a> |
<a href="/aup">Upload Policy</a>
</div>
<div class="footerAds">
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
<div class="footerAds__column">
<div class="footerAds__slot footerAds__slot--faLogo">
<img src="/themes/beta/img/banners/fa_logo.png?v2">
</div>
</div>
<div class="footerAds__column">
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_top"></ins>
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_bottom"></ins>
</div>
</div>
<div class="online-stats">
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
3334 <strong>guests</strong>,
8900 <strong>registered</strong>
and 76340 <strong>other</strong>
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
</div>
<small>Limit bot activity to periods with less than 10k registered users online.</small>
<br><br>
<strong>&copy; 2005-2026 Frost Dragon Art LLC</strong>
<div class="footnote">
Server Time: May 24, 2026 04:31 AM<br />
Page generated in 0.067 seconds<br />[ 89.6% PHP, 10.4% SQL ] (20 queries)<br />
</div>
</div>
</div>
<script type="text/javascript">
_fajs.push(function() {
var exists = getCookie('sz');
var saved = save_viewport_size();
if((!exists && saved) || (exists && saved && exists != saved)) {
//window.location.reload();
}
});
</script>
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
<script type="text/javascript">
var server_timestamp = 1779622298;
var client_timestamp = Date.now() / 1000;
var server_timestamp_delta = server_timestamp - client_timestamp;
var sfw_cookie_name = 'sfw';
var news_cookie_name = 'n';
//
document.addEventListener("DOMContentLoaded", (event) => {
//
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
});
</script>
<script type="text/javascript">
_fajs.push(function() {
var ddmenuOptions = {
menuId: "ddmenu",
linkIdToMenuHtml: null,
open: "onmouseover", // or "onclick"
delay: 1,
speed: 1,
keysNav: true,
license: "2c1f72"
};
var ddmenu = new Ddmenu(ddmenuOptions);
});
</script>
</body>
<!---
|\ /|
/_^ ^_\
\v/
The fox goes "moo!"
--->
</html>

2275
testdata/html/user_with_banner.html vendored Normal file

File diff suppressed because one or more lines are too long

457
testdata/html/user_with_shouts.html vendored Normal file
View File

@@ -0,0 +1,457 @@
<!DOCTYPE html>
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>Userpage of SoXX-TheFennec -- Fur Affinity [dot] net</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
<meta name="distribution" content="global" />
<meta name="copyright" content="Frost Dragon Art LLC" />
<meta name="robots" content="noai, noimageai" />
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
<!-- generic -->
<meta name="robots" content="noindex" />
<!-- og -->
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<!-- twitter -->
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
<script type="text/javascript">
var _faurl = {
d: '//d.furaffinity.net',
s: '//d.furaffinity.net/media/static',
a: '//a.furaffinity.net',
r: '//rv.furaffinity.net',
t: '//t.furaffinity.net',
};
</script>
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
<!-- browser hints -->
<link rel="preconnect" href="//t.furaffinity.net" />
<link rel="preconnect" href="//a.furaffinity.net" />
<link rel="preconnect" href="//rv.furaffinity.net" />
<link rel="preconnect" href="https://www15.smartadserver.com" />
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
<!-- Playwire Pre-connect <head> links -->
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
</head>
<!-- EU request: yes -->
<body class="c-bodyColor"
id="pageid-redirect" data-static-path="/themes/beta"
>
<script type="text/javascript">
0; // attempted fix for fouc in ff
</script>
<!-- sidebar -->
<div class="mobile-navigation">
<div class="mobile-nav-container">
<div class="mobile-nav-container-item left">
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
</div>
<div class="mobile-nav-container-item center">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-container-item right">
</div>
</div>
<div class="nav-ac-container">
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
<article class="nav-ac-content mobile-menu">
<div class="mobile-nav-content-container">
<div class="aligncenter">
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
<br />
</div>
<hr>
<h2><a href="/browse/">Browse</a></h2>
<h2><a href="/search/">Search</a></h2>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support &#x25BC;</h2></label>
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
<article class="nav-ac-content nav-ac-content-dropdown">
<a href="/journals/fender">News & Updates</a><br>
<a href="/journals/rednef">Events & Spotlight</a><br>
<a href="/help/">Help & Support</a><br>
<a href="/advertising.html">Advertising</a><br>
<a href="/blm">Black Lives Matter</a><br />
<a href="/changelog">Changelog</a><br />
<a href="/fight_spam">Internet Safety</a><br />
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a><br />
<a href="/route/banner_museum">Banner Museum</a><br />
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>SUPPORT FA</h3>
<a href="/plus/">Subscribe to FA+ </a><br>
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
<h3>RULES & POLICIES</h3>
<a href="/tos">Terms of Service</a><br>
<a href="/privacy">Privacy</a><br>
<a href="/coc">Code of Conduct</a><br>
<a href="/aup">Upload Policy</a>
<h3>SUPPORT</h3>
<a href="/help/#contact">Contact Us</a><br />
<a href="https://status.furaffinity.net/">Site Status</a>
</article>
</div>
<hr>
<h2><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</h2>
<h2></h2>
</div>
</article>
</div>
</div>
<nav id="ddmenu">
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
<div class="mobile-nav-logo">
<a class="mobile-nav-logo" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
</div>
<div class="menu-icon"></div>
<ul class="navhideonmobile">
<li class="lileft">
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
<a class="top-heading" href="/">
<picture>
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
</picture> </a>
</div>
</li>
<li class="lileft">
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
<i class="caret"></i>
<div class="dropdown dropdown-left ">
<div class="dd-inner">
<div class="column">
<h3>Community</h3>
<a href="/journals/fender">News & Updates</a>
<a href="/journals/rednef">Events & Spotlight</a>
<a href="/help/">Help & Support</a>
<a href="/advertising.html">Advertising</a>
<a href="/blm/">Black Lives Matter</a>
<a href="/changelog">Changelog</a>
<a href="/fight_spam">Internet Safety</a>
<h3>Community Walls</h3>
<a href="/route/i_was_here">I Was Here</a>
<a href="/route/banner_museum">Banner Museum</a>
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
<h3>Rules & Policies</h3>
<a href="/tos">Terms of Service</a>
<a href="/privacy">Privacy</a>
<a href="/coc">Code of Conduct</a>
<a href="/aup">Upload Policy</a>
<h3>Support</h3>
<a href="/help/#contact">Contact Us</a>
<a href="https://status.furaffinity.net/">Site Status</a>
</div>
</div>
</div>
</li>
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
<div class="lileft hideonmobile">
<form id="searchbox" method="get" action="/search/">
<input type="search" name="q" placeholder="SEARCH">
<a href="/search">&nbsp;</a>
</form>
</div>
<li class="no-sub">
<span class="top-heading"><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</span>
</li>
</ul>
<script type="text/javascript">
_fajs.push(['init_sfw_button', '.sfw-toggle']);
</script>
</nav>
<script type="text/javascript">
_fajs.push(function(){
// all menus that should be opened only one at a time
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
var curr_input = $(evt.findElement('label').getAttribute('for'));
curr_input.next('.nav-ac-content').removeClassName('no-transition');
if(curr_input.checked === false) {
$$('.css-menu-toggle.only-one').each(function(elm){
var elm_input = $(elm.getAttribute('for'));
if(elm_input.checked === true) {
elm_input.next('.nav-ac-content').addClassName('no-transition');
elm_input.checked = false;
}
});
}
});
});
</script>
<div class="news-block">
</div>
<div id="main-window" class="footer-mobile-tweak g-wrapper">
<div id="header">
<!-- site banner -->
<site-banner >
<map name="banner-map">
<area
shape="rect"
coords="441,144,1042,197"
href="https://link.vgen.co/furaffinity"
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
target="_blank" />
</map>
<a href="/">
<picture>
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
</picture>
</a>
</site-banner>
<a name="top"></a>
</div>
<div id="site-content">
<!-- /header -->
<!-- {redirect} -->
<div id="standardpage">
<section class="aligncenter notice-message user-submitted-links">
<div class="section-body alignleft">
<h2>System Message</h2>
<div class="redirect-message"><p class="link-override">The owner of this page has elected to make it available to registered users only.<br />To view the contents of this page please <a href="/login?ref=%2Fuser%2Fsoxx-thefennec%2F">log in</a> or <a href="/register/">create an account</a>.</p></div>
<div class="proceed-btn-container">
<a class="button standard go" href="javascript: history.back(-1)">Continue &raquo;</a>
</div>
</div>
</section>
</div>
</div>
<!-- /<div id="site-content"> -->
<div id="footer">
<div class="auto_link footer-links">
<a href="/advertising">Advertise</a> |
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
<a href="https://status.furaffinity.net/">Site Status</a> |
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
<a href="/route/i_was_here">Memorial</a> |
<a href="/tos">Terms of Service</a> |
<a href="/privacy">Privacy</a> |
<a href="/coc">Code of Conduct</a> |
<a href="/aup">Upload Policy</a>
</div>
<div class="footerAds">
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
<div class="footerAds__column">
<div class="footerAds__slot footerAds__slot--faLogo">
<img src="/themes/beta/img/banners/fa_logo.png?v2">
</div>
</div>
<div class="footerAds__column">
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_right"></ins>
</div>
</div>
<div class="online-stats">
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
3334 <strong>guests</strong>,
8900 <strong>registered</strong>
and 76340 <strong>other</strong>
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
</div>
<small>Limit bot activity to periods with less than 10k registered users online.</small>
<br><br>
<strong>&copy; 2005-2026 Frost Dragon Art LLC</strong>
<div class="footnote">
Server Time: May 24, 2026 04:31 AM<br />
Page generated in 0.058 seconds<br />[ 83.8% PHP, 16.2% SQL ] (20 queries)<br />
</div>
</div>
</div>
<script type="text/javascript">
_fajs.push(function() {
var exists = getCookie('sz');
var saved = save_viewport_size();
if((!exists && saved) || (exists && saved && exists != saved)) {
//window.location.reload();
}
});
</script>
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
<script type="text/javascript">
var server_timestamp = 1779622299;
var client_timestamp = Date.now() / 1000;
var server_timestamp_delta = server_timestamp - client_timestamp;
var sfw_cookie_name = 'sfw';
var news_cookie_name = 'n';
//
document.addEventListener("DOMContentLoaded", (event) => {
//
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
});
</script>
<script type="text/javascript">
_fajs.push(function() {
var ddmenuOptions = {
menuId: "ddmenu",
linkIdToMenuHtml: null,
open: "onmouseover", // or "onclick"
delay: 1,
speed: 1,
keysNav: true,
license: "2c1f72"
};
var ddmenu = new Ddmenu(ddmenuOptions);
});
</script>
</body>
<!---
|\ /|
/_^ ^_\
\v/
The fox goes "moo!"
--->
</html>

64
time.go Normal file
View File

@@ -0,0 +1,64 @@
package fa
import (
"fmt"
"strings"
"time"
)
// faDateLayouts lists every layout FA has been observed emitting in
// title= attributes of date elements. Tried in order.
var faDateLayouts = []string{
"January 2, 2006 03:04:05 PM", // "March 23, 2026 09:01:08 AM" current beta popup_date title
"January 2, 2006 3:04:05 PM",
"Jan 2, 2006 03:04:05 PM", // 3-letter month variant
"Jan 2, 2006 3:04:05 PM",
"Jan 2, 2006 03:04 PM", // legacy beta layout (no seconds)
"Jan 2, 2006 3:04 PM",
"2006-01-02T15:04:05Z07:00",
time.RFC3339,
}
// ParseFADate parses a FurAffinity-formatted date string. FA renders dates
// either as a "popup" with the full timestamp in a title attribute, or as a
// relative phrase ("5 hours ago") in the visible text. Callers should pass
// the title attribute when available.
//
// FA does not include timezone information in its displayed format; the site
// uses server-local time historically labelled as UTC-7. We treat parsed
// values as UTC because that is what the SDK consistently exposes callers
// who need a wall-clock display should convert.
func ParseFADate(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, fmt.Errorf("parse fa date: empty string")
}
cleaned := stripOrdinals(s)
for _, layout := range faDateLayouts {
if t, err := time.ParseInLocation(layout, cleaned, time.UTC); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("parse fa date %q: no matching layout", s)
}
// stripOrdinals removes English ordinal suffixes (st, nd, rd, th) from a date
// string so it can be parsed by Go's reference layout. "Mar 17th, 2026" →
// "Mar 17, 2026".
func stripOrdinals(s string) string {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= '0' && c <= '9') && i+2 < len(s) {
next2 := strings.ToLower(s[i+1 : i+3])
if next2 == "st" || next2 == "nd" || next2 == "rd" || next2 == "th" {
b.WriteByte(c)
i += 2
continue
}
}
b.WriteByte(c)
}
return b.String()
}

77
time_test.go Normal file
View File

@@ -0,0 +1,77 @@
package fa
import (
"testing"
"time"
)
func TestParseFADate(t *testing.T) {
must := func(layout, s string) time.Time {
t.Helper()
v, err := time.ParseInLocation(layout, s, time.UTC)
if err != nil {
t.Fatalf("setup: parse %q: %v", s, err)
}
return v
}
cases := []struct {
name string
in string
want time.Time
}{
{
name: "standard popup_date",
in: "Mar 17, 2026 04:21 PM",
want: must("Jan 2, 2006 03:04 PM", "Mar 17, 2026 04:21 PM"),
},
{
name: "ordinal suffix",
in: "Mar 17th, 2026 04:21 PM",
want: must("Jan 2, 2006 03:04 PM", "Mar 17, 2026 04:21 PM"),
},
{
name: "no padding",
in: "Mar 7, 2026 4:21 PM",
want: must("Jan 2, 2006 3:04 PM", "Mar 7, 2026 4:21 PM"),
},
{
name: "rfc3339",
in: "2026-03-17T16:21:00Z",
want: must(time.RFC3339, "2026-03-17T16:21:00Z"),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseFADate(tc.in)
if err != nil {
t.Fatalf("ParseFADate(%q) error = %v", tc.in, err)
}
if !got.Equal(tc.want) {
t.Errorf("ParseFADate(%q) = %s; want %s", tc.in, got, tc.want)
}
})
}
}
func TestParseFADate_Empty(t *testing.T) {
if _, err := ParseFADate(""); err == nil {
t.Fatal("expected error for empty string")
}
}
func TestStripOrdinals(t *testing.T) {
cases := map[string]string{
"Mar 1st, 2026": "Mar 1, 2026",
"Mar 2nd, 2026": "Mar 2, 2026",
"Mar 3rd, 2026": "Mar 3, 2026",
"Mar 4th, 2026": "Mar 4, 2026",
"already clean": "already clean",
"Mar 1st 2nd": "Mar 1 2",
}
for in, want := range cases {
if got := stripOrdinals(in); got != want {
t.Errorf("stripOrdinals(%q) = %q; want %q", in, got, want)
}
}
}

222
transport.go Normal file
View File

@@ -0,0 +1,222 @@
package fa
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)
// transport is the SDK's http.RoundTripper. It is the only place where
// rate limiting, header injection, Cloudflare detection, and retries are
// enforced callers cannot bypass it because the *http.Client wired into
// Colly is built around it. Cookies live on the *http.Client's Jar, not here.
type transport struct {
base http.RoundTripper
limiter *rateLimiter
userAgent string
maxRetries int
logger *slog.Logger
}
// defaultMaxRetries is the cap on automatic 429/5xx retries per request.
// Three retries with exponential backoff (1s/2s/4s) is enough to absorb
// short blips without masking real outages.
const defaultMaxRetries = 3
// RoundTrip implements http.RoundTripper. It gates on the rate limiter,
// injects the User-Agent header, retries on transient failures, and
// classifies Cloudflare challenges as non-retryable user-actionable errors.
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.userAgent != "" && req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", t.userAgent)
}
// FA serves Brotli-encoded HTML only to UAs that advertise it; the stdlib
// transport handles gzip transparently when we don't set this header
// ourselves, so leave it alone.
var lastErr error
for attempt := 0; attempt <= t.maxRetries; attempt++ {
waitStart := time.Now()
if err := t.limiter.wait(req.Context()); err != nil {
return nil, err
}
waitMs := time.Since(waitStart).Milliseconds()
// Each attempt needs an independent body reader; if the caller passed
// one we trust them to have provided GetBody (stdlib does this for
// the common cases). Without a body, this branch is a no-op.
if attempt > 0 && req.GetBody != nil {
body, err := req.GetBody()
if err != nil {
return nil, err
}
req.Body = body
}
rtStart := time.Now()
resp, err := t.base.RoundTrip(req)
durMs := time.Since(rtStart).Milliseconds()
t.logRequest(req, resp, err, durMs, waitMs)
if err != nil {
lastErr = err
if !isTransientNetErr(err) || attempt == t.maxRetries {
return nil, err
}
t.sleepBackoff(attempt)
continue
}
if isCloudflareChallenge(resp) {
drainAndClose(resp.Body)
return nil, ErrCloudflareChallenge
}
switch {
case resp.StatusCode == http.StatusTooManyRequests:
drainAndClose(resp.Body)
if attempt == t.maxRetries {
return nil, ErrRateLimited
}
t.sleepRetryAfter(resp, attempt)
continue
case resp.StatusCode >= 500 && resp.StatusCode <= 599:
drainAndClose(resp.Body)
if attempt == t.maxRetries {
return nil, &HTTPError{StatusCode: resp.StatusCode, URL: req.URL.String()}
}
t.sleepBackoff(attempt)
continue
}
return resp, nil
}
if lastErr != nil {
return nil, lastErr
}
return nil, errors.New("fa: transport exhausted retries without a response")
}
// logRequest emits one structured slog record per HTTP round-trip so a
// consumer can trace request timings and rate-limit waits. Only the URL host
// is logged never the path or query to avoid leaking what was fetched.
func (t *transport) logRequest(req *http.Request, resp *http.Response, err error, durMs, waitMs int64) {
if t.logger == nil {
return
}
var host string
if req != nil && req.URL != nil {
host = req.URL.Host
}
status := 0
if err == nil && resp != nil {
status = resp.StatusCode
}
// InfoContext (not Info) so the request's context propagates to the slog
// handler. A tracing consumer can carry an active span in that context
// and nest this HTTP request as a child span of it.
ctx := context.Background()
if req != nil {
ctx = req.Context()
}
t.logger.InfoContext(ctx, "fa.request",
"host", host,
"durationMs", durMs,
"status", status,
"rateWaitMs", waitMs,
)
}
// sleepBackoff sleeps for an exponential interval based on attempt index.
// attempt is 0-based, so the sequence is 1s, 2s, 4s.
func (t *transport) sleepBackoff(attempt int) {
d := time.Duration(1<<attempt) * time.Second
if t.logger != nil {
t.logger.Debug("fa: backoff", "attempt", attempt+1, "sleep", d)
}
time.Sleep(d)
}
// sleepRetryAfter honours the server's Retry-After header (seconds or HTTP
// date), capping at 60s to bound worst-case latency. Falls back to
// exponential backoff if the header is missing or unparseable.
func (t *transport) sleepRetryAfter(resp *http.Response, attempt int) {
const cap = 60 * time.Second
if h := resp.Header.Get("Retry-After"); h != "" {
if secs, err := strconv.Atoi(strings.TrimSpace(h)); err == nil && secs >= 0 {
d := time.Duration(secs) * time.Second
if d > cap {
d = cap
}
if t.logger != nil {
t.logger.Debug("fa: retry-after", "sleep", d)
}
time.Sleep(d)
return
}
if when, err := http.ParseTime(h); err == nil {
d := time.Until(when)
if d < 0 {
d = time.Second
}
if d > cap {
d = cap
}
time.Sleep(d)
return
}
}
t.sleepBackoff(attempt)
}
// isCloudflareChallenge inspects a response for the signatures Cloudflare
// emits when it interposes a challenge. We treat these as non-retryable
// because the SDK has no JS engine; the caller must refresh cf_clearance.
func isCloudflareChallenge(resp *http.Response) bool {
if resp == nil {
return false
}
if v := resp.Header.Get("cf-mitigated"); strings.EqualFold(v, "challenge") {
return true
}
// Managed challenge / IUAM page: 403 or 503 with cf-ray and HTML body.
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusServiceUnavailable {
if resp.Header.Get("cf-ray") != "" && strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
return true
}
}
return false
}
// isTransientNetErr returns true for the kinds of network errors that are
// reasonable to retry (timeouts, EOFs from broken keepalive connections).
// Anything else DNS failures, refused connections surfaces immediately.
func isTransientNetErr(err error) bool {
if err == nil {
return false
}
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
return true
}
var ne interface{ Timeout() bool }
if errors.As(err, &ne) && ne.Timeout() {
return true
}
return false
}
// drainAndClose flushes the response body so the underlying TCP connection
// can be returned to the pool. Failing to do this on a retry path leaks
// connections under load.
func drainAndClose(body io.ReadCloser) {
if body == nil {
return
}
_, _ = io.Copy(io.Discard, body)
_ = body.Close()
}

250
transport_test.go Normal file
View File

@@ -0,0 +1,250 @@
package fa
import (
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
)
// newTestTransport builds a transport that wraps a base RoundTripper for
// httptest use. Rate limit is fast (no waits between requests) so retry
// tests run in subseconds.
func newTestTransport(base http.RoundTripper, maxRetries int) *transport {
return &transport{
base: base,
limiter: newRateLimiter(time.Microsecond, 16, false),
userAgent: "test-agent",
maxRetries: maxRetries,
}
}
func TestTransport_DetectsCloudflareChallenge_FromHeader(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("cf-mitigated", "challenge")
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 3)}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
_, err := c.Do(req)
if !errors.Is(err, ErrCloudflareChallenge) {
t.Fatalf("got %v; want ErrCloudflareChallenge", err)
}
}
func TestTransport_DetectsCloudflareChallenge_From503CFRay(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("cf-ray", "abc123-FRA")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("<html>Just a moment...</html>"))
}))
defer srv.Close()
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 3)}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
_, err := c.Do(req)
if !errors.Is(err, ErrCloudflareChallenge) {
t.Fatalf("got %v; want ErrCloudflareChallenge", err)
}
}
func TestTransport_RetriesOn5xx_ThenSucceeds(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := hits.Add(1)
if n < 3 {
w.WriteHeader(http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
// Override sleepBackoff: use a transport whose limiter pace makes
// "exponential" 1s/2s waits unbearable for tests. Inject a small
// limiter and accept the 1s+2s actual sleep or short-circuit by
// using maxRetries=0 path. Instead we exercise just 1 retry by
// returning 502 once: keep test fast.
hits.Store(0)
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := hits.Add(1)
if n == 1 {
w.WriteHeader(http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 1)}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
resp, err := c.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d; want 200", resp.StatusCode)
}
if hits.Load() != 2 {
t.Fatalf("hits = %d; want 2 (initial 502 + one retry)", hits.Load())
}
}
func TestTransport_Returns429AsErrRateLimited_AfterExhaustingRetries(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "0") // zero seconds => fast test
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 1)}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
_, err := c.Do(req)
if !errors.Is(err, ErrRateLimited) {
t.Fatalf("got %v; want ErrRateLimited", err)
}
}
func TestTransport_InjectsUserAgent(t *testing.T) {
var seen string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Get("User-Agent")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 0)}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
resp, err := c.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
resp.Body.Close()
if seen != "test-agent" {
t.Fatalf("UA = %q; want %q", seen, "test-agent")
}
}
func TestTransport_HonorsRetryAfter(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := hits.Add(1)
if n == 1 {
w.Header().Set("Retry-After", "0") // 0 = retry immediately
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 2)}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
resp, err := c.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
resp.Body.Close()
if hits.Load() < 2 {
t.Fatalf("hits = %d; want >= 2", hits.Load())
}
if v, _ := strconv.Atoi(resp.Header.Get("Retry-After")); v != 0 && resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected final state")
}
}
// traceCtxKey is a test-only context key used to smuggle a sentinel value
// through a request's context so a slog handler can prove it received it.
type traceCtxKey struct{}
// captureHandler is a minimal slog.Handler that records the context handed
// to Handle. A context-aware tracing handler reads the active span from that
// context; this test stand-in just checks the context arrives at all.
type captureHandler struct {
mu sync.Mutex
ctx context.Context
handled bool
}
func (h *captureHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *captureHandler) Handle(ctx context.Context, _ slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.ctx = ctx
h.handled = true
return nil
}
func (h *captureHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *captureHandler) WithGroup(string) slog.Handler { return h }
// TestTransport_LogRequest_PropagatesRequestContext guards SDK issue #24:
// logRequest must emit its record with InfoContext(req.Context(), …), not
// Info(…), so a context-aware slog.Handler can recover the caller's active
// span and nest the HTTP span beneath it. Regressing to Info() would hand the
// handler context.Background() and orphan every HTTP span.
func TestTransport_LogRequest_PropagatesRequestContext(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
h := &captureHandler{}
tr := newTestTransport(http.DefaultTransport, 0)
tr.logger = slog.New(h)
c := &http.Client{Transport: tr}
ctx := context.WithValue(context.Background(), traceCtxKey{}, "trace-123")
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
resp, err := c.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
resp.Body.Close()
h.mu.Lock()
got, handled := h.ctx, h.handled
h.mu.Unlock()
if !handled {
t.Fatal("slog handler was never called; logRequest emitted no record")
}
if got == nil {
t.Fatal("slog handler received a nil context")
}
if v, _ := got.Value(traceCtxKey{}).(string); v != "trace-123" {
t.Fatalf("handler context value = %q; want %q the request's context did not reach the slog record (logRequest must use InfoContext)", v, "trace-123")
}
}
func TestTransport_ContextCancellationPropagates(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Block until the request context is cancelled.
<-r.Context().Done()
}))
defer srv.Close()
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 0)}
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(30 * time.Millisecond)
cancel()
}()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
_, err := c.Do(req)
if err == nil {
t.Fatal("expected cancellation error")
}
}

54
user.go Normal file
View File

@@ -0,0 +1,54 @@
package fa
import (
"context"
"fmt"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// User is a user's full profile page, as seen on /user/{name}/.
type User struct {
UserRef
Title string // user-set "title" / artist headline
Joined time.Time // registration date
BioHTML string // raw profile HTML
BioText string // plaintext convenience
Stats UserStats // submission/view/journal/watcher counts
Contacts []UserContact // social/contact rows
Shouts []Shout // most recent shouts shown on the page
FeaturedSub *SubmissionRef // pinned/featured submission, if any
SiteBanner *SiteBanner // header banner; IsCustom=false when FA's default is shown
// Watched is true when the logged-in viewer currently watches this user
// i.e. the profile header renders an "Unwatch" button. It is false
// both when the viewer does not watch the user and when no watch button
// is present at all (anonymous session, or viewing your own page), so
// it is only meaningful on another user's page while logged in.
Watched bool
}
// GetUser fetches a user profile by URL-safe name (FA's lowercase login form).
func (c *Client) GetUser(ctx context.Context, name string) (*User, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("fa: GetUser: empty name")
}
var out *User
err := c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error {
u, err := parseUser(name, doc)
if err != nil {
return err
}
out = u
return nil
})
if err != nil {
return nil, err
}
return out, nil
}

219
user_parser.go Normal file
View File

@@ -0,0 +1,219 @@
package fa
import (
"fmt"
"strings"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// parseUser pulls a [User] out of /user/{name}/.
//
// FA's profile page has many optional sections; parser treats the headline
// (display name + avatar) as required, everything else as best-effort.
func parseUser(name string, doc *goquery.Document) (*User, error) {
u := &User{UserRef: UserRef{Name: strings.ToLower(strings.TrimSpace(name))}}
// Headline username + avatar.
header := doc.Find("userpage-nav-user-details, div.userpage-nav-user-details, div.username").First()
if header.Length() == 0 {
header = doc.Find("h1.username, h2.username").First()
}
u.DisplayName = firstNonEmpty(
// Scope the display name to the profile header first an unscoped
// .c-usernameBlock__displayName also matches the logged-in viewer's
// block elsewhere on the page.
trimText(doc.Find("userpage-nav-user-details .js-displayName").First()),
trimText(doc.Find("userpage-nav-user-details .c-usernameBlock__displayName").First()),
trimText(doc.Find(".username h2 span").First()),
trimText(doc.Find(".username h1").First()),
trimText(doc.Find(".c-usernameBlock__displayName").First()),
trimText(doc.Find(".c-usernameBlockSimple__displayName").First()),
trimText(header),
u.Name,
)
// The profile owner's avatar lives in the <userpage-nav-avatar> header
// element. It must be scoped there: img.avatar / img.loggedin_user_avatar
// in the site navigation belong to the logged-in viewer, and an unscoped
// selector picks the viewer's avatar on every logged-in page load.
u.AvatarURL = urls.AbsoluteCDN(firstNonEmpty(
trimAttr(doc.Find("userpage-nav-avatar img").First(), "src"),
trimAttr(doc.Find("div.userpage-nav-avatar img").First(), "src"),
trimAttr(doc.Find("img.user-nav-avatar").First(), "src"),
))
if u.DisplayName == "" {
return nil, fmt.Errorf("%w: user %q: missing display name", ErrParse, name)
}
// "Title" headline shown under the username.
u.Title = firstNonEmpty(
trimText(doc.Find(".userpage-flex-item.username .font-small").First()),
trimText(doc.Find(".user-nav-user-details .c-usernameBlock__subtitle").First()),
)
// Registered-on date appears in profile-meta or in a span.popup_date.
doc.Find("span.popup_date").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
raw := firstNonEmpty(trimAttr(sel, "title"), trimText(sel))
if t, err := ParseFADate(raw); err == nil {
u.Joined = t
return false
}
return true
})
// Profile bio: large HTML block on the left column.
bio := firstNonEmptySel(doc,
"div.userpage-profile",
"div.profile-page-body",
"div.profile-description",
)
if bio != nil {
u.BioHTML = htmlOf(bio)
u.BioText = strings.TrimSpace(bio.Text())
}
// Stats: the "Stats" box in the right column is a flat run of
// <span class="highlight">Label:</span> value<br/>
// pairs inside one or more <div class="cell">. The value is the bare
// text node that immediately follows each highlight span.
doc.Find("div.userpage-section-right div.cell").Each(func(_ int, cell *goquery.Selection) {
nodes := cell.Contents()
nodes.Each(func(i int, node *goquery.Selection) {
if !node.Is("span.highlight") {
return
}
label := strings.ToLower(strings.TrimRight(trimText(node), ":"))
val := parseStatNumber(nodes.Eq(i + 1).Text())
switch label {
case "submissions":
u.Stats.Submissions = val
case "favs", "favorites":
u.Stats.Favorites = val
case "views", "page visits":
u.Stats.Views = val
case "comments earned", "comments":
u.Stats.Comments = val
case "journals":
u.Stats.Journals = val
}
})
})
// Watcher / watching counts are NOT in the stats box FA renders them
// in the "Recent Watchers" / "Recently Watched" section headers as
// "View List (Watched by N)" / "View List (Watching N)".
u.Stats.Watchers = parseStatNumber(trimText(doc.Find("section.watched-by-block .section-header a").First()))
u.Stats.Watching = parseStatNumber(trimText(doc.Find("section.is-watching-block .section-header a").First()))
// Contact information rows.
doc.Find("div.user-contact-user-info, .userpage-contact-information li").Each(func(_ int, sel *goquery.Selection) {
site := trimText(sel.Find("span.user-contact-item-name, .contact-site").First())
linkSel := sel.Find("a").First()
handle := trimText(linkSel)
if handle == "" {
handle = strings.TrimSpace(sel.Text())
}
href, _ := linkSel.Attr("href")
if site != "" || handle != "" {
u.Contacts = append(u.Contacts, UserContact{
Site: site,
Handle: handle,
URL: urls.AbsoluteCDN(href),
})
}
})
// Featured submission: small preview thumbnail on the profile.
if feat := doc.Find("div.userpage-featured-submission a, section.userpage-section-right figure a").First(); feat.Length() > 0 {
href, _ := feat.Attr("href")
if id := extractIntFromHref(href); id > 0 {
u.FeaturedSub = &SubmissionRef{
ID: SubmissionID(id),
Title: trimAttr(feat, "title"),
ThumbURL: urls.AbsoluteCDN(trimAttr(feat.Find("img").First(), "src")),
}
}
}
// Shouts: anchored by <a id="shout-NNN"> inside a
// <div class="comment_container"> (underscore). Beta uses custom HTML5
// elements <comment-container>/<comment-username>/<comment-date>/
// <comment-user-text> within that wrapper goquery matches them by tag.
doc.Find("a[id^='shout-']").Each(func(_ int, anchor *goquery.Selection) {
container := anchor.ParentsFiltered("div.comment_container").First()
if container.Length() == 0 {
// Fallback for legacy markup where the anchor sits as a sibling
// of a table or comment-container directly.
container = anchor.Parent()
}
shout := Shout{}
authorLink := container.Find("a.c-usernameBlock__displayName").First()
if authorLink.Length() > 0 {
href, _ := authorLink.Attr("href")
shout.Author = UserRef{
DisplayName: trimText(authorLink.Find("span.js-displayName").First()),
AvatarURL: urls.AbsoluteCDN(trimAttr(container.Find("img.comment_useravatar").First(), "src")),
}
if shout.Author.DisplayName == "" {
shout.Author.DisplayName = trimText(authorLink)
}
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
shout.Author.Name = strings.ToLower(parts[1])
}
}
shout.PostedAt = parsePopupDate(container.Find("comment-date span.popup_date").First())
if shout.PostedAt.IsZero() {
shout.PostedAt = parsePopupDate(container.Find("span.popup_date").First())
}
body := container.Find("comment-user-text").First()
if body.Length() == 0 {
body = container.Find(".comment_text, .comment-user-text").First()
}
shout.BodyHTML = htmlOf(body)
if shout.Author.DisplayName != "" || shout.BodyHTML != "" {
u.Shouts = append(u.Shouts, shout)
}
})
// Watch state: the header carries a Watch/Unwatch button when the viewer
// is logged in and looking at another user's page. An "/unwatch/" link
// means the viewer currently watches this user.
if _, unwatch := findWatchLinks(doc, u.Name); unwatch != "" {
u.Watched = true
}
// Site banner: the <site-banner> element in the page header holds either
// the artist's own banner (URL under /art/<name>/, uploaded via
// /controls/profilebanner/) or when none is set FA's site-wide promo
// banner (URL under /media/banners/).
if banner := doc.Find("site-banner img").First(); banner.Length() > 0 {
src := urls.AbsoluteCDN(trimAttr(banner, "src"))
if src != "" {
u.SiteBanner = &SiteBanner{
ImageURL: src,
IsCustom: strings.Contains(src, "/art/"+u.Name+"/"),
}
}
}
return u, nil
}
// firstNonEmptySel returns the first selection matching any of the selectors,
// or nil if none match. Useful for parser code that needs to tolerate
// alternate beta-theme markup.
func firstNonEmptySel(doc *goquery.Document, selectors ...string) *goquery.Selection {
for _, sel := range selectors {
s := doc.Find(sel).First()
if s.Length() > 0 {
return s
}
}
return nil
}

243
user_parser_test.go Normal file
View File

@@ -0,0 +1,243 @@
package fa
import (
"bytes"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
// syntheticUserHTML mirrors the real /user/{name}/ markup closely enough to
// catch the bugs the fictitious old fixture hid: a logged-in viewer's avatar
// in the site nav (which must NOT be picked as the profile avatar), the
// `<span class="highlight">Label:</span> value` stats strip, and the
// watcher/watching counts that live in the section headers rather than the
// stats box.
const syntheticUserHTML = `<html><body>
<!-- site navigation: this is the logged-in VIEWER, not the profile owner -->
<img class="loggedin_user_avatar avatar" alt="Viewer" src="//a.furaffinity.net/0/viewer.gif"/>
<userpage-nav-header>
<userpage-nav-avatar>
<a class="current" href="/user/somefurry/"><img alt="somefurry" src="//a.furaffinity.net/123/somefurry.gif"/></a>
</userpage-nav-avatar>
<userpage-nav-user-details>
<div class="top-bar"><username>
<div class="c-usernameBlock username-in-nav-bar">
<a class="c-usernameBlock__displayName js-displayName-block" href="/user/somefurry/">
<span class="js-displayName">SomeFurry</span>
</a>
</div>
</username></div>
<div class="font-small"><span class="user-title">
Artist | <span class="hideonmobile">Registered:</span>
<span class="popup_date" data-time="1519896600" title="Mar 1, 2018 09:30 AM">8 years ago</span>
</span></div>
</userpage-nav-user-details>
<userpage-nav-interface-buttons>
<a class="button standard samewidth stop" id="watch-button" href="/unwatch/somefurry/?key=abc">Unwatch</a>
</userpage-nav-interface-buttons>
</userpage-nav-header>
<div class="userpage-profile"><p>Welcome to my profile.</p></div>
<section class="userpage-right-column">
<div class="userpage-section-right">
<div class="section-header"><h2>Stats</h2></div>
<div class="section-body"><div class="table">
<div class="cell">
<span class="highlight">Views:</span> 1,176 <br/>
<span class="highlight">Submissions:</span> 1,234<br/>
<span class="highlight">Favs:</span> 567
</div>
<div class="cell">
<span class="highlight">Comments Earned:</span> 85<br/>
<span class="highlight">Comments Made:</span> 83<br/>
<span class="highlight">Journals:</span> 12
</div>
</div></div>
</div>
</section>
<section class="userpage-left-column watched-by-block">
<div class="userpage-section-left"><div class="section-header">
<div class="floatright"><h3><a href="/watchlist/to/somefurry/">View List (Watched by 89)</a></h3></div>
<h2>Recent Watchers</h2>
</div></div>
</section>
<section class="userpage-left-column is-watching-block">
<div class="userpage-section-left"><div class="section-header">
<div class="floatright"><h3><a href="/watchlist/by/somefurry/">View List (Watching 10)</a></h3></div>
<h2>Recently Watched</h2>
</div></div>
</section>
</body></html>`
func TestParseUser_Synthetic(t *testing.T) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticUserHTML))
if err != nil {
t.Fatalf("setup: %v", err)
}
u, err := parseUser("SomeFurry", doc)
if err != nil {
t.Fatalf("parseUser: %v", err)
}
if u.DisplayName != "SomeFurry" {
t.Errorf("DisplayName = %q; want SomeFurry", u.DisplayName)
}
if u.Name != "somefurry" {
t.Errorf("Name = %q; want somefurry (lowercased)", u.Name)
}
if u.AvatarURL != "https://a.furaffinity.net/123/somefurry.gif" {
t.Errorf("AvatarURL = %q; want the profile owner's avatar, not the logged-in viewer's", u.AvatarURL)
}
if u.Stats.Submissions != 1234 {
t.Errorf("Stats.Submissions = %d; want 1234", u.Stats.Submissions)
}
if u.Stats.Favorites != 567 {
t.Errorf("Stats.Favorites = %d; want 567", u.Stats.Favorites)
}
if u.Stats.Views != 1176 {
t.Errorf("Stats.Views = %d; want 1176", u.Stats.Views)
}
if u.Stats.Comments != 85 {
t.Errorf("Stats.Comments = %d; want 85", u.Stats.Comments)
}
if u.Stats.Journals != 12 {
t.Errorf("Stats.Journals = %d; want 12", u.Stats.Journals)
}
if u.Stats.Watchers != 89 {
t.Errorf("Stats.Watchers = %d; want 89", u.Stats.Watchers)
}
if u.Stats.Watching != 10 {
t.Errorf("Stats.Watching = %d; want 10", u.Stats.Watching)
}
if !u.Watched {
t.Error("Watched = false; want true (page shows an Unwatch button)")
}
if !strings.Contains(u.BioText, "Welcome") {
t.Errorf("BioText missing expected content: %q", u.BioText)
}
if u.Joined.Year() != 2018 {
t.Errorf("Joined.Year = %d; want 2018", u.Joined.Year())
}
}
func TestParseUser_RealFixture(t *testing.T) {
raw := loadFixture(t, "user.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
u, err := parseUser("fixture", doc)
if err != nil {
t.Fatalf("parseUser(real): %v", err)
}
// Values below are read directly from testdata/html/user.html. They guard
// against FA markup drift a zero/empty here means a selector went stale.
if u.DisplayName != "SoXX-TheFennec" {
t.Errorf("DisplayName = %q; want SoXX-TheFennec", u.DisplayName)
}
if u.AvatarURL != "https://a.furaffinity.net/1515442832/soxx-thefennec.gif" {
t.Errorf("AvatarURL = %q; want the profile owner's avatar", u.AvatarURL)
}
if u.Stats.Submissions != 30 {
t.Errorf("Stats.Submissions = %d; want 30", u.Stats.Submissions)
}
if u.Stats.Favorites != 180 {
t.Errorf("Stats.Favorites = %d; want 180", u.Stats.Favorites)
}
if u.Stats.Views != 1176 {
t.Errorf("Stats.Views = %d; want 1176", u.Stats.Views)
}
if u.Stats.Comments != 85 {
t.Errorf("Stats.Comments = %d; want 85", u.Stats.Comments)
}
if u.Stats.Watchers != 50 {
t.Errorf("Stats.Watchers = %d; want 50", u.Stats.Watchers)
}
if u.Stats.Watching != 315 {
t.Errorf("Stats.Watching = %d; want 315", u.Stats.Watching)
}
if u.SiteBanner == nil {
t.Fatal("SiteBanner = nil; want the default site banner populated")
}
if u.SiteBanner.IsCustom {
t.Errorf("SiteBanner.IsCustom = true; want false (user.html shows FA's default site banner)")
}
if !strings.Contains(u.SiteBanner.ImageURL, "/media/banners/") {
t.Errorf("SiteBanner.ImageURL = %q; want a /media/banners/ URL", u.SiteBanner.ImageURL)
}
}
// syntheticUserWithCustomBannerHTML reproduces FA's <site-banner> markup for a
// user who has uploaded their own profile banner via /controls/profilebanner/.
// The image lives under /art/<name>/ rather than /media/banners/, which is the
// signal the parser uses to set IsCustom.
const syntheticUserWithCustomBannerHTML = `<html><body>
<site-banner>
<a href="/">
<picture>
<source media="(max-width: 799px)" srcset="//d.furaffinity.net/art/somefurry/1716929854/profile_banner_mobile.jpg">
<source media="(min-width: 800px)" srcset="//d.furaffinity.net/art/somefurry/1716929854/profile_banner.jpg">
<img src="//d.furaffinity.net/art/somefurry/1716929854/profile_banner.jpg" alt="Profile Banner image">
</picture>
</a>
</site-banner>
<userpage-nav-header>
<userpage-nav-avatar>
<a class="current" href="/user/somefurry/"><img alt="somefurry" src="//a.furaffinity.net/123/somefurry.gif"/></a>
</userpage-nav-avatar>
<userpage-nav-user-details>
<div class="c-usernameBlock"><span class="c-usernameBlock__displayName">SomeFurry</span></div>
</userpage-nav-user-details>
</userpage-nav-header>
</body></html>`
// TestParseUser_SiteBanner_RealFixture validates the custom-banner case
// against a real captured profile (gillpanda, who has uploaded a profile
// banner). The fixture is captured by the `fixtures` build-tagged refresh
// test using FA_TEST_USER_WITH_BANNER; the test skips cleanly when absent.
func TestParseUser_SiteBanner_RealFixture(t *testing.T) {
raw := loadFixture(t, "user_with_banner.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
u, err := parseUser("gillpanda", doc)
if err != nil {
t.Fatalf("parseUser(real): %v", err)
}
if u.SiteBanner == nil {
t.Fatal("SiteBanner = nil; want a populated custom banner")
}
if !u.SiteBanner.IsCustom {
t.Errorf("SiteBanner.IsCustom = false; want true (gillpanda has a custom banner)")
}
if !strings.Contains(u.SiteBanner.ImageURL, "/art/gillpanda/") {
t.Errorf("SiteBanner.ImageURL = %q; want a /art/gillpanda/ URL", u.SiteBanner.ImageURL)
}
}
func TestParseUser_SiteBanner_Custom(t *testing.T) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticUserWithCustomBannerHTML))
if err != nil {
t.Fatalf("setup: %v", err)
}
u, err := parseUser("SomeFurry", doc)
if err != nil {
t.Fatalf("parseUser: %v", err)
}
if u.SiteBanner == nil {
t.Fatal("SiteBanner = nil; want the custom banner populated")
}
if !u.SiteBanner.IsCustom {
t.Errorf("SiteBanner.IsCustom = false; want true (URL is under /art/<name>/)")
}
want := "https://d.furaffinity.net/art/somefurry/1716929854/profile_banner.jpg"
if u.SiteBanner.ImageURL != want {
t.Errorf("SiteBanner.ImageURL = %q; want %q", u.SiteBanner.ImageURL, want)
}
}